fix(849): the verdict gate replaces every unknown state, and proves the clauses that claim to #890

Merged
timothy merged 11 commits from fix/849-verdict-postwrite into main 2026-08-30 07:55:11 +02:00
Owner

fixes #849

The H10 gate's post-write verification had five routes that all ended the same way: an exemption
success — or a generic pending a later run re-derives into one — standing over a human failure.

Two were attempted inside #742 and withdrawn, and that withdrawal shaped this change. The attempt
withheld the exemption by writing a GENERIC pending, which is exactly what a later run re-derives,
so it moved which run posted the forged green rather than stopping it; and it had no retry path,
because this workflow triggers only on pull_request_target types. The fix needs two properties at
once — sticky, so a later run cannot re-derive it, and reconcilable, so a blip does not cost a
head its exemption permanently. Neither the repair sentinel nor a generic pending has both, which is
why there is now a second sentinel.

Behaviour

before now
No high-water mark exemption posted, verification silently skipped every re-derivable write becomes the sticky sentinel, before the POST
Post-write check ran only after success runs after every write
A malformed .description startswith hard-errors, the whole count is lost the row is dropped, a verdict beside it still counts
Retarget after the POST permanent forged green, no event left to correct it re-counted; the green becomes transient, and a later retarget queues a successor that re-derives it
Any path that cannot establish what the head carries posted nothing, so a possibly-forged success stayed current the unknown state is replaced with the sentinel
A consumed field whose TYPE the schema disallows read as absent — i.e. unattributable, i.e. re-derive unknown state, same replace-and-die route

Reconciliation bounds the stall: a later run pages /statuses/{sha} in full and — only if that
history contains the sentinel's own row, matched by the id the combined read reported — either finds
a Review-verdict: row underneath it (upgrading to the human-only repair sentinel) or finds none and
clears it. Sound because the two endpoints disagree: a masked verdict is invisible on the combined
endpoint and still present in the per-POST history.

Review

Six rounds, each finding less than the last. Cross-family (Codex/GPT-5.6) for rounds 1–2, cold
same-family reviewers throughout, plus the author's own passes.

Stated plainly: Codex hit its usage quota, so rounds 3+ had no cross-family reviewer. In its place
a same-family agent ran a MUTATION SWEEP — enumerate every security-bearing clause the diff adds,
disarm each, run the whole suite per mutant. That is a same-family substitute, not an equivalent one.

The sweep is why several rounds exist: 60 mutants, 40 red, 20 survivors on a tree that had already
passed three per-finding review rounds by two model families. It found, among others, that the
head-push fence arm never marked a declined row (its retarget twin was proved; two callers, one
fixture) and that state=pending after a repair is load-bearing — without it a repaired head
re-enters the post-POST check and replaces the human-only marker with the weaker one.

Round 4 also caught a regression I introduced in round 3: type-testing the consumed fields and
resolving a failure to "" made a corrupt .creator read as unattributable, which greened a human
failure that origin/main had fail-closed on.

Verification

  • scripts/tests/ green (1309); the suite EXECUTES the shipped run: body, not a paraphrase.
  • Every behaviour change ships a test_MUTATION_… bound by a count assertion, so a reworded clause
    fails loudly rather than measuring the unmutated body. Every clause with a predecessor is mutated
    back to it.
  • Six clauses ship deliberately unproven and are ENUMERATED by name in the record, with per-clause
    unreachability arguments — each is defence in depth behind a filter that makes its input well-formed
    for every case a fixture can pose. The sweep independently confirmed that set is exactly right.
  • Two CHAINED tests feed run N's real output into run N+1, because both sentinels are fixed points.
  • Measured on this instance (Gitea 1.27.1): /commits/{sha}/status rows carry id, which the
    row-identity comparison depends on — the job had only ever read ids from /statuses/{sha}.
  • No live-E2E: this is a CI workflow with no app, API or UI surface.

Docs

New record ci.verdict-unverified-write-sentinel. The fence/pending guarantee prose was swept by
CONCEPT rather than phrase, per the scope boundary on the issue: ci.verdict-write-retarget-fence,
ci.exemption-provenance, docs/ci-cd.md, docs/remote-state-inventory.md,
docs/guard-inventory.md and CLAUDE.md.

Rounds 7-8 (after the body above was drafted)

The first cross-family pass in five rounds found a Blocker and reversed two round-7 fixes:

  • select(type == "object"), added so a malformed NEIGHBOUR could not kill the step, made an
    all-unreadable .statuses array read as "no verdict exists" — so a docs-only PR walked to the
    exemption where origin/main had aborted before any write. A green this branch posted and main
    did not.
  • The fence arms judged both snapshots; the correct scope is the row the POST replaces, with the
    opening snapshot able only to STRENGTHEN the write.
  • The "this job's own output" exclusion keyed on the DESCRIPTION, which is not provenance — any
    writer with code: write could wear that text and buy an abstention.

Rebased onto #889, which landed mid-session and added scripts/lib/h10-reviewers.sh — a parser that
reads H10_REVIEWERS out of this very workflow. Verified by hand that it still parses the modified
file, resolves an allow-listed account, rejects a non-member, and that #889's own 75 tests pass
against it.

fixes #849 The H10 gate's post-write verification had five routes that all ended the same way: an exemption `success` — or a generic `pending` a later run re-derives into one — standing over a human `failure`. Two were attempted inside #742 and **withdrawn**, and that withdrawal shaped this change. The attempt withheld the exemption by writing a GENERIC `pending`, which is exactly what a later run re-derives, so it moved which run posted the forged green rather than stopping it; and it had no retry path, because this workflow triggers only on `pull_request_target` types. The fix needs two properties at once — **sticky**, so a later run cannot re-derive it, and **reconcilable**, so a blip does not cost a head its exemption permanently. Neither the repair sentinel nor a generic `pending` has both, which is why there is now a second sentinel. ## Behaviour | | before | now | |---|---|---| | No high-water mark | exemption posted, verification silently skipped | every re-derivable write becomes the sticky sentinel, **before** the POST | | Post-write check | ran only after `success` | runs after **every** write | | A malformed `.description` | `startswith` hard-errors, the whole count is lost | the row is dropped, a verdict beside it still counts | | Retarget after the POST | permanent forged green, no event left to correct it | re-counted; the green becomes transient, and a later retarget queues a successor that re-derives it | | Any path that cannot establish what the head carries | posted nothing, so a possibly-forged `success` stayed current | the unknown state is **replaced** with the sentinel | | A consumed field whose TYPE the schema disallows | read as absent — i.e. unattributable, i.e. re-derive | unknown state, same replace-and-die route | **Reconciliation** bounds the stall: a later run pages `/statuses/{sha}` in full and — only if that history contains the sentinel's own row, matched by the `id` the combined read reported — either finds a `Review-verdict:` row underneath it (upgrading to the human-only repair sentinel) or finds none and clears it. Sound because the two endpoints disagree: a masked verdict is invisible on the combined endpoint and still present in the per-POST history. ## Review Six rounds, each finding less than the last. Cross-family (Codex/GPT-5.6) for rounds 1–2, cold same-family reviewers throughout, plus the author's own passes. **Stated plainly: Codex hit its usage quota, so rounds 3+ had no cross-family reviewer.** In its place a same-family agent ran a MUTATION SWEEP — enumerate every security-bearing clause the diff adds, disarm each, run the whole suite per mutant. That is a same-family substitute, not an equivalent one. The sweep is why several rounds exist: **60 mutants, 40 red, 20 survivors** on a tree that had already passed three per-finding review rounds by two model families. It found, among others, that the head-push fence arm never marked a declined row (its retarget twin was proved; two callers, one fixture) and that `state=pending` after a repair is load-bearing — without it a repaired head re-enters the post-POST check and replaces the human-only marker with the weaker one. Round 4 also caught a **regression I introduced in round 3**: type-testing the consumed fields and resolving a failure to `""` made a corrupt `.creator` read as unattributable, which greened a human `failure` that `origin/main` had fail-closed on. ## Verification - `scripts/tests/` green (1309); the suite EXECUTES the shipped `run:` body, not a paraphrase. - Every behaviour change ships a `test_MUTATION_…` bound by a count assertion, so a reworded clause fails loudly rather than measuring the unmutated body. Every clause with a predecessor is mutated back to it. - **Six clauses ship deliberately unproven and are ENUMERATED by name** in the record, with per-clause unreachability arguments — each is defence in depth behind a filter that makes its input well-formed for every case a fixture can pose. The sweep independently confirmed that set is exactly right. - Two CHAINED tests feed run N's real output into run N+1, because both sentinels are fixed points. - Measured on this instance (Gitea 1.27.1): `/commits/{sha}/status` rows carry `id`, which the row-identity comparison depends on — the job had only ever read ids from `/statuses/{sha}`. - No live-E2E: this is a CI workflow with no app, API or UI surface. ## Docs New record `ci.verdict-unverified-write-sentinel`. The fence/pending guarantee prose was swept by CONCEPT rather than phrase, per the scope boundary on the issue: `ci.verdict-write-retarget-fence`, `ci.exemption-provenance`, `docs/ci-cd.md`, `docs/remote-state-inventory.md`, `docs/guard-inventory.md` and `CLAUDE.md`. ## Rounds 7-8 (after the body above was drafted) The first cross-family pass in five rounds found a **Blocker** and reversed two round-7 fixes: - `select(type == "object")`, added so a malformed NEIGHBOUR could not kill the step, made an all-unreadable `.statuses` array read as "no verdict exists" — so a docs-only PR walked to the exemption where `origin/main` had aborted before any write. A green this branch posted and `main` did not. - The fence arms judged both snapshots; the correct scope is the row the POST replaces, with the opening snapshot able only to STRENGTHEN the write. - The "this job's own output" exclusion keyed on the DESCRIPTION, which is not provenance — any writer with `code: write` could wear that text and buy an abstention. Rebased onto #889, which landed mid-session and added `scripts/lib/h10-reviewers.sh` — a parser that reads `H10_REVIEWERS` out of this very workflow. Verified by hand that it still parses the modified file, resolves an allow-listed account, rejects a non-member, and that #889's own 75 tests pass against it.
timothy added 8 commits 2026-08-30 05:23:51 +02:00
The gate's post-write verification had five routes that all ended the same way — an
exemption `success`, or a generic `pending` a later run turns into one, standing over a
human `failure`.

Two of these were attempted inside #742 and withdrawn, and the withdrawal is what shaped
this change. That attempt withheld the exemption by writing a GENERIC `pending`, which is
exactly what a later run re-derives into `success` — it moved which run posted the forged
green rather than stopping it — and it had no retry path, because this workflow triggers
only on `pull_request_target` types, so a transient failure on a PR's last event stalled an
exempt PR until a human nudged it. The fix therefore needs two properties at once: sticky,
so a later run cannot re-derive it, and reconcilable, so a blip does not cost a head its
exemption permanently. Neither the repair sentinel nor a generic `pending` has both, which
is why there is now a second sentinel rather than a reuse of the first.

What changed:

1. No high-water mark => the exemption is WITHHELD before the POST and the head is marked
   with the new `UNVERIFIED_DESC` sentinel. Withholding before the write rather than
   posting and repairing matters because the defect is known in advance: publishing a green
   to take it back opens a window branch protection, and an already-scheduled auto-merge,
   can see.
2. Post-write verification runs after EVERY write, not only `success`. A generic `pending`
   masks a rejection landing in its own write window just as well, and carries no marker,
   so the next run re-derives it with the human's row now below THAT run's mark.
3. `.description` is type-tested before `startswith`. `(.description // "")` does not
   replace a NUMBER, so `startswith` hard-errors on one, killing the whole count — the
   genuine verdict beside the malformed row is lost with it.
4. The retarget count is re-taken AFTER the POST on the exemption path, closing the
   PERMANENT forged green `ci.verdict-write-retarget-fence` listed as its residual 1. The
   retarget axis only: a push after the POST moves the head, so the status no longer gates
   that PR, while a retarget changes the effective diff with the sha unchanged.
5. An unreadable combined-status read retries once and then REPLACES the unknown state
   instead of declining to write. Declining protects a real verdict and leaves a FORGED one
   — an off-list `success` is the row #742 exists to revoke, revocation happens by
   re-deriving it, and the job then went red on a status branch protection does not read.

One defect this introduced and fixed on the way: widening the post-write gate to every
write made the job match its OWN row, because the machine-sentinel arm selects on a null
creator. A run taking the carry-forward path POSTed `$REPAIR_DESC`, then found "a sentinel
above the mark", then repaired to the identical description. `--arg own "$desc"` excludes
it, by description rather than by id — the id of the row just written is not knowable
there.

Reconciliation is what bounds the stall: a later run pages `/statuses/{sha}` in full and
either finds a `Review-verdict:` row underneath the sentinel — an established fact, so it
upgrades to the repair sentinel, clearable only by a human — or finds none and clears it.
It is sound because the two endpoints disagree: a masked verdict is invisible on the
combined endpoint (latest row per context, which is the sentinel) and still present in the
per-POST history.

Tests: each fix is paired with a `test_MUTATION_…` proof that restores the exact
predecessor text through a new `_run_classify(mutate=…)` knob, whose count assertion is the
binding — a clause that has since moved substitutes zero times and fails loudly rather than
measuring the unmutated body. Two CHAINED tests feed run N's real output into run N+1,
because both sentinels are fixed points and a single hop cannot assert a fixed point: the
raced-`pending` repair must survive the run that would otherwise grant the exemption, and
the unverified sentinel must not decay while it cannot be reconciled.

Docs: new record `ci.verdict-unverified-write-sentinel`; the now-false guarantee prose in
`ci.verdict-write-retarget-fence` (its `rule:` frontmatter, the "resolves it" opener, "the
fence above closes", the truncating-block claim and residual 1), `ci.exemption-provenance`,
`docs/ci-cd.md`, `docs/remote-state-inventory.md` and `CLAUDE.md` corrected by concept
rather than by phrase, per the scope boundary recorded on the issue.

fixes #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
Two independent cold reviews (Codex/GPT-5.6 cross-family, and a cold Claude reviewer in
its own worktree) converged on the same class: paths where "this job cannot establish
what is on the head" still resolved by leaving the head alone, which protects a real
verdict and leaves a forged one.

Behaviour:

1. The four page-2 completeness refusals now replace the unknown state too. They were
   excluded on the reasoning that the probe fires when NO row for this context was on page
   1, so there is no green of any provenance to leave standing — self-contradictory, since
   the only reason page 2 is read is that the row may be beyond page 1, which the probe's
   own message says. Accepted cost, stated in the record: a head with more CONTEXTS than
   the 50-row cap stalls every run; measured 2026-08-29, this repo puts 8 on a `main` head,
   and that case already stalled with an ABSENT check.
2. The no-mark downgrade covers every re-derivable write, not only `success`. Restricting
   it analysed the wrong PR: the damaging case is one that IS exemptible and got the
   generic `pending` only from a transient enumeration failure. That description carries no
   marker, nothing verifies it without a mark, and the next run re-derives it into the
   exemption with the human row below its own mark — route 2's damage through route 1's
   condition. `$REPAIR_DESC` stays exempt, being stronger and not re-derivable.
3. The fence branch that cannot trust its retarget count while holding a derived `success`
   writes the sentinel instead of abstaining. It is reached only after the classification
   DECLINED to inherit the row the head carries, so posting nothing left that row current;
   the message said the context "stays absent", true only of a head that had none.
4. Reconciliation needs a WITNESS: it may clear only over a complete history containing the
   sentinel's own row. `ex_unverified` means the combined endpoint just returned that row
   and `/statuses/{sha}` keeps one per POST, so a complete-but-empty history contradicts a
   write that demonstrably happened — and `page_statuses` accepts an empty page 1 as
   complete, which is what made it reachable. Both reviewers reproduced the clear-then-exempt
   outcome. The shipped positive test used exactly that impossible fixture, so it was
   pinning the defect; it now seeds the sentinel row, and an impossible-empty negative plus
   a witness mutation proof were added.
5. The mid-run "did this row change" comparison now includes the row ID. The two sentinels
   are byte-identical by design, so a mid-run replacement of one by another was invisible to
   a state/creator/description triple. Measured 2026-08-29 (Gitea 1.27.1, head 736649b3):
   the COMBINED endpoint carries `id` on every row, ids 14..30 ascending — the job had only
   ever read ids from `/statuses/{sha}`. Where a server omits it both sides are empty and
   the comparison degrades to the pre-existing text test.
6. The repair has a FLOOR — it may never write a description weaker than the one this run
   decided — and is skipped when it would rewrite what is already there. Widening the gate
   to every write meant a transient post-write read could rewrite a correct `$REPAIR_DESC`
   carry-forward with the machine-clearable sentinel, reversing the ordering rule the
   classification chain states.

Writing the sentinel and failing the job are separate decisions, which is why
`replace_unknown_state` and `replace_unknown_and_die` are two functions: the read refusals
were already non-zero exits on `main` and stay red; the fence branch exited 0 there and
still does, because an unreadable timeline is an ordinary hiccup and reddening every one is
noise this file elsewhere refuses to add.

Prose corrected where it now overclaimed: "the green never stands" after the post-POST
re-check is wrong — it is live between the POST and the repair, so the check makes a
permanent green TRANSIENT; "a later run reconciles this automatically" is wrong in the one
case where the replacement costs anything, since finding a masked verdict UPGRADES to the
human-only sentinel; and the mutation-proof framing claimed every mutant restores the exact
predecessor, when two do, one restores the shape #742 withdrew, and the rest disarm clauses
that have no predecessor. The quiet-timeline positive control now counts timeline walks,
because a single POST is also what a skipped re-check produces.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
Two more cold reviews — cross-family (Codex/GPT-5.6) and a cold Claude reviewer that ran
the mutants itself — converged on two separate things: a remaining class of paths that
still left an unknown state standing, and, more importantly, that several clauses this
branch claimed as fixes SURVIVED mutation of the exact text they name.

## Behaviour

1. The reconciliation witness matches the CURRENT row's `id`, not merely a row with the
   sentinel's description. Description alone is satisfied by an OLDER identical sentinel —
   which is what a fixed point produces — so a read carrying only the earlier row cleared
   the sentinel while the verdict buried under the current one ended up below the fresh
   mark. Falls back to the description where the server omits `id`.
2. The two OBSERVED-mutation arms mark a head that carries a row this run declined, instead
   of only abstaining. They are still right not to post their CLASSIFICATION — computed
   against a base or head the PR may no longer have — but a declined row must not stay
   authoritative for the whole window until a successor finishes, and for a PR's FIRST push
   no successor is queued at all. Scoped to `pre_state` being non-empty, so the common path
   stays quiet.
3. `replace_unknown_state` RETURNS a status. Its first version ended the failure arm with a
   successful `echo`, so it reported 0 after both POSTs failed and the fence caller's
   `exit 0` reported an abstention that had not happened.
4. An `id` difference counts only when BOTH reads supplied one. A response that omits `id`
   beside one that includes it otherwise reads as a replacement, and this guard's reaction
   is to abstain — over a row the classification had already declined.
5. Every element and every consumed field of the combined response is type-checked before
   extraction, and a schema failure routes to the replacement. `.statuses` being an array
   was checked; its ELEMENTS were not, so one scalar made `select(.context == $c)`
   hard-error and `set -e` took the step down before any path could mark the head.
6. The path-predicate failure replaces rather than merely exiting, for the same reason.
7. `$UNVERIFIED_DESC` says "Status write", not "Exemption write". It is now written on paths
   that grant no exemption at all, and it is the operator-facing text of a required check.
8. The no-op-repair skip keeps the human `::error::`. Skipping the WRITE is right — the head
   already carries the strongest marker — but that message is the only place a reviewer is
   told their verdict was buried. `raced_why` is a sentence now, not the token `human`.

## Proof

The cold reviewer measured three of the six round-2 claims surviving mutation of their own
clause, one against the verbatim predecessor from the previous commit. Nine proofs added:
the no-mark downgrade's SCOPE (not just the description it writes), the page-2 refusals, the
untrusted-fence write, the row-`id` comparison, the repair floor, the no-op skip, both `$own`
exclusions, the write-result return, and the both-ids-present rule.

Two of those needed the test double to grow: the combined-status stub emitted no `id` at
all, so the `ex_id` clause had never once run with a non-empty value; and POSTs always
succeeded, so both write helpers' failure arms were unreachable.

The `$own` exclusions and the no-op skip are OUTCOME-redundant — mutating either alone leaves
the post sequence unchanged, which is how duplicate guards hide each other. Their proofs
assert the LOG, because what the exclusions alone decide is whether the job reports a race
against its own row. One clause is left deliberately unproven and named as such in the record
and the guard inventory rather than counted: the path-predicate failure branch has no fixture
that can reach it.

## Also

Round 2 left two comment paragraphs duplicated verbatim and a block header narrower than its
block; both fixed. Stale prose corrected in the workflow ("dies WITHOUT posting", "post-write
verification never runs for it", "this block only runs after a `success`"), `docs/ci-cd.md`
("the fence never re-counts", "the history is read twice" — it is three now),
`ci.exemption-provenance` and `docs/guard-inventory.md`.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
A third cold review, which ran the mutants itself, found one measured direction regression
against `origin/main`, one ordering inversion, and four clauses this branch claims as
fixes that survived mutation of their own text.

## The regression

Round 3 type-tested the four consumed fields of the existing `h10` row and resolved a
failure to `""`. For `.creator` that means "no creator" — unattributable — which is a
LICENCE TO RE-DERIVE. Measured, same fixture, both bodies: a head carrying
`h10=failure` with `"creator": 7` posts `Exempt: docs-only change` here and posted NOTHING
on `main`, which died on `.creator.login` before any write. Fail-closed became fail-open.

The rationale that produced it came from #763, whose site is the POST-WRITE filter: there,
dying leaves a green already published, so dropping the row is the safe direction. Here the
alternative is dying BEFORE any write. The deferral rationale did not transfer — which is
the shape this repo has a record for.

A wrong TYPE is now distinguished from a legitimately ABSENT value: `null` is the machine
creator, an unset description and every field of the `{}` no-verdict row; anything else is
unknown state and takes the route an unreadable ELEMENT already took.

## The ordering inversion

`mark_declined_row_if_any` was scoped to "the head carries any row", so it fired on a head
carrying `$REPAIR_DESC` and replaced the human-only marker with the machine-clearable one —
inverting the ordering the SAME commit added a floor to protect at the repair site. One
mechanism, three writers, and only two had the rule.

It also buried a verdict an ALLOW-LISTED reviewer wrote for another base. "Declined" is
decided against this event's `$BASE_REF`, so such a row is still the right answer for the
base it names and the successor run for that base short-circuits on it; burying it costs a
manual re-post on an ordinary retarget-onto-the-reviewed-base flow. Membership is tested on
the raw creator, not on `ex_human`, which the base check has already cleared — the question
is who wrote the row, not whether it governs this diff.

## The unproven clauses

Four claims survived mutation, including the headline one. The witness fixture had been
designed AROUND its own discriminator — its comment said a seed with an unrelated id "would
make this run carry the sentinel forward … and the guard under test would never be reached",
which is a description of the test not reaching it. Eleven proofs added, covering the
witness-by-id, the head arm's own call site (two callers of one helper, one fixture), the
mark helper's result propagation, and the round-4 behaviour above.

`raced_why`'s human value is a named constant now: it is the one such value that is also a
PREDICATE, compared twice, and a drift in either copy silently downgrades the human
`::error::` — the only message that tells a reviewer their verdict was buried.

## Docs

The renamed sentinel literal in two places; three documents still asserting the fence
"writes NOTHING"; the record's `mechanics:` still describing round 2's witness; the
replacement-site list, which had grown by four; a residual pointing "below" at something
above it; and `CLAUDE.md`'s "closed", which is stronger than the record it points at — that
record lists six residuals including both endpoints failing at once. The proof inventory is
stated as an invariant (every clause with a predecessor is mutated back to it) rather than a
count that rots.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
Codex was unavailable for this round (usage quota), so the cross-family reviewer was
replaced by a same-family agent doing one mechanical job: enumerate every security-bearing
clause the diff adds, disarm each, and run the WHOLE suite per mutant. 60 mutants, 40 red,
20 survivors — a yield no per-finding review in this series came close to, because a review
looks at what the diff says it does and a sweep looks at what the tests actually pin.

## Proved (nine)

- the description type test in the RECONCILIATION `buried` filter — exact twin of the
  post-write one, which had a proof; without it a numeric description hard-errors
  `startswith`, the count comes back unusable, and the genuine verdict on the next row is
  lost with it;
- the `.status` / `.description` / `.id` type tests, parametrised over all four consumed
  fields so a fifth cannot be added without a case (`.creator`'s was the only one proved);
- both retry loops — the combined read and `repair_status_to`'s second POST. Against a stub
  that fails EVERY attempt a retrying reader and a one-shot reader are indistinguishable,
  which is how a retry ships unexercised; the fixtures now fail only the first attempt;
- the mid-run guard's self-exemption, which is what stops a sentinel-writing run abstaining
  on the row it was about to replace with an equivalent one;
- both repair-write failure paths (the repair and the post-POST replacement), reachable only
  with a stub that lets the FIRST post through and fails the rest — with every post failing
  the job dies on its own classification write and never reaches them;
- the two `state=pending` updates after a repair. The first is load-bearing beyond tidiness:
  without it a repaired head re-enters the post-POST check and, on a retarget it then
  observes, replaces `$REPAIR_DESC` with the weaker reconcilable sentinel — the same ordering
  inversion the floor beside it exists to prevent, reached by another route.

## Declared unreachable (six), enumerated rather than counted

The path-predicate failure branch; the empty-`row` refusal; page 2's non-numeric length; the
`$witness` normalisation; and the two unusable-count arms. Each is defence in depth behind a
filter that makes its input well-formed for every case a fixture can pose — the same standing
exception the post-write unusable-count arm already carried.

That set has gone two -> five -> six across three rounds as the sweep widened. Naming them is
the point: an inventory that undercounts reads as a checked claim and talks the next reader
out of verifying, which is the same defect as inventing coverage — and this branch has
already had to correct that twice.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
The `mechanics:` field ended with the same sentence twice — the enumeration of the
unreachable clauses was appended without removing the tail it replaced. Found by the
mutation-sweep agent while reading the record it was checking its own results against.

In its place, the number that makes the technique worth its cost: 60 mutants, 40 red, 20
survivors, on a tree that had already been through three per-finding review rounds by two
model families.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
A fourth cold review of the tip. No Blockers, no High: it enumerated every POST site and
every exit and could not construct an input where this branch writes a `success` that
`origin/main` would not.

## The arms judged the wrong snapshot

`mark_declined_row_if_any`'s three refusals all read `$pre_*` — the FIRST read — while the
POST replaces whatever row is CURRENT. So a reviewer's verdict arriving between the two
reads slipped past every refusal written to protect it: the base mismatch clears
`ex_attributable` so the mid-run abstain declines, `pre_creator` is empty so the allow-list
loop declines, and the arm marks a row nobody evaluated. Executed trace, control and case.
Both snapshots are consulted now, and either one vetoes.

Recovery was not free, which is why it mattered: the next run's reconciliation counts that
`Review-verdict:` row as buried and upgrades to the human-only sentinel — exactly the cost
the refusal exists to avoid.

The arm also marked this job's OWN ordinary machine `pending`. Every PR past its first run
carries one, so "kept off the commonest path in this job" was true only of a head with no
status at all. Scoped on the DESCRIPTION rather than on `creator: null`, which would also
exclude a machine `success` from another workflow — the row this marking exists for.

## Two comments that invited a bug

- One still described the round-4 REGRESSION as the intended behaviour ("a malformed row
  reads as no creator, hence re-derived"), two lines below the block recording that it was
  fixed. Adjacent comments giving contradictory accounts of one line, and the stale one
  licenses reinstating it.
- The fault token's justification said "no Gitea status field contains a NUL". The token is
  SOH (0x01). That is not pedantry: `$'\000…'` is the EMPTY STRING in bash, so an editor
  correcting the code to match the comment would make every legitimately-absent field
  compare equal to the token and send every clean head down the fail-closed route — the gate
  would stall every PR.

## Docs

The record quoted a predicate that no longer exists (`[ "$ex_desc" != "$pre_desc" ]`, now
`$row_replaced`); `docs/ci-cd.md` stated the reconciliation witness unconditionally when the
code degrades to a description match where the server omits `id`; one of the six unproven
clauses carried a wrong `because` (the conclusion holds via `(.id | numbers) // -1` over a
validated array, not via the schema-fault route, which governs a different endpoint's row);
and the record's own counts read as a contradiction cold — 20 surviving MUTANTS collapse
onto 6 distinct CLAUSES, several clauses admitting more than one disarming edit. The
run-by-run provenance moved to the issue, where `docs.no-session-narrative` says it belongs.

Two existing mutation proofs lost their binding to the reworded clauses and failed loudly
rather than measuring the unmutated body, which is what that count assertion is for. Rebound.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
The first cross-family review in five rounds (Codex/GPT-5.6, once its quota reset). It found
a Blocker four same-family rounds had missed, and REVERSED two of round 7's fixes — which is
the more useful result, because both were made in response to a review and both overshot in
the direction the finding pointed.

## The Blocker: dropping unreadable elements became "no verdict exists"

Round 3 added `select(type == "object")` so a malformed NEIGHBOUR could not kill the step.
When it drops EVERY element, `first // {}` yields `{}`, all `ex_*` read empty, and the job
concludes no verdict exists — so a docs-only PR walks straight to the exemption. Measured:
`{"total_count":1,"statuses":[7]}` posts `Exempt: docs-only change` here and posted NOTHING
on `origin/main`, which raised jq error 5 and aborted under `set -e` before any write. An
input on which this branch greens a head that `main` fails closed on, and if that scalar is a
mangled rendering of the head's human `failure`, the rejection is what gets greened.

The asymmetry is now the rule: a malformed row BESIDE one we did read is noise; a malformed
row where we found NOTHING is the only evidence there was. The absence conclusion has to be
earned over a list with no unreadable elements in it.

## Two round-7 fixes that overshot

- **The arms judged both snapshots.** Round 6's review said they judged `$pre_*` while the
  POST replaces `$ex_*`; I made both veto, which is the mirror defect — an opening row since
  REPLACED by a machine `success` still vetoed, so the arm left that success gating the head.
  They judge the current row alone now. The opening snapshot keeps exactly one job: it can
  make the write STRONGER, never suppress it.
- **The "this job's own output" exclusion keyed on the DESCRIPTION.** A description is not
  provenance. Any workflow with `code: write` can POST a `creator: null` row and any
  repository writer can POST one with a creator, either wearing this job's text — so masking
  a human `failure` with a lookalike `pending` bought an abstention, and the successor
  re-derived it as ordinary machine output with the rejection below its own mark. Removed;
  the attempt is recorded because it is the tempting one, and there is no issuer field that
  could make it safe.

## A guard that could not be reached, folded into the one that can

The repair veto turned out unreachable: an `$ex_desc` of `$REPAIR_DESC` with a different
`$desc` is caught by the mid-run sentinel guard long before an arm runs, and when `$desc` IS
`$REPAIR_DESC` the promotion writes the same string. Rather than keep a guard no fixture can
reach — or delete it on the strength of a check three hundred lines away — the invariant is
enforced where it is local and provable: the mark carries the strongest description any
snapshot shows, then declines to write what is already there.

`ci.exemption-provenance` still called the post-final-count window a PERMANENT forged green
in its `rule:` frontmatter and body; the post-POST re-count made it transient two rounds ago.

refs #849
Decisions-Edit: yes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
timothy added 1 commit 2026-08-30 05:27:43 +02:00
docs(849): re-apply the CLAUDE.md correction the rebase resolution dropped
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 10s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 17s
PR Gates / Docs update reminder (pull_request) Successful in 21s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 27s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 13s
review-verdict/h10 Awaiting review verdict for 879208d
Review verdict / Set review-verdict status (pull_request_target) Successful in 13s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 11m43s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 12m49s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 8m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Failing after 15m0s
879208d554
The rebase onto #889 resolved a CLAUDE.md hunk in favour of upstream, which kept #845's new
clause and discarded #849's — leaving the file asserting that a rejection landing inside a
run's own write window is "a separate and still-open route". Both edits belong: they touch
one sentence for different reasons.

This message also repairs the TRAILER BLOCK for the whole branch, which CI caught and local
runs did not. Every commit here ended:

    refs #849
    Decisions-Edit: yes

    Co-Authored-By: ...

Git parses only the LAST paragraph as trailers, so the blank line put `Decisions-Edit: yes`
in the second-to-last one and it was never a trailer at all — `git log --format=%(trailers)`
showed only the Co-Authored-By pair. `refs #849` without a colon disqualifies that paragraph
independently. `decisions_validate.py` arms its rationale-prose exemption from ANY non-merge
commit in the range, so one correctly-formed block repairs all nine.

Refs: #849
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
timothy force-pushed fix/849-verdict-postwrite from 0f1cc2f733 to 879208d554 2026-08-30 05:27:43 +02:00 Compare
timothy added 1 commit 2026-08-30 06:05:05 +02:00
fix(849): round 9 — sentinel TEXT is not sentinel STATE, and an unreadable neighbour is not noise
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 16s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 19s
PR Gates / Docs update reminder (pull_request) Successful in 22s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
review-verdict/h10 Awaiting review verdict for c2c70e5
Review verdict / Set review-verdict status (pull_request_target) Successful in 13s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 14m56s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 15m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m8s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 9m36s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 9s
c2c70e50ad
The round-8 cross-family review found two more Blockers. Both are cases where a principle
this branch had already established was applied in one place and not the adjacent one.

## Sentinel text is not sentinel state

`ex_repair` and `ex_unverified` were set from the DESCRIPTION alone. A `success` carrying
`$REPAIR_DESC` verbatim — from a machine or an off-list account — therefore read as a
sentinel: the mid-run guard exited on it, and the mark's already-there test matched it and
returned without POSTing. A green stood on an unreviewed head, on a first-push event with no
successor guaranteed.

This is the same reasoning that removed the "this job's own output" exclusion one round
earlier: a description is not provenance. It is not state either. Both sentinels this job
writes are `pending` by construction, so requiring it costs nothing.

## An unreadable neighbour cannot be shown to be unrelated

Round 8 refused only when NO readable target row was found, reasoning that a malformed row
beside a good one is noise. An element whose `.context` cannot be read cannot be shown to be
a DIFFERENT context — so it may be a mangled rendering of this head's own rejection, and the
one-row-per-context invariant that would rule that out is exactly what a schema-corrupt
response has already broken. The branch's own POSITIVE CONTROL encoded the failing case: a
scalar beside an off-list `success`, which this branch re-derived and greened where
`origin/main` errored on the scalar and posted nothing. That test is inverted, not adjusted.

The cost is a stall on any head carrying a malformed element — the correct direction for a
required check, since it withholds a green rather than granting one.

## Two clauses deleted rather than proved

Chasing a proof for the mark's repair promotion showed its three clauses were MUTUALLY
REDUNDANT: each alone produces the outcome, so no single-clause mutation could show harm.
Tracing why revealed that two are unreachable as a sole cause — a repair sentinel at the
first read sets `ex_repair`, which forces `desc="$REPAIR_DESC"`, and one arriving mid-run is
caught by the sentinel guard unless this run is itself writing that string. So they are
redundant rather than unprovable, and they are gone. One clause, one mechanism, one proof.

refs #849

Refs: #849
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
timothy closed this pull request 2026-08-30 06:31:25 +02:00
timothy reopened this pull request 2026-08-30 06:31:28 +02:00
timothy added 1 commit 2026-08-30 07:10:24 +02:00
fix(849): restore 13 proofs round 9 deleted by accident, and one comment that argued both sides
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 22s
Review verdict / Set review-verdict status (pull_request_target) Successful in 17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m45s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 11m40s
review-verdict/h10 Review-verdict: MERGEABLE @ d4b36ac (base: main)
d4b36ac232
The round-9 cross-family review found no Blockers and no Highs, and independently confirmed
the clause deletion it was asked to check. What it did find is that round 9 removed FIFTEEN
test definitions and added four — a net loss of eleven — where the commit message claimed
two. Verified against the parent: 227 definitions before, 216 after.

The cause is mechanical and worth naming, because it produces a green suite: the round-9
edits replaced whole source RANGES (`s[:start] + new + s[end:]`) whose end anchor was the
next test rather than the end of the one being rewritten, so everything in between went with
it. The suite then passed because the tests were GONE, not because the code was right — the
exact shape this issue exists to prevent, reproduced in its own test file.

Among the casualties were round 4's proofs for two earlier BLOCKERS:

- `test_a_generic_PENDING_with_no_mark_also_becomes_the_sentinel` and its mutation, which
  pin the no-mark downgrade covering every re-derivable write rather than only `success`;
- `test_a_MALFORMED_creator_FIELD_...` and its mutation, which pin a wrong-typed field
  taking the fault route rather than reading as absent and licensing a re-derive.

Also lost: both `$own`-exclusion proofs, the no-op-repair skip proof, the id-asymmetry pair
(the reviewer's named example), and two write-failure propagation proofs.

All 13 unintended deletions are restored verbatim from the parent commit and ALL PASS against
round 9's code, so nothing had regressed — the harm was the missing evidence, not the
behaviour. The two deletions that WERE intended stay deleted: a test superseded by
`..._still_refuses`, and the positive control round 9 inverted.

Prose: the comment above the unreadable-element guard still argued a malformed neighbour is
safe noise once the target row was found, eleven lines above code that now refuses
unconditionally — two adjacent blocks giving opposite accounts of one rule, and the stale one
licenses reinstating the Blocker.

Refs: #849
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
timothy closed this pull request 2026-08-30 07:25:06 +02:00
timothy reopened this pull request 2026-08-30 07:25:09 +02:00
Author
Owner

Review-verdict: MERGEABLE @ d4b36ac

Nine review rounds: cross-family (Codex/GPT-5.6) rounds 1-2 and 8-9, cold same-family throughout, plus a mutation SWEEP over every clause the diff adds (60 mutants, 40 red, 20 survivors) that found what per-finding review did not. Every reachable clause carries a mutation proof bound by a count assertion; six unreachable clauses are enumerated by name in the record rather than counted. CI green on this head; the earlier preflight red was a registry eviction of the pinned CI toolchain image, recovered per the documented runbook and unrelated to this diff.

Review-verdict: MERGEABLE @ d4b36ac Nine review rounds: cross-family (Codex/GPT-5.6) rounds 1-2 and 8-9, cold same-family throughout, plus a mutation SWEEP over every clause the diff adds (60 mutants, 40 red, 20 survivors) that found what per-finding review did not. Every reachable clause carries a mutation proof bound by a count assertion; six unreachable clauses are enumerated by name in the record rather than counted. CI green on this head; the earlier preflight red was a registry eviction of the pinned CI toolchain image, recovered per the documented runbook and unrelated to this diff.
timothy merged commit 58681b3a79 into main 2026-08-30 07:55:11 +02:00
timothy deleted branch fix/849-verdict-postwrite 2026-08-30 07:55:12 +02:00
Sign in to join this conversation.