Skip to content

feat(sweep): re-validate whether each human-gated gate is still real (BLO-30608) - #1538

Merged
kkroo merged 5 commits into
masterfrom
feat/blo-30608-gate-revalidation
Aug 29, 2026
Merged

feat(sweep): re-validate whether each human-gated gate is still real (BLO-30608)#1538
kkroo merged 5 commits into
masterfrom
feat/blo-30608-gate-revalidation

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 28, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Work that only a human can clear — a review, an approval, a credential grant — sits in a human-gated queue, and the weekly ageing sweep (BLO-19130, wired in BLO-29420) escalates whatever has waited too long
  • That sweep measures how long a gate has been open; it never re-checks whether the gate is still real
  • So a row whose blocking condition resolved out of band ages forever, indistinguishable from one genuinely still waiting — and on a blocked row with a stale blocker, POST /checkout returns 422 and no agent can pick it up even to discover the premise is false
  • This pull request adds a read-only re-validation pass in front of the ageing pass, classifying each row still-gated / resolved-but-open / unverifiable with the evidence that produced the verdict
  • The benefit is that rows whose gate has already cleared stop being aged as if blocked, and the share of the queue expressing no machine-checkable gate at all becomes a measured number rather than an assumption

Linked Issues or Issue Description

Closes BLO-30608.

Follows BLO-19130 (ageing mechanism) and BLO-29420 (made it reachable). Refs BLO-30438, BLO-29399, BLO-29004.

What Changed

  • New pure module server/src/services/human-gated-gate-revalidation.ts: bounded, read-only classifier over {still-gated, resolved-but-open, unverifiable} with per-row evidence and retained overruled probes.
  • Wired through the existing humanGatedAgeingProducer (one producer, two sections) so re-validation runs before ageing and resolved-but-open rows are withheld from the age-ranked list rather than aged again.
  • Two DB-local probes: blocker-premise (issue_relations + blocker status) and approval-gate (issue_approvals + approvals.status). Zero external calls.
  • orderByHumanSilenceDescending + rankableSilenceDays moved into the pure ageing module and exported, so the producer and the backfill script rank on one clock implementation before spending a bounded probe budget.
  • HUMAN_GATED_DIGEST_ORIGIN_KIND moved to the same dependency-free module (re-exported from the digest module, so existing importers are unchanged) — it is part of the population predicate, and a caller reproducing that predicate should not have to import the database layer to name it.
  • New scripts/blo-30608-gate-revalidation-backfill.ts with two evidence sources (--source=db, --source=api) feeding the same classifier.
  • Two rendering fixes found by the wiring test: sanitizeRenderedField ate the leading [ of the module-authored [probe] prefix, and the 300-char evidence bound truncated away the blocker identifier in exactly the cancelled-edge case where it is the only actionable fact.

Model Used

claude-opus-5 (Claude Code, Release Engineer agent).

Problem

The human-gated ageing sweep measures how long a gate has been open. It never re-checks whether the gate is still real. A row whose blocking condition resolved out of band ages forever, indistinguishable from one genuinely still waiting — and gets reported every week with a bigger number, which is a report that gets muted.

Not hypothetical. BLO-29399 (critical, a firing alert) was blocked by BLO-29004 and POST /checkout returned 422 Issue is blocked by unresolved blockers. That 422 is the sharp edge: no agent could pick the row up even to discover the premise had been false for days.

What this adds

A read-only re-validation pass that runs before the ageing pass, classifying each row still-gated / resolved-but-open / unverifiable with the evidence that produced the verdict. resolved-but-open rows get their own digest section, carrying their age, and are withheld from the age-ranked list instead of aged as if still blocked.

Two probes, both DB-local — one pass is two batched SELECTs and zero external calls:

probe reads
blocker-premise issue_relations + the blockers' status
approval-gate issue_approvals + approvals.status

The approval probe deliberately does not re-call GitHub for gate.kind: github_actions_run cards. approval-gate-reconciler.ts:311 already polls those runs and closes the card when the run terminates, so reading the card's status reuses that audited mechanism rather than building a second, disagreeing one.

A permission/RBAC probe is a declared seam (GateProbeKind), not a half-built implementation: a live access probe needs network egress and per-target credentials, and collection runs inside a DB transaction. Rows gated on access are honestly reported unverifiable rather than guessed at.

The cancelled-blocker case gets its own resolution kind

listIssueDependencyReadinessMap resolves dependents on done only"cancelled blockers stay unresolved until an operator removes or replaces the blocker relationship explicitly". So a cancelled blocker never self-clears: the row is permanently un-checkoutable. That is the sharpest thing this pass can find, so it is reported ahead of the merely-finished cases and named as needing an operator.

Design properties (each has a test)

  • Read-only. No write path exists. Verified by asserting issue rows and blocker edges are byte-identical after a pass.
  • Fails toward still-gated when probes disagree. A false resolved-but-open invites a reader to close live work; a false still-gated only ages the row one more week — which is the status quo being improved on. The asymmetry is deliberate.
  • Bounded at DEFAULT_MAX_PROBES, with budget exhaustion counted separately from unverifiable. Merging them would let a too-small budget masquerade as a discovery about the queue.
  • Overruled probes are retained on each classification, so a verdict that looks wrong can be audited.

Backfill (AC3 / AC5)

scripts/blo-30608-gate-revalidation-backfill.ts, run uncapped over the live BLO company:

Open human-gated population : 743
still-gated        : 16
resolved-but-open  : 50
unverifiable       : 677

resolved-but-open by who can clear it:
  blocker edge cancelled (never self-clears) : 0
  all blockers done, row never moved         : 15
  every linked approval decided              : 35

Cost: 772 round trips, 251.4s wall clock.

The headline is the 677. 91% of the human-gated queue expresses no machine-checkable gate at all — exactly the outcome the issue anticipated as "itself the finding". The re-validation pass catches a real but minority 50 rows (6.7%) that were being aged as if still blocked.

Four verdicts were spot-checked independently against blockerAttention.unresolvedBlockerCount and the linked cards — BLO-25131 (blocker BLO-30596 done), BLO-4866, BLO-8805, BLO-12017 (4 done blockers), BLO-29852 (single approved card). All confirmed.

The script offers two evidence sources feeding the same classifier: --source=db (the exact code path the sweep runs) and --source=api (reproducible by anyone holding ordinary agent credentials, no DB access). Only acquisition differs, so a verdict cannot depend on which produced it.

Two rendering defects found and fixed while testing

The wiring test caught both; neither was a bad assertion:

  1. Routing composed evidence through sanitizeRenderedField stripped the leading [ as a Markdown marker, rendering [blocker-premise] as blocker-premise]. Fixed with a dedicated bound that keeps the module-authored structure while retaining the control-char and backtick defences (issue-controlled values are already sanitized where interpolated).
  2. Cancelled-edge evidence led with its long explanation, so the 300-char bound truncated away the blocker identifier — the only actionable part. The refs now lead.

Both have regression tests.

Risks

Low, and bounded by construction:

  • Read-only. No write path exists in the new module or the script. A test asserts issue rows and blocker edges are byte-identical after a pass, so a future write would fail CI rather than quietly mutate the queue.
  • Fails toward still-gated when probes disagree. A false resolved-but-open invites a reader to close live work; a false still-gated only ages the row one more week, which is the status quo. The asymmetry is deliberate.
  • Bounded at DEFAULT_MAX_PROBES, with budget exhaustion counted separately from unverifiable so a too-small budget cannot masquerade as a discovery about the queue.
  • Digest-shape change. The digest gains a section and withholds resolved-but-open rows from the age-ranked list. Nothing is dropped — withheld rows are rendered with their age — but a reader diffing week-over-week counts will see the ranked list shrink by the resolved share (~6.7% measured).
  • --source=api in the backfill is an explicit approximation of the sweep's clock and population (the API does not expose lastHumanTouchAt); the docblock states it, and it errs toward over-probing rather than skipping. --source=db is the sweep's split by construction.

Verification

  • 41 pure classifier tests + 9 embedded-Postgres wiring tests that drive the real humanGatedAgeingProducer against seeded rows. Testing the pure function only is what let a 683-line module sit on master with 35 green tests and zero production importers (BLO-29420) — a test that imports the module cannot tell wired from inert.
  • 107/107 across all four suites, including the 64 pre-existing ageing + digest tests.
  • 242/242 on the adjacent numeric-env-bounds + ac-policy-sweep suites.
  • tsc --noEmit clean.

Out of scope (per the issue)

No auto-closing, no auto-unblocking, no threshold changes. The demonstrated instance needed judgement about which of two similar-sounding drifts applied; that call stays with an owner.

🤖 Generated with Claude Code

…(BLO-30608)

The human-gated ageing sweep measures how long a gate has been open. It never
asked whether the gate is still real, so a row whose blocking condition
resolved out of band aged forever, indistinguishable from one genuinely still
waiting. BLO-29399 (critical, a firing alert) hit this live: it was `blocked`
by BLO-29004 and `POST /checkout` returned 422, so no agent could pick it up
even to discover the premise had been false for days.

Adds a read-only re-validation pass that runs BEFORE the ageing pass and
classifies every row as still-gated / resolved-but-open / unverifiable, with
the evidence that produced the verdict. resolved-but-open rows are rendered in
their own digest section carrying their age, and are withheld from the
age-ranked list rather than aged as if still blocked.

Two probes, both DB-local, so a full pass costs two batched SELECTs and zero
external calls:

- blocker-premise reads `issue_relations` + blocker status.
- approval-gate reads `issue_approvals` + `approvals.status`. It does NOT
  re-call GitHub for `gate.kind: github_actions_run` cards, because
  `approval-gate-reconciler.ts` already polls those runs and closes the card
  when the run terminates; reading the card reuses that audited mechanism
  instead of building a second, disagreeing one.

A cancelled blocker gets its own resolution kind. Dependency readiness resolves
dependents on `done` only, so a cancelled blocker never clears itself and the
row stays permanently un-checkoutable — the sharpest thing this pass can find,
and it is reported ahead of the merely-finished cases.

Design properties, all covered by tests:

- Read-only. No write path exists; verified by asserting issue rows and blocker
  edges are byte-identical after a pass.
- Fails toward still-gated when probes disagree. A false resolved-but-open
  invites a reader to close live work; a false still-gated only ages the row
  one more week, which is the status quo being improved on.
- Bounded at DEFAULT_MAX_PROBES, with budget exhaustion counted separately from
  `unverifiable` — merging them would let a too-small budget masquerade as a
  discovery about the queue.

Backfill over the live BLO population (743 open human-gated rows, uncapped,
772 round trips, 251s): still-gated 16, resolved-but-open 50, unverifiable 677.
The 50 split 15 blocker-done / 35 approval-decided, 0 cancelled-edge. Four
verdicts were spot-checked against `blockerAttention.unresolvedBlockerCount`
and the linked cards independently. The headline is the 677: 91% of the
human-gated queue expresses no machine-checkable gate at all, which is itself
the finding the issue anticipated.

Tests: 41 pure classifier + 9 embedded-Postgres wiring tests driving the real
producer (a test that imports the module cannot tell wired from inert — the
lesson from BLO-29420). 107/107 including the 64 pre-existing ageing/digest
tests; `tsc --noEmit` clean.

Co-Authored-By: Claude <noreply@anthropic.com>
@allyblockcast

allyblockcast Bot commented Aug 28, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-12017
🔗 Paperclip issue: BLO-4866
🔗 Paperclip issue: BLO-29420
🔗 Paperclip issue: BLO-30596
🔗 Paperclip issue: BLO-25131
🔗 Paperclip issue: BLO-30608
🔗 Paperclip issue: BLO-29004
🔗 Paperclip issue: BLO-29399
🔗 Paperclip issue: BLO-29852
🔗 Paperclip issue: BLO-8805

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 28, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-12017
🔗 Paperclip issue: BLO-4866
🔗 Paperclip issue: BLO-29420
🔗 Paperclip issue: BLO-30596
🔗 Paperclip issue: BLO-25131
🔗 Paperclip issue: BLO-30608
🔗 Paperclip issue: BLO-29004
🔗 Paperclip issue: BLO-29399
🔗 Paperclip issue: BLO-29852
🔗 Paperclip issue: BLO-8805

@allyblockcast

allyblockcast Bot commented Aug 28, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 8932e07

Critical Issues (0)

Important Issues (1)

  • [native-codex] \ — the API backfill applies its probe budget to \ in the endpoint's default response order, while the production producer explicitly sorts oldest-human-clock-first before probing. With a bounded budget, this can spend all probes on newer rows and omit the stale rows the backfill is intended to measure, making the reported split dependent on API ordering rather than staleness.
    • Sort the API-acquired human-gated rows by the same human-silence clock before , or make the API path retrieve the same age field/order as the DB path; add a test proving a capped run chooses the oldest candidates.

Suggestions (1)

  • [native-codex] \ — keep the \ population contract aligned with the DB path by explicitly excluding the same hidden/digest/plugin/routine rows, or document that the API mode is only an approximate reproduction.

Strengths

  • The production producer runs re-validation before ageing and withholds resolved rows without mutating issue or blocker state.
  • The pure classifier has explicit fail-closed disagreement handling, bounded probing, and auditable evidence rendering.
  • The embedded-Postgres wiring tests exercise the real producer rather than only importing the classifier.

Recommended Action

  1. Fix the Important issue before relying on capped API backfill results.
  2. Consider the Suggestion opportunistically.

@allyblockcast

allyblockcast Bot commented Aug 28, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 8932e07

Critical Issues (0)

Important Issues (1)

  • [native-codex] scripts/blo-30608-gate-revalidation-backfill.ts:147-148 — the API backfill applies its probe budget to humanGated in the endpoint's default response order, while the production producer explicitly sorts oldest-human-clock-first before probing. With a bounded budget, this can spend all probes on newer rows and omit the stale rows the backfill is intended to measure, making the reported split dependent on API ordering rather than staleness.
    • Sort the API-acquired human-gated rows by the same human-silence clock before slice(0, budget), or make the API path retrieve the same age field/order as the DB path; add a test proving a capped run chooses the oldest candidates.

Suggestions (1)

  • [native-codex] scripts/blo-30608-gate-revalidation-backfill.ts:133-147 — keep the --source=api population contract aligned with the DB path by explicitly excluding the same hidden/digest/plugin/routine rows, or document that the API mode is only an approximate reproduction.

Strengths

  • The production producer runs re-validation before ageing and withholds resolved rows without mutating issue or blocker state.
  • The pure classifier has explicit fail-closed disagreement handling, bounded probing, and auditable evidence rendering.
  • The embedded-Postgres wiring tests exercise the real producer rather than only importing the classifier.

Recommended Action

  1. Fix the Important issue before relying on capped API backfill results.
  2. Consider the Suggestion opportunistically.

…ion producer

Self-review of the previous commit. The re-validation pass ranks candidates
oldest-first so a bounded probe budget is spent where staleness is likeliest,
and it did that by calling `humanSilenceDays` directly on every row.

That clock THROWS on a row with an unparseable `createdAt` and no human touch
(`humanClockAt`), and `loadHumanGatedIssues` deliberately emits `createdAt: ""`
for exactly that row so `selectAgedHumanGatedIssues` can classify it
`malformed` and name the offending key. Ranking runs before that validation, so
a single unreadable row turned into a thrown producer — trading a precise
"this row is malformed" line for a whole failed section.

That is a regression against the invariant this seam exists to hold: a row the
module cannot read must be *reported*, not allowed to take the report down.

Ranking now reads through `rankableSilenceDays`, which returns -Infinity for an
unrankable row so it sorts last (least likely to be stale, so the probe budget
is not spent on it) and still flows into `selectAgedHumanGatedIssues` to get its
real verdict. The rendered-age map drops non-finite entries, so such a row shows
no age rather than a fabricated one.

Regression test added and verified to actually catch it: with the fix reverted
it fails with `Error: Issue GRM-3 has no parseable createdAt`. 108/108 across
the four suites; `tsc --noEmit` clean.

Also verified the committed backfill script's `--source=db` path end-to-end
against embedded Postgres (population 1, resolved-but-open 1, calls 5) — it was
previously exercised only via `--source=api`, and `scripts/` is outside the
server tsconfig `include`, so it now also typechecks standalone.

Co-Authored-By: Claude <noreply@anthropic.com>
@allyblockcast

allyblockcast Bot commented Aug 29, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: c19eb76

Prior Findings Dispositioned (1)

  • prior:8932e07 important 1 — still-present — scripts/blo-30608-gate-revalidation-backfill.ts:154 — the API acquisition path still applies its bounded probe budget with humanGated.slice(0, budget) in the issues endpoint's default ordering. The production producer explicitly ranks candidates by human silence before calling the same classifier, so capped API runs can still sample newer rows instead of the oldest gates the backfill is intended to measure.

Critical Issues (0)

Important Issues (1)

  • [native-codex] prior:8932e07 important 1 scripts/blo-30608-gate-revalidation-backfill.ts:154 — Capped --source=api backfills are order-dependent rather than age-prioritized.
    • Fetch or derive the same human-silence clock used by the DB path and sort oldest-first before slicing; add a capped API acquisition test that proves the oldest candidates are probed.

Suggestions (0)

Strengths

  • The production producer now exercises the revalidation pass ahead of age ranking and preserves resolved-but-open rows in a separate rendered section.
  • The updated wiring coverage verifies the real producer path rather than only the pure classifier.

Recommended Action

  1. Resolve the Important issue before using capped API backfill output as an age-prioritized measurement.

Release Engineer and others added 2 commits August 29, 2026 00:53
Ally's review of 8932e07 flagged that the backfill script applied its probe
budget in the endpoint's default response order while the production producer
sorts oldest-human-clock-first. Under a cap that makes the reported split a
measurement of API ordering rather than of staleness — the one thing the
backfill exists to measure. The `--source=db` path had the same defect for the
same reason: `loadHumanGatedIssues` orders by `issues.id`, which is a lock
ordering, not an age one.

Rather than adding a second sort at each call site, the ordering moves into the
pure ageing module as `orderByHumanSilenceDescending`, beside the clock it
reads, so there is exactly one implementation of "which row is older" shared by
the producer and both script paths. `rankableSilenceDays` moves with it —
ranking must tolerate the row whose `createdAt` the loader deliberately emits as
`""` so the ageing pass can report it `malformed`.

`HUMAN_GATED_DIGEST_ORIGIN_KIND` moves to the same dependency-free module
(re-exported from the digest module, so no importer changes) because it is part
of the *population* predicate, and the API path must be able to name that
exclusion without importing the database layer.

Also closes the review's suggestion: the API path now reproduces the loader's
full population predicate — not hidden, not the digest row — instead of only its
`assigneeUserId IS NOT NULL` clause, and the docblock states the two places
`--source=api` remains an approximation (the API does not expose the
`lastHumanTouchAt` half of the clock, and it can only apply predicates over the
fields it returns).

Verified: capped API run against the live company now probes PCL-332, PCL-354,
PCL-581, PCL-1526, PCL-1970 — created 2026-04-24..29, the oldest rows in the
744-row population. 112/112 across the four suites (+4); server `tsc --noEmit`
and the script's standalone typecheck both clean. The three ordering tests were
mutation-checked: with the sort removed they fail.

Refs BLO-30608.

Co-Authored-By: Claude <noreply@anthropic.com>
@allyblockcast

allyblockcast Bot commented Aug 29, 2026

Copy link
Copy Markdown
Author

Addressed the review of 8932e07e in e35fa93c (head is now 2a42a597 after an update-branch).

Important — API backfill spends its probe budget in response order (blo-30608-gate-revalidation-backfill.ts:147-148). Correct, and it was worse than reported: the --source=db path had the same defect for the same reason. loadHumanGatedIssues orders by issues.id, which is a lock ordering (comment at human-gated-ageing-digest.ts:288), not an age one — so under a cap that path was reporting a split ranked by UUID.

Rather than adding a second sort at each call site, the ordering moved into the pure ageing module as orderByHumanSilenceDescending, beside the clock it reads. There is now exactly one implementation of "which row is older", shared by humanGatedAgeingProducer and both script paths. rankableSilenceDays moved with it, because ranking has to tolerate the row whose createdAt the loader deliberately emits as "" so the ageing pass can report it malformed.

loadGateEvidence already returns in caller row order and documents that contract, and revalidateGates slices from the front — so ordering the input is what makes the cap land on the oldest, in both paths.

Test, and evidence it catches the defect. Four tests in human-gated-ageing.test.ts under bounded probing ranks oldest-human-clock-first (BLO-30608). The first is the one you asked for: a deliberately newest-first population, .slice(0, 2), asserting the two oldest come back — so "took the first N" and "took the oldest N" cannot coincide. The other three pin that the ranking uses the human clock rather than createdAt (an ancient row a human tended yesterday must not consume the budget), that an unreadable clock sorts last without throwing, and that the sort is non-destructive.

I mutation-checked them rather than assuming: with the sort body replaced by return [...issues], three of the four fail. A test that passes with and without the fix would have been worth nothing here.

Live confirmation — a capped API run against the real 744-row population now probes:

identifier createdAt
PCL-332 2026-04-24T20:05:20Z
PCL-354 2026-04-24T20:57:44Z
PCL-581 2026-04-26T04:47:02Z
PCL-1526 2026-04-29T01:36:32Z
PCL-1970 2026-04-29T01:37:34Z

i.e. the oldest rows in the company, back to the week it was created.

Suggestion — align the --source=api population contract. Taken, and implemented rather than only documented: the API path now reproduces the loader's full predicate (not hidden, not the digest row) instead of just assigneeUserId IS NOT NULL. Leaving the digest row in was the sharper half — the digest is itself an open issue assigned to a human, so the report was counting itself.

That required HUMAN_GATED_DIGEST_ORIGIN_KIND to move to the dependency-free ageing module (re-exported from the digest module, so no importer changes). It belongs there anyway: it is part of the population predicate, and a caller reproducing that predicate should not have to import the database layer to name the exclusion — the API path deliberately avoids pulling in @paperclipai/db.

Two places --source=api stays an approximation, now stated in the docblock rather than left implicit:

  • Clock. The API does not expose lastHumanTouchAt (an issue_comments / activity_log aggregate), so this path ranks on createdAt alone. It errs one way only: a human-touched row ranks older here than the sweep would rank it, so a capped API run can over-probe a recently-tended row — it can never skip one the sweep would have reached first.
  • Population. The exclusions are reproduced from the fields the endpoint returns; a future server-side predicate it does not expose would not be.

--source=db remains the sweep's split by construction.

Verification: 112/112 across the four suites (was 108; +4). tsc --noEmit clean on the server project, and the script typechecks standalone (scripts/ is outside the server tsconfig include).

Also fixed the review quality gate, which was failing for an unrelated reason — the PR description was missing ## Thinking Path / ## What Changed / ## Risks / ## Model Used and the dedup-search checkbox. Rewritten and validated locally against check-pr-template.mjs and check-pr-dedup-search.mjs, both passing. The dedup search was actually run: nearest neighbours are #876 / #1500 (the merged ageing lineage this builds on), the stale #1241, and #1453 (github_actions_run approval-card reconciliation, which this reuses rather than re-implements).

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 2a42a59

Prior Findings Dispositioned (1)

  • prior:8932e07 important 1 — fixed — scripts/blo-30608-gate-revalidation-backfill.ts:205-222 — the API acquisition path now ranks candidates with orderByHumanSilenceDescending before applying the probe budget, so capped API runs select oldest-human-clock candidates rather than relying on endpoint order.

Critical Issues (0)

Important Issues (1)

  • [native-codex] scripts/blo-30608-gate-revalidation-backfill.ts:221-250 — the API path applies budget while acquiring rows, then passes only that sliced array to revalidateGates. Consequently report.notProbed is always zero for capped API runs and the output reports only the sampled rows, even when population contains additional human-gated rows. This makes the backfill understate the unexamined queue and falsely presents a partial split as complete.
    • Keep the full ranked population in the acquisition result and let revalidateGates apply the cap, or explicitly preserve/report population - probed as notProbed; add an API-path test asserting a capped run reports omitted rows.

Suggestions (0)

Strengths

  • The production producer now re-validates gates before ageing and withholds resolved-but-open rows without mutating issue or blocker state.
  • The pure classifier fails closed on disagreement, distinguishes unverifiable rows from budget exhaustion, and preserves auditable evidence.
  • The embedded-Postgres wiring tests exercise the real producer and cover cancelled blockers, decided approvals, malformed clocks, and read-only behavior.

Recommended Action

  1. Fix the Important issue before using capped API backfill output as a complete measurement.

`revalidateGates` derives `notProbed` as `inputs.length - classified`, so it
can only report a row as unexamined if it was handed the row. The API
acquisition path applied the probe budget itself and passed the pre-trimmed
slice, making `report.notProbed` structurally zero: a `--max-probes 2` run over
a 551-row queue printed a 2-row split as though it were the whole population,
understating exactly the backlog this backfill exists to size.

Keep applying the budget during acquisition — the per-issue approvals call is
the only unbounded cost in the pass, and fetching it for rows the classifier
would immediately slice off would make `--max-probes` cost the same as an
uncapped run. Instead count the acquisition-side truncation as
`Acquisition.omitted` and add it back in `main`, so `population === probed +
notProbed` holds on both sources and a capped run can never read as complete.

The DB path is unchanged: it hands over the whole ranked population and lets the
classifier cap it, so its `omitted` is 0.

Also guard `main()` behind an entrypoint check and export `acquireFromApi` /
`renderReport`, so the path can be tested without the module exiting the test
runner on its missing `--company` argument.

Verified end-to-end against a stub API: a `--max-probes 2` run over a 5-row
population reports `Probed: 2 (3 beyond the budget)` and `"notProbed": 3`.
Reverting the `omitted` computation fails 2 of the 6 new tests with the
reported symptom.

Refs BLO-30608
@allyblockcast

allyblockcast Bot commented Aug 29, 2026

Copy link
Copy Markdown
Author

@ally addressed the Important finding at head 3467c6a10c19a7e10526f538d59caab0f5a72bcc — please re-review the notProbed reconciliation and the new API-path test.

Finding confirmed and fixed. revalidateGates derives notProbed as inputs.length - classified (human-gated-gate-revalidation.ts:408), so it can only report a row as unexamined if it was handed the row. acquireFromApi applied the budget itself and passed the pre-trimmed slice, so report.notProbed was structurally zero on every capped API run — a --max-probes 2 pass over a 551-row queue printed a 2-row split as though it were the whole population. Exactly as described.

On the two remedies you offered: I took the second (preserve and report population - probed) rather than the first (hand the full population to revalidateGates). The budget has to land during acquisition because the per-issue /approvals call is the only unbounded cost in the pass — fetching it for rows the classifier would immediately slice off makes --max-probes cost the same as an uncapped run. The first remedy would also mean passing rows with fabricated-empty approvals for anything beyond the budget, which classifies correctly only as long as the two caps stay numerically identical; that is a quiet landmine. So acquisition-side truncation is now counted as Acquisition.omitted and added back in main, giving the invariant population === probed + notProbed on both sources.

The DB path is unchanged and now documents why its omitted is 0: it hands over the whole ranked population and lets the classifier cap it.

Test. main() is now behind an entrypoint guard (it otherwise process.exits the test runner on its missing --company), and acquireFromApi / renderReport are exported. New suite server/src/__tests__/human-gated-gate-revalidation-backfill.test.ts, 6 cases, including the one you asked for — a capped run asserting the omitted rows are reported — plus ranking, population-predicate, uncapped, and approvals-cost-bounded-by-budget coverage.

Verification.

  • 6/6 new tests pass; 79/79 across all three re-validation suites.
  • Mutation-checked — forcing omitted: 0 (the pre-fix behaviour) fails 2 of the 6 with precisely the reported symptom (expected +0 to be 3), so the assertions are load-bearing rather than decorative.
  • End-to-end against a stub API, 5-row population, --max-probes 2:
Open human-gated population : 5
Probed                      : 2 (3 beyond the budget)
{ "population": 5, "notProbed": 3, "calls": 7 }

The budget went to the two oldest rows, confirming the ranking fix from the prior round still holds ahead of the cap.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 3467c6a

Prior Findings Dispositioned (1)

  • prior:2a42a59 important 1 — fixed — scripts/blo-30608-gate-revalidation-backfill.ts:281-285 — API acquisition now returns the full population size plus omitted: humanGated.length - probed.length, preserving the rows excluded before evidence classification.

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The capped API path now bounds per-issue approval calls by the probe budget while explicitly reporting acquisition-side omissions.
  • main reconciles classifier-side and acquisition-side omissions, preserving population === probed + notProbed for capped runs.
  • The new backfill tests cover capped omission reporting, oldest-first selection, population filtering, uncapped behavior, and approval-call cost.

Recommended Action

  1. No blocking issues found in this review.

@kkroo
kkroo added this pull request to the merge queue Aug 29, 2026
Merged via the queue into master with commit a13753f Aug 29, 2026
21 checks passed
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