Skip to content

feat(gate): graduated auto-publish gate + hard scope guard + deploy/heartbeat guardrails#28

Merged
KiranPranay merged 5 commits into
mainfrom
launch-gates
Jul 14, 2026
Merged

feat(gate): graduated auto-publish gate + hard scope guard + deploy/heartbeat guardrails#28
KiranPranay merged 5 commits into
mainfrom
launch-gates

Conversation

@KiranPranay

Copy link
Copy Markdown
Member

Items 2 + 3 of the launch directive. Builds on the merged record-loss fix (#27).

Scope safety (item 2)

  • LAUNCH_STATES=ALL means "all states"; an unset/malformed value now RAISES instead of silently running all-states-all-time. scope_is_configured() agrees with the real resolution (ALL or a non-empty set).

Graduated auto-publish gate (item 3)

A record that passed dedupe AUTO-publishes only if: non-minor and no accused named and durably sourced (court/news/press, not live-blog-only) and confidence ≥ 0.85 and no POCSO→non-minor mismatch. Everything else is held in data/_needs_review/queue.json — never on the public site, carried over (both modes) so it is never lost, re-split each run so a later court update can promote it.

Deploy/ops guardrails

  • Pages deploy now excludes _needs_review + _meta (held records — incl. named accused — must never be web-reachable).
  • Heartbeat surfaces auto-published vs held counts + needs-review queue depth (warns > 25).

Reviewed hard

Two adversarial review rounds. Round 1 found 6 real defects (a POCSO minor's day-precise detail auto-publishing; id-fusion between distinct cases; three diverging "minor" notions; auto-mode held-record loss; a scope-guard bypass; heartbeat blind to held) — all fixed with regression tests, incl. the exact id-fusion 3-run scenario. Round 2 verification in progress.

make check green locally (193 tests, mypy --strict, 100% on sanitize/pii_guard/gates, validation + pii_guard clean).

Two launch-gating changes:

1. Never run silently unscoped. LAUNCH_STATES must be explicitly set — ALL
   (all states, intentional) or a comma list; an unset value now RAISES
   instead of defaulting to all-states-all-time (the cron-without-inputs
   failure). launch_states() treats ALL as "no state filter"; a new
   scope_is_configured() backs the hard assertion in run().

2. Graduated auto-publish gate (pipeline/gates.py). A record that passed
   dedupe (confidence >= review threshold) AUTO-publishes only if it is also
   non-minor, names no accused, is durably sourced (court/news_article/
   press_release, not live-blog-only), and confidence >= 0.85. Everything
   else — minors, named accused, live-blog-only, the 0.80..0.84 band — is
   HELD in data/_needs_review/queue.json: never on the public site, carried
   over so a hold whose source rolls off the feed is not lost, re-split each
   run so a later court update can promote it. The ledger never settles a
   held doc, so it re-surfaces until a human resolves it. The staged review
   PR shows both streams, labelled, so nothing publishes unreviewed.

Fixtures updated to a non-minor auto-eligible case (still demonstrates the
sanitizer dropping a forbidden key + source-union dedup). Tests cover the
gate criteria, minor/named-accused holds, needs-review carryover, and the
scope-unset refusal.
…not be web-reachable)

The Pages deploy rsynced all of data/ except _review, so the graduated gate's
held records (data/_needs_review/queue.json — minors, named accused,
live-blog-only) would be fetchable by URL even though they are not indexed.
That would publish an unproven accused name without human review. Exclude
_needs_review and _meta (the ledger) alongside _review.
Safety-critical fixes from an adversarial review of the auto-publish gate:

- HIGH: a POCSO case the model flags minor_involved=false auto-published a
  minor's day-precise dates (no independent minor check). Add a deterministic
  POCSO-implies-minor cross-check: a POCSO category/section flagged non-minor
  is HELD (reason pocso_minor_mismatch), mirroring the two-layer scope defense.
- HIGH: a published record that becomes held kept its id off-shard, so its
  freed serial was re-minted for a new case — fusing two distinct cases under
  one id. write_shards now takes a `reserve` set (the held records); their
  ids/serials/anchors are reserved so a fresh mint can never collide.
- MEDIUM: three notions of "minor" diverged (sanitize `is True`, gate
  truthiness, dedupe `or`); a truthy non-bool flag was held but not
  age-projected. Coerce minor_involved to a strict bool before the last gate.
- MEDIUM: in auto mode (no STAGED_DIR) a held record whose source rolled off
  was dropped. Reload the committed _needs_review/queue.json into dedupe in
  both modes so held records always re-enter and persist/promote.
- MEDIUM: scope_is_configured() used bare truthiness, so LAUNCH_STATES="," or
  "  " passed the guard yet resolved to all-states. Now it agrees with the real
  resolution (ALL or a non-empty set), so malformed values are refused.
- MEDIUM: the ops heartbeat never surfaced held records. Add auto-published vs
  held counts, the needs-review queue depth (with a >25 warning), and count
  held in the in-scope total.

Regression tests for every finding, incl. the exact id-fusion 3-run scenario
and the auto-mode carryover. make check green.
…sed minor

Second review round on the gate fixes found 6 more real defects; all fixed:

- HIGH: merge_records dropped `id` when a court doc (no id) became primary over
  an already-published media record, so its freed public serial was re-minted
  for a distinct case (public id reuse). merge now fills `id` from the secondary,
  so an already-published case keeps its canonical id through a merge — and the
  reserve seeding then protects that serial. Regression test now exercises the
  court-beats-media path (media run 1, court run 2).
- HIGH: a carryover held record with a non-bool minor kept day-precise detail in
  the queue (coercion ran only on fresh extractions). Coerce at the single write
  choke point (_write_needs_review) so every held record — fresh or carried — is
  age-projected.
- MEDIUM: _coerce_minor mapped an absent/None minor flag to False (fail OPEN) —
  a minor case with an omitted flag could auto-publish day-precise detail. Now
  fail CLOSED: absent/None -> minor, and any POCSO signal -> minor (projected +
  held), never shipped as non-minor.
- MEDIUM: a held POCSO-mismatch record kept day-precise detail in the queue.
  Forcing POCSO -> minor before sanitize projects it (subsumed by _coerce_minor).
- LOW: reserve seeding also seeded anchor keys, letting a distinct case with a
  weak (empty-district) anchor reuse a held id. Reserve now reserves serials+ids
  only (seed_anchors=False).
- MEDIUM: fix #4 double-loaded the held queue in staged mode (archive + main),
  spamming _review with self-matches. Load the main queue only in auto mode.

make check green (193 tests, 100% on sanitize/pii_guard/gates).
_update_ledger classifies membership in the non-injective sanitised-URL space.
A quarantined _review doc whose sanitised url collides with an on-main published
record's was settled 'published' (existing_urls checked before the held check)
and skipped forever — and carryover never restores _review, so the case was
lost. Check the held/quarantine set FIRST: a doc quarantined this run never
settles. A rare colliding published doc merely re-processes (safe direction).
@KiranPranay
KiranPranay merged commit c3d0f2b into main Jul 14, 2026
3 checks passed
@KiranPranay
KiranPranay deleted the launch-gates branch July 14, 2026 20:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant