Skip to content

Commit 676eeb1

Browse files
committed
Hand a revoked review's reports back to the community gate
The first revoke rebuilt a reviewed row's community reports as one unbucketed event on the row's last_seen, which included the review date. A later report on the original day then counted as a day apart, bucketed reporters lost the three-reporter rule, a row the community had published on its own was unpublished, and the review's placeholder count of 1 carried into the ledger. Now the ledger keeps the real events: approving a pending number no longer pops its state, the quorum recheck keeps the state of a reviewed row whose only other support is community reports, and reports that arrive while the review holds a row up are recorded too. A reviewed row is never demoted by a bucket recheck. On revoke, a row with a ledger state goes back to the gate (count and dates from its events when the review had published it), and one without is unpublished. Production data: a no-op merge stays at v52, revoking #27's number gives v53, a rerun stays put. Tests: assert_revoked_review_hands_reports_back_to_the_gate covers each case above. The approval test asserted the ledger forgot the report once the review published it; that loss is the bug, so it now asserts the report is kept unpublished. The revoke test's settled row now stays published, a legacy reviewed row covers the no-ledger path, and an FCC-only revoke step makes the version bump depend on the revocation alone (later version numbers shift by one).
1 parent 09ac1e0 commit 676eeb1

4 files changed

Lines changed: 203 additions & 40 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ All notable changes to CallShield will be documented in this file.
77
- A number the maintainer approved can now be taken back. Its entry in
88
`spam_numbers_approved.json` gets `"revoked": true` and a date, and the next
99
merge strips the review from the row. A row the review alone was holding up
10-
is unpublished. A number listed twice stops the merge with an error naming
10+
is unpublished. One with community reports goes back to the usual gate with
11+
the reports it actually got, so the review's date and count can't help it
12+
through. A number listed twice stops the merge with an error naming
1113
it, since two dates made its evidence flip and every phone re-download the
1214
database on each merge. So does deleting an approval outright, which used to
1315
leave the review in the row for two years. Not-spam votes no longer make a

‎data/README.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -239,11 +239,13 @@ it goes in `spam_numbers_approved.json` with a link to the report. The merge
239239
then publishes it, keeping the pending community reports in its count, and the
240240
review is the row's own evidence, so later reports can't demote it.
241241

242-
A revoked approval comes off at the next merge. The row keeps whatever its
243-
other sources say and loses the review's evidence and description. A row the
244-
review alone was holding up is unpublished, including one that only community
245-
reports back. Those reports go back to the pending ledger, counted as one day's
246-
reports on the row's last day, so the usual gate decides whether it returns.
242+
The pending ledger keeps a reviewed number's community reports, including
243+
ones that arrive after the review, with their real days and reporters. A
244+
revoked approval comes off at the next merge. The row keeps whatever its other
245+
sources say and loses the review's evidence and description. A row with
246+
community reports goes back to the usual gate on those reports alone, so it
247+
stays if they carry it and is held if they don't. A row the review alone was
248+
holding up is unpublished.
247249

248250
Every evidence record carries `expires_at_epoch_ms`, and phones stop matching
249251
a downloaded number or range once its last record expires, so a row

‎scripts/merge_community_reports.py‎

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -382,22 +382,31 @@ def require_listed_reviews(existing: dict[str, dict], listed: set[str]) -> None:
382382
)
383383

384384

385+
def keeps_community_ledger(entry: dict) -> bool:
386+
"""A reviewed row whose only other support is community reports.
387+
388+
Its reports stay in the pending ledger with their real days and reporters,
389+
so revoking the review hands them back to the community gate intact.
390+
"""
391+
support = canonical_source_ids(entry)
392+
return MAINTAINER_SOURCE in support and support - {MAINTAINER_SOURCE} <= {"community_reports"}
393+
394+
385395
def revoke_maintainer_approvals(
386396
existing: dict[str, dict],
387397
pending: dict[str, dict],
388398
revoked: set[str],
389-
today: str,
390-
pending_cutoff: str,
391399
) -> tuple[int, int]:
392400
"""Take the review back off every row whose approval is revoked.
393401
394402
Its evidence, source and description come off. A row another source still
395-
backs stays published without them. A row the review carried is unpublished,
396-
and its community reports go back to the pending ledger as one report on the
397-
row's last day, which can delay a later promotion but never hurry one. Running
398-
this twice changes nothing.
403+
backs stays published without them. A row with community reports in the
404+
ledger goes back to the community gate, which runs right after this and
405+
keeps it or unpublishes it on those reports alone. A row the review alone
406+
carried, or whose reports have aged out of the ledger, is unpublished.
407+
Running this twice changes nothing.
399408
"""
400-
stripped = 0
409+
released = 0
401410
removed = 0
402411
for number in sorted(revoked):
403412
entry = existing.get(number)
@@ -416,18 +425,25 @@ def revoke_maintainer_approvals(
416425
part for part in entry.get("description", "").split("; ") if part != MAINTAINER_DESCRIPTION
417426
)
418427
support = canonical_source_ids(entry)
428+
state = pending.get(number)
419429
if support - {"community_reports"}:
420-
stripped += 1
430+
released += 1
431+
continue
432+
if support and state is not None and (state["published"] or state["events"]):
433+
if not state["published"]:
434+
# The review published it, so its count and dates were partly
435+
# the review's. The reports alone decide them now.
436+
days = [event["day"] for event in state["events"]]
437+
entry["reports"] = sum(event["count"] for event in state["events"])
438+
entry["first_seen"], entry["last_seen"] = min(days), max(days)
439+
entry["description"] = entry["description"] or COMMUNITY_DESCRIPTION
440+
state["entry"] = entry
441+
released += 1
421442
continue
422443
del existing[number]
423444
pending.pop(number, None)
424445
removed += 1
425-
reports = max(0, int(entry.get("reports", 0)))
426-
last = entry.get("last_seen")
427-
if "community_reports" in support and reports and _is_valid_day(last, today) and last >= pending_cutoff:
428-
event = {"key": f"revoked:{number}", "day": last, "bucket": "", "count": reports}
429-
pending[number] = {"published": False, "entry": entry, "events": [event]}
430-
return stripped, removed
446+
return released, removed
431447

432448

433449
def apply_maintainer_approvals(
@@ -444,7 +460,8 @@ def apply_maintainer_approvals(
444460
GitHub waiting forever when it's a region no imported source covers
445461
(issue #27, a Munich number). The review is its own evidence record, so the
446462
row is no longer community-only and the quorum rechecks leave it alone. The
447-
pending reports still count toward it. Running this twice changes nothing.
463+
pending reports still count toward it and stay in the ledger, along with
464+
later ones, in case the review is revoked. Running this twice changes nothing.
448465
"""
449466

450467
added = 0
@@ -468,7 +485,7 @@ def apply_maintainer_approvals(
468485
updated += 1
469486
continue
470487

471-
state = pending.pop(number, None)
488+
state = pending.get(number)
472489
events = state["events"] if state else []
473490
days = [event["day"] for event in events] + [reviewed]
474491
base = dict(state["entry"]) if state else {}
@@ -656,16 +673,15 @@ def main(argv: list[str] | None = None):
656673
# as a community report day.
657674
approvals, revoked_numbers = validated_approvals(load_approved_numbers(), today)
658675
require_listed_reviews(existing, {approval["number"] for approval in approvals} | revoked_numbers)
659-
revoked_stripped, revoked_removed = revoke_maintainer_approvals(
660-
existing, pending, revoked_numbers, today, pending_cutoff
661-
)
662-
if revoked_stripped or revoked_removed:
663-
print(f"Revoked approvals: {revoked_removed} rows unpublished, {revoked_stripped} kept on other sources")
676+
revoked_released, revoked_removed = revoke_maintainer_approvals(existing, pending, revoked_numbers)
677+
if revoked_released or revoked_removed:
678+
print(f"Revoked approvals: {revoked_removed} rows unpublished, {revoked_released} left to their other sources")
664679

665680
demoted = 0
666681
for number, entry in list(existing.items()):
667682
if canonical_source_ids(entry) != {"community_reports"}:
668-
pending.pop(number, None)
683+
if not keeps_community_ledger(entry):
684+
pending.pop(number, None)
669685
continue
670686
state = pending.setdefault(number, legacy_community_state(entry, today))
671687
if state["published"] and (state.get("bucket_quorum") or not any(event["bucket"] for event in state["events"])):
@@ -776,10 +792,32 @@ def main(argv: list[str] | None = None):
776792
entry["sources"] = sorted(set(entry.get("sources", [])) | {COMMUNITY_SOURCE})
777793
if reported_at > entry.get("last_seen", ""):
778794
entry["last_seen"] = reported_at
795+
if state is None and keeps_community_ledger(entry):
796+
state = {
797+
"published": False,
798+
"entry": {
799+
"number": number,
800+
"type": entry.get("type", spam_type),
801+
"reports": 0,
802+
"first_seen": reported_at,
803+
"last_seen": reported_at,
804+
"description": COMMUNITY_DESCRIPTION,
805+
"sources": [COMMUNITY_SOURCE],
806+
},
807+
"events": [],
808+
}
809+
pending[number] = state
779810
if state:
780811
state["events"].append(event)
781-
state["entry"] = entry
782-
if bucket and state["published"] and not state.get("bucket_quorum") and not community_has_quorum(state):
812+
if state["published"]:
813+
state["entry"] = entry
814+
if (
815+
bucket
816+
and state["published"]
817+
and not state.get("bucket_quorum")
818+
and not has_maintainer_review(entry)
819+
and not community_has_quorum(state)
820+
):
783821
state["published"] = False
784822
del existing[number]
785823
demoted += 1
@@ -892,7 +930,7 @@ def main(argv: list[str] | None = None):
892930
or demoted > 0
893931
or approved_added > 0
894932
or approved_updated > 0
895-
or revoked_stripped > 0
933+
or revoked_released > 0
896934
or revoked_removed > 0
897935
)
898936
if changed:

0 commit comments

Comments
 (0)