Skip to content

ledger(policy): require a declared reason for any repeat verdict at one head SHA - #9865

Merged
loopover-orb[bot] merged 3 commits into
mainfrom
feat/verdict-immutability-9742
Jul 29, 2026
Merged

ledger(policy): require a declared reason for any repeat verdict at one head SHA#9865
loopover-orb[bot] merged 3 commits into
mainfrom
feat/verdict-immutability-9742

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Closes #9742.

Problem

A verdict's integrity was provable, but its uniqueness was not. decision_records already keeps every evaluation of a (repo, pull, head_sha) — a repeat lands as <id>:revN, so nothing was ever silently replaced — but the record could not say why a second evaluation happened, which verdict it supersedes, or who asked for it. "This PR was evaluated once" and "this PR was evaluated three times and one result was kept" were indistinguishable in the public record.

What changed

Enforcement at the ledger-write layer. persistDecisionRecord refuses a repeat verdict for a head SHA that already carries one unless the write declares a reason, so no caller can bypass it by writing the row itself. A new head SHA (force-push, new commits) is untouched — that is a fresh verdict by definition and needs no reason. migrations/0204 adds three nullable columns (reason, superseded record id, actor), all NULL for a first evaluation, which is the overwhelming majority of rows.

The reason is derived, not judged per call site. Every agent-regate-pr job already carried a delivery id, and synthetic ones already tagged themselves with a prefix naming their producer — but those prefixes were string literals repeated across 8 producers with matching startsWith reads scattered elsewhere. They now live in src/queue/delivery-id.ts, and REEVALUATION_REASON_BY_ORIGIN is typed Record<DeliveryIdOrigin, ReevaluationReason>: adding a producer without assigning it a reason fails the build rather than writing an unexplained verdict. isScheduledRegateSweepJob and the queue-fairness lane classifier now read from the same table, so they cannot drift from it.

scheduled_recheck is a new reason code. The periodic re-gate sweep re-evaluates every open PR at an unchanged head, which is by far the highest-volume cause of a repeat verdict. Without its own code, every sweep tick past the first would have had to borrow one of the incident-shaped codes — drowning them, and (since both call sites swallow a throw) doing so silently while aborting the rest of the maintenance pass.

DecisionRecordContext.reevaluation is required, not defaulted. A repeat verdict whose cause nobody declared is precisely what this invariant refuses, so a future call site has to answer at the type level instead of inheriting a plausible-looking guess. Making it required is what surfaced all six executor call sites rather than leaving me to find them by hand; five derive from their own deliveryId, and the approval-queue accept path — the one case whose cause is a named human — attributes the verdict to whoever accepted it.

Acceptance

For any PR, the public record now shows exactly how many evaluations each head SHA received and why any beyond the first occurred.

Validation

  • test/unit/decision-record.test.ts — 58 tests: refusal without a reason, the linked supersession chain, new-SHA unaffected, actor recorded/trimmed/absent, and the full origin→reason mapping including longest-prefix resolution so regate-repair: is never mis-read as routine regate-sweep: maintenance.
  • Every changed line and branch in the diff is covered (verified locally against the v8 report, branch-counted).
  • npm run test:coverage unsharded: 25,422 passing. tsc --noEmit, db:migrations:check (contiguous 0001..0204), db:schema-drift:check, and docs:drift-check all clean.

@JSONbored JSONbored self-assigned this Jul 29, 2026
@loopover-orb

loopover-orb Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Tip

✅ LoopOver review result - approve/merge recommended

Review updated: 2026-07-29 17:32:56 UTC

17 files · 1 AI reviewer · no blockers · readiness 98/100 · CI green · clean

✅ Suggested Action - Approve/Merge

  • safe to merge

Review summary
This PR implements the #9742 reevaluation-reason ledger requirement cleanly: `persistDecisionRecord` now refuses a repeat verdict for an already-decided head SHA unless a `ReevaluationReason` is supplied, the closed reason vocabulary is enforced via `isReevaluationReason`, and the delivery-id prefix vocabulary is consolidated into `queue/delivery-id.ts` with a `Record<DeliveryIdOrigin, ReevaluationReason>` map that fails the build if a new producer origin is added without a reason. All eight producers, `isScheduledRegateSweepJob`, and `queue-fairness.ts`'s lane classifier were migrated to the shared origin table, and every call site of `persistDecisionRecord`/`DecisionRecordContext.decisionRecord` (agent-action-executor, agent-approval-queue, five processors.ts call sites) was updated to pass a reason, with a thorough new test suite covering the refusal path, the closed-vocabulary rejection, chained supersession (including a 3rd-evaluation-supersedes-2nd-not-1st case), the actor-trim/null behavior, and the fresh-head-SHA no-op case. The migration (0204) adds three nullable TEXT columns with no backfill needed and is D1-remote-safe (no TEMP/PRAGMA/transaction-control statements). One thing worth a note: `test/unit/priority-label-webhook.test.ts` gains ~70 lines of tests about unrelated fail-open/catch behavior for the priority-label webhook path that has nothing to do with the reevaluation-reason ledger this PR is about — that looks like accidental bundling from another change and is worth separating out or explaining.

Nits — 6 non-blocking
  • test/unit/priority-label-webhook.test.ts's new fail-open/catch tests (~70 lines) are unrelated to the stated ledger(policy): verdict immutability per head SHA — re-evaluations require a reason code and leave a trail #9742 reevaluation-reason scope and read like leftover changes from a different PR — consider splitting them out.
  • src/review/decision-record.ts uses the bare literal `3` for the default `attempts` parameter and elsewhere; a named constant would make the retry-count intent clearer (minor, pre-existing pattern).
  • The CI check `validate-code` failed on this commit but only the check name was provided, not its output, so the specific cause (type error, lint, etc.) could not be verified from what's given here.
  • Confirm the `validate-code` failure isn't related to this PR's new required `reevaluation` field on `DecisionRecordContext` — verify every remaining (non-test) call site that constructs a `decisionRecord` object elsewhere in the codebase was updated, since a missed one would be a TS compile error.
  • Consider adding a short doc comment on `REEVALUATION_REASON_BY_ORIGIN`'s exhaustiveness guarantee pointing back to where `DeliveryIdOrigin` is extended, so a future contributor adding a producer knows exactly which two files must change together.
  • PR author also opened the linked issue — Link an issue that was opened by a different contributor, or provide a rationale for why this self-authored issue represents genuine discovery work.

Decision drivers

  • ✅ Code review — No blockers (1 reviewer)
  • ✅ Gate result — Passing (No configured blocker found.)
Context & advisory signals — never blocks the verdict
Signal Result Evidence
Linked issue ✅ Linked #9742
Related work ✅ No active overlap found No same-issue or scoped active PR overlap found.
Change scope ✅ 20/20 Low review scope from cached public metadata (1 linked issue).
Validation posture ✅ 25/25 PR body includes validation/test evidence.
Contributor workload ✅ 10/10 Author activity: 14 registered-repo PR(s), 13 merged, 334 issue(s).
Contributor context ✅ Confirmed Gittensor contributor JSONbored; Gittensor profile; 14 PR(s), 334 issue(s).
Improvement ✅ Minor risk: clean · value: minor
Linked issue satisfaction

Addressed
The diff enforces a per-head-SHA reevaluation reason at the ledger-write layer (persistDecisionRecord/UndeclaredReevaluationError), retains the full supersession chain via revN ids and supersedes_record_id, leaves fresh-head-SHA writes untouched, and adds tests covering rejection without a reason, linked reason-coded chains, and unaffected new-SHA evaluations exactly as required by the issue's acc

Review context
  • Author: JSONbored
  • Role context: owner (maintainer lane)
  • Public audience mode: oss maintainer
  • Lane context: Repository is registered but has no active allocation in the current snapshot.
  • Public profile languages: Python, TypeScript, Ruby, Go, MDX, Shell, Solidity, JavaScript
  • Official Gittensor activity: 14 PR(s), 334 issue(s).
  • PR-specific overlap: none found.
Contributor next steps
  • Start here: Treat this as maintainer-lane context rather than normal contributor-lane activity.
  • Then work through the remaining 1 step in the Signals table above.
Signal definitions
  • Related work = same linked issue, overlapping active PRs, or title/path similarity.
  • Change scope = cached public metadata such as size labels, draft state, and review-burden hints.
  • Validation posture = whether the PR provides enough public validation/test evidence for maintainer review.
  • Contributor workload = public contributor activity and cleanup pressure, not a repo-wide quality failure.
  • Contributor context = public GitHub/Gittensor identity context; non-Gittensor status is not a blocker.
🧪 Chat with LoopOver

Ask LoopOver a question about this PR directly in a comment — grounded only in the same cached, public-safe facts shown above, never a new claim.

  • @loopover ask &lt;question&gt; answers contribution-quality Q&A with source citations and freshness.
  • @loopover chat &lt;question&gt; answers in natural prose from cached decision-pack facts via local inference (maintainer/collaborator; read-only).
  • A plain-language @loopover mention with a real question is routed to the closest matching read-only command automatically — no exact syntax required.

Full command reference: https://loopover.ai/docs/loopover-commands

🧪 Experimental — new and may change.

Decision record
  • action: merge · clause: success
  • config: 05f40875143129f628693b5f46bab8219a85300ce8c38fb0baac28a5d925febb · pack: oss-anti-slop · ci: passed
  • record: 7229ab260f6dfe3bcd880df4d8b1dbbdf12915f9329909715709a6b92bf01cac (schema v5, head 56ccbb5)

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed


💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →.

Checked by LoopOver, a quiet PR intelligence layer for OSS maintainers.

  • Re-run LoopOver review

@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

…ne head SHA (#9742)

decision_records already kept every evaluation of a (repo, pull, head_sha) -- a repeat
lands as <id>:revN, so nothing was ever silently replaced. What the record could not say
is WHY a second evaluation happened, which verdict it supersedes, or who asked for it:
"evaluated once" and "evaluated three times and one result was kept" were
indistinguishable in the public record.

The write layer now refuses a repeat verdict that does not declare a reason, so no caller
can bypass it by writing the row itself. A new head SHA is untouched -- that is a fresh
verdict by definition and needs no reason.

The reason is DERIVED, not judged per call site. The delivery-id prefixes that already
partitioned job producers were scattered string literals with matching startsWith reads
elsewhere; they now live in one module, and the reason map is typed
Record<DeliveryIdOrigin, ReevaluationReason> -- adding a producer without assigning a
reason fails the build rather than writing an unexplained verdict.

scheduled_recheck names the routine sweep. It is by far the highest-volume cause, and
without it every sweep tick past the first would have had to borrow one of the
incident-shaped codes -- drowning them, and (since both call sites swallow a throw) doing
it silently while aborting the rest of the maintenance pass.

DecisionRecordContext.reevaluation is required rather than defaulted: a repeat verdict
whose cause nobody declared is exactly what this refuses, so a future call site must
answer it at the type level instead of inheriting a plausible-looking guess.
@JSONbored
JSONbored force-pushed the feat/verdict-immutability-9742 branch from ccb76e7 to dd7448a Compare July 29, 2026 16:16
…IGIN key

The allowlist matches a producer by a marker string inside its object literal. The
manual re-gate route no longer repeats the `manual-regate:` prefix literal -- it calls
deliveryIdFor("manualRegate", ...) -- so the marker moved with it.
@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.75%. Comparing base (889defe) to head (56ccbb5).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #9865   +/-   ##
=======================================
  Coverage   91.74%   91.75%           
=======================================
  Files         918      919    +1     
  Lines      112965   112987   +22     
  Branches    27184    27193    +9     
=======================================
+ Hits       103642   103667   +25     
+ Misses       8037     8034    -3     
  Partials     1286     1286           
Flag Coverage Δ
backend 95.69% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/api/routes.ts 95.61% <ø> (ø)
src/queue/delivery-id.ts 100.00% <100.00%> (ø)
src/queue/processors.ts 94.73% <100.00%> (+0.08%) ⬆️
src/review/decision-record.ts 100.00% <100.00%> (ø)
src/review/pending-closure-watchdog.ts 100.00% <ø> (ø)
src/review/pr-reconciliation.ts 100.00% <100.00%> (ø)
src/review/surface-disposition-reconciler.ts 90.47% <ø> (ø)
src/selfhost/queue-common.ts 98.65% <100.00%> (ø)
src/selfhost/queue-fairness.ts 100.00% <100.00%> (ø)
src/services/agent-action-executor.ts 96.86% <100.00%> (ø)
... and 1 more

@loopover-orb loopover-orb Bot added the manual-review Gittensor contributor context label Jul 29, 2026
… not longest-match

The longest-prefix tie-break could only run if one prefix were a prefix of another,
which none is -- untestable defensive code standing in for a guarantee nobody stated.
The guarantee is now an invariant test, so adding an ambiguous prefix fails with the
real problem named rather than being silently absorbed by a scan.
@JSONbored JSONbored removed the manual-review Gittensor contributor context label Jul 29, 2026

@loopover-orb loopover-orb Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LoopOver approves — the gate is satisfied and CI is green.

@loopover-orb
loopover-orb Bot merged commit d07813e into main Jul 29, 2026
8 checks passed
@loopover-orb
loopover-orb Bot deleted the feat/verdict-immutability-9742 branch July 29, 2026 17:33
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.

ledger(policy): verdict immutability per head SHA — re-evaluations require a reason code and leave a trail

1 participant