Skip to content

Commit 09ac1e0

Browse files
committed
Let the maintainer revoke an approval, and refuse duplicate approvals
Once a number was published through spam_numbers_approved.json nothing could take it back: deleting the entry left the maintainer_review evidence in the row for two years, and the not-spam candidate filter read raw sources, so a reviewed row became a candidate whose correction was then skipped as authoritative. A number listed twice with two dates flipped its evidence and bumped the database version on every merge. Approvals are now validated up front. A duplicate number stops the merge naming it, and so does a reviewed row whose entry was deleted. An entry with "revoked": true and a revoked_at date strips the review's evidence, source and description before the quorum recheck; a row nothing else backs is unpublished, and its community reports go back to the pending ledger as one report on the row's last day (only within the 30-day window). The candidate filter reads canonical source ids. Production data merges with no change. Tests: new assert_maintainer_approval_can_be_revoked covers revoke, the filter, duplicates, a deleted entry and unchanged versions on rerun. The existing broken-approval loop wrote a file holding only the broken entry; dropping the other reviewed entries is now an error of its own, so the loop no longer proved the named entry was the fault. Each case now keeps them listed and checks for its own message.
1 parent 99fb1a1 commit 09ac1e0

5 files changed

Lines changed: 275 additions & 25 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to CallShield will be documented in this file.
44

55
## Unreleased
66

7+
- A number the maintainer approved can now be taken back. Its entry in
8+
`spam_numbers_approved.json` gets `"revoked": true` and a date, and the next
9+
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
11+
it, since two dates made its evidence flip and every phone re-download the
12+
database on each merge. So does deleting an approval outright, which used to
13+
leave the review in the row for two years. Not-spam votes no longer make a
14+
reviewed row a correction candidate.
715
- On a phone outside North America, a ten-digit number the phone couldn't
816
place is no longer scored by North American rules. An Australian phone
917
gave `8881234567` the toll-free score, and the VoIP-range, campaign-range,

‎data/README.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ This directory contains the spam number database that the CallShield app pulls f
1818
- `spam_domains_approved.json`: Optional maintainer approval input for domain
1919
candidates. Add reviewed names to its `approved` array. An invalid shape
2020
stops the extractor.
21-
- `spam_numbers_approved.json`: Numbers the maintainer checked by hand, usually a spam report filed as a GitHub issue for a number no imported source covers. Each entry gives the number, a `type`, the `reviewed_at` date, an https `reference` to the report and a note on what the review found. The merge publishes each one with a `maintainer_review` evidence record, and an entry it can't check stops the merge
21+
- `spam_numbers_approved.json`: Numbers the maintainer checked by hand, usually a spam report filed as a GitHub issue for a number no imported source covers. Each entry gives the number, a `type`, the `reviewed_at` date, an https `reference` to the report and a note on what the review found. The merge publishes each one with a `maintainer_review` evidence record, and an entry it can't check stops the merge. So does a number listed twice. To take an approval back, keep the entry and add `"revoked": true` with a `revoked_at` date. Deleting the entry stops the merge, because it would leave the review in the row
2222
- `spam_domains_review.json`: Generated domain candidates awaiting approval
2323
- `not_spam_review.json`: Generated community false-positive review candidates
2424
- `merged_report_ids.json`: Ids of reports merged in the last 14 days, so a report the app resends after its original was merged counts once. The ids are random and already appear in the report files
@@ -239,6 +239,12 @@ 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.
247+
242248
Every evidence record carries `expires_at_epoch_ms`, and phones stop matching
243249
a downloaded number or range once its last record expires, so a row
244250
stays live while any of its evidence is. The manifest's

‎data/spam_numbers_approved.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"about": "Numbers the maintainer checked by hand against public complaint sites, usually after a spam report filed as a GitHub issue. merge_community_reports.py publishes each one with a maintainer_review evidence record. Every entry needs the number, a type, the review date and a link to the report.",
2+
"about": "Numbers the maintainer checked by hand against public complaint sites, usually after a spam report filed as a GitHub issue. merge_community_reports.py publishes each one with a maintainer_review evidence record. Every entry needs the number, a type, the review date and a link to the report. One entry per number. To take an approval back, keep its entry and add \"revoked\": true and a revoked_at date.",
33
"approved": [
44
{
55
"number": "+498943780834",

‎scripts/merge_community_reports.py‎

Lines changed: 127 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,117 @@ def load_approved_numbers() -> list[dict]:
319319
return approved
320320

321321

322+
def validated_approvals(approvals: list[dict], today: str) -> tuple[list[dict], set[str]]:
323+
"""Check every entry, then split the approvals from the revoked numbers.
324+
325+
Each number gets one entry. Listed twice with different dates, a number's
326+
evidence flipped on every merge and moved the database version each time,
327+
so every phone downloaded it again. To take an approval back, the entry
328+
stays and gains ``"revoked": true`` and a ``revoked_at`` date, which keeps
329+
the decision on the record.
330+
"""
331+
active: list[dict] = []
332+
revoked: set[str] = set()
333+
seen: set[str] = set()
334+
for approval in approvals:
335+
if not isinstance(approval, dict):
336+
raise TypeError(f"{APPROVED_NUMBERS_FILE.name}: every approval must be an object")
337+
number = validated_report_number(str(approval.get("number", "")))
338+
if not number:
339+
raise ValueError(f"{APPROVED_NUMBERS_FILE.name}: {approval.get('number')!r} is not a plausible number")
340+
if number in seen:
341+
raise ValueError(f"{APPROVED_NUMBERS_FILE.name}: {number} is listed more than once. Keep one entry per number")
342+
seen.add(number)
343+
is_revoked = approval.get("revoked", False)
344+
if not isinstance(is_revoked, bool):
345+
raise TypeError(f"{APPROVED_NUMBERS_FILE.name}: {number} needs revoked set to true or false")
346+
if is_revoked:
347+
revoked_at = approval.get("revoked_at")
348+
if not isinstance(revoked_at, str) or not _is_valid_day(revoked_at, today):
349+
raise ValueError(f"{APPROVED_NUMBERS_FILE.name}: {number} needs a revoked_at date no later than today")
350+
revoked.add(number)
351+
continue
352+
reviewed = approval.get("reviewed_at")
353+
reference = approval.get("reference")
354+
spam_type = approval.get("type")
355+
if not isinstance(reviewed, str) or not _is_valid_day(reviewed, today):
356+
raise ValueError(f"{APPROVED_NUMBERS_FILE.name}: {number} needs a reviewed_at date no later than today")
357+
if not isinstance(reference, str) or not reference.startswith("https://"):
358+
raise ValueError(f"{APPROVED_NUMBERS_FILE.name}: {number} needs an https reference to the report")
359+
if not isinstance(spam_type, str) or not spam_type.replace("_", "").isalpha():
360+
raise ValueError(f"{APPROVED_NUMBERS_FILE.name}: {number} needs a type such as telemarketer or scam")
361+
active.append({"number": number, "type": spam_type, "reviewed_at": reviewed})
362+
return active, revoked
363+
364+
365+
def has_maintainer_review(entry: dict) -> bool:
366+
return MAINTAINER_SOURCE in entry.get("sources", []) or any(
367+
isinstance(item, dict) and item.get("source_id") == MAINTAINER_SOURCE for item in entry.get("evidence") or []
368+
)
369+
370+
371+
def require_listed_reviews(existing: dict[str, dict], listed: set[str]) -> None:
372+
"""Stop when a reviewed row's entry was deleted instead of revoked.
373+
374+
Deleting the entry took nothing back. The review stayed in the row as its
375+
evidence for two years, and the row stayed published on it.
376+
"""
377+
for number, entry in existing.items():
378+
if number not in listed and has_maintainer_review(entry):
379+
raise ValueError(
380+
f"{APPROVED_NUMBERS_FILE.name} no longer lists {number}, which carries a maintainer review. "
381+
"Put the entry back with revoked set to true and a revoked_at date"
382+
)
383+
384+
385+
def revoke_maintainer_approvals(
386+
existing: dict[str, dict],
387+
pending: dict[str, dict],
388+
revoked: set[str],
389+
today: str,
390+
pending_cutoff: str,
391+
) -> tuple[int, int]:
392+
"""Take the review back off every row whose approval is revoked.
393+
394+
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.
399+
"""
400+
stripped = 0
401+
removed = 0
402+
for number in sorted(revoked):
403+
entry = existing.get(number)
404+
if entry is None or not has_maintainer_review(entry):
405+
continue
406+
evidence = [
407+
item for item in entry.get("evidence") or []
408+
if not (isinstance(item, dict) and item.get("source_id") == MAINTAINER_SOURCE)
409+
]
410+
if evidence:
411+
entry["evidence"] = evidence
412+
else:
413+
entry.pop("evidence", None)
414+
entry["sources"] = [source for source in entry.get("sources", []) if source != MAINTAINER_SOURCE]
415+
entry["description"] = "; ".join(
416+
part for part in entry.get("description", "").split("; ") if part != MAINTAINER_DESCRIPTION
417+
)
418+
support = canonical_source_ids(entry)
419+
if support - {"community_reports"}:
420+
stripped += 1
421+
continue
422+
del existing[number]
423+
pending.pop(number, None)
424+
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
431+
432+
322433
def apply_maintainer_approvals(
323434
existing: dict[str, dict],
324435
pending: dict[str, dict],
@@ -339,21 +450,7 @@ def apply_maintainer_approvals(
339450
added = 0
340451
updated = 0
341452
for approval in approvals:
342-
if not isinstance(approval, dict):
343-
raise TypeError(f"{APPROVED_NUMBERS_FILE.name}: every approval must be an object")
344-
number = validated_report_number(str(approval.get("number", "")))
345-
reviewed = approval.get("reviewed_at")
346-
reference = approval.get("reference")
347-
spam_type = approval.get("type")
348-
if not number:
349-
raise ValueError(f"{APPROVED_NUMBERS_FILE.name}: {approval.get('number')!r} is not a plausible number")
350-
if not isinstance(reviewed, str) or not _is_valid_day(reviewed, today):
351-
raise ValueError(f"{APPROVED_NUMBERS_FILE.name}: {number} needs a reviewed_at date no later than today")
352-
if not isinstance(reference, str) or not reference.startswith("https://"):
353-
raise ValueError(f"{APPROVED_NUMBERS_FILE.name}: {number} needs an https reference to the report")
354-
if not isinstance(spam_type, str) or not spam_type.replace("_", "").isalpha():
355-
raise ValueError(f"{APPROVED_NUMBERS_FILE.name}: {number} needs a type such as telemarketer or scam")
356-
453+
number, reviewed, spam_type = approval["number"], approval["reviewed_at"], approval["type"]
357454
evidence = source_evidence(
358455
manifest,
359456
MAINTAINER_SOURCE,
@@ -553,6 +650,18 @@ def main(argv: list[str] | None = None):
553650
burst_duplicates = find_burst_duplicates(burst_candidates)
554651

555652
existing = {n["number"]: n for n in db["numbers"]}
653+
654+
# Approvals are checked before anything changes. A revoked one comes off
655+
# before the quorum recheck, which would otherwise read the review's date
656+
# as a community report day.
657+
approvals, revoked_numbers = validated_approvals(load_approved_numbers(), today)
658+
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")
664+
556665
demoted = 0
557666
for number, entry in list(existing.items()):
558667
if canonical_source_ids(entry) != {"community_reports"}:
@@ -708,7 +817,6 @@ def main(argv: list[str] | None = None):
708817
quarantine(report_file, rejected_dir)
709818
rejected += 1
710819

711-
approvals = load_approved_numbers()
712820
approved_added, approved_updated = (0, 0)
713821
if approvals:
714822
approved_added, approved_updated = apply_maintainer_approvals(
@@ -724,7 +832,7 @@ def main(argv: list[str] | None = None):
724832
review_candidates = []
725833
for number, reporters in sorted(not_spam_votes.items()):
726834
entry = existing.get(number)
727-
if entry is None or set(entry.get("sources", [])) != {COMMUNITY_SOURCE}:
835+
if entry is None or canonical_source_ids(entry) != {"community_reports"}:
728836
continue
729837
report_count = max(0, int(entry.get("reports", 0)))
730838
if len(reporters) > report_count:
@@ -784,6 +892,8 @@ def main(argv: list[str] | None = None):
784892
or demoted > 0
785893
or approved_added > 0
786894
or approved_updated > 0
895+
or revoked_stripped > 0
896+
or revoked_removed > 0
787897
)
788898
if changed:
789899
db["version"] += 1

0 commit comments

Comments
 (0)