--- key: ci.verdict-unverified-write-sentinel title: '2026-08-29 — the review-verdict job verifies EVERY write, withholds an exemption it cannot verify, and marks the head with a RECONCILABLE sticky sentinel (#849)' status: active since: '2026-08-29' supersedes: none superseded-by: none rule: 'The `review-verdict/h10` job runs its post-write race check after EVERY status write, not only an exemption `success`, and where no mark exists to run it against, the write ITSELF becomes the sentinel — so every status this job writes is either verified against the history or marked as unverifiable: a GENERIC `pending` masks a rejection landing in its own write window exactly as a `success` does, and because it carries no marker a later run re-derives it into an exemption with the human row now below THAT run''s high-water mark — the damage arrives one event later, not never. Where a write cannot be checked at all the job writes a SECOND sentinel, `UNVERIFIED_DESC`, distinct from the repair sentinel and never interchangeable with it: the repair sentinel ASSERTS that a verdict existed and was buried, which only the arm that actually COUNTED such a row may claim, while every "could not check" arm — an unreadable or over-cap history, an impossible empty history, an unusable count — states only what it established. Both are STICKY (the classification refuses to grant an exemption over either, and re-writes its own description verbatim, so each is a FIXED POINT a later run cannot re-derive into `success`); only the unverified one is RECONCILABLE. Reconciliation is what bounds the stall that got the #742 attempt withdrawn: a later run pages `/statuses/{sha}` IN FULL and, ONLY IF that history contains the sentinel''s own row, 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, resolving the uncertainty and clearing it so the PR is classified normally. The reconciliation is sound because the two endpoints differ: a verdict this job masked is invisible on the COMBINED endpoint (latest row per context, which is the sentinel) and still present in the per-POST history. It is deliberately BROADER than the inheritance test — no `H10_REVIEWERS` membership, no `(base: …)` match — because over-counting upgrades to a stall a human clears in one command while MISSING one re-exempts a head carrying a buried rejection. ONLY a COMPLETE walk CONTAINING THE CURRENT SENTINEL ROW — matched by the `id` the combined read reported, not merely by a row with the same description — may clear it. Description alone is satisfied by an OLDER identical sentinel, which is exactly what a fixed point produces: with an earlier sentinel and a buried verdict below the current one, a read carrying only the earlier row satisfies the test, the sentinel clears, and the verdict ends up below the fresh mark. Where the server omits `id` there is nothing to match on and the check degrades to the description, which is the pre-existing behaviour rather than a new hole. The witness is not optional decoration: `ex_unverified` means the combined endpoint just returned that row, and `/statuses/{sha}` keeps one row per POST, so a complete history WITHOUT it — an empty one included — contradicts a write that demonstrably happened. `page_statuses` accepts an empty page 1 as complete (correct for a first-run mark), which is what made that shape reachable, and clearing on it exempted a head whose sentinel may have been sitting on a rejection. This is NOT the withdrawn currency witness: that asked whether ANY row sat above the mark, which an unrelated row satisfied, and it made one anomaly permanent — this asks for a SPECIFIC row already known to exist. Failing it costs one run in the transient case; it is NOT bounded in general, because a history past the 20-page cap can never be walked completely, so such a head needs a human verdict and no later run will clear it. When NO high-water mark can be established the write is withheld BEFORE the POST rather than posted and repaired, since the defect is known in advance and publishing a green to take it back opens a window branch protection — and an already-scheduled auto-merge — can see. EVERY re-derivable state is downgraded there, not only `success`: an earlier draft restricted it to the exemption on the grounds that a sticky generic `pending` "withholds nothing, since an unreviewed PR is blocked already", which analysed the wrong PR — the damaging case is one that IS exemptible and got the generic `pending` only from a transient enumeration failure, whose unmarked description the next run re-derives into the exemption with the human row below its own mark. `$REPAIR_DESC` is the one exemption, being the stronger fact and not re-derivable. SEPARATELY, the retarget count is re-taken AFTER the POST on the exemption path, which closes the PERMANENT forged green `ci.verdict-write-retarget-fence` recorded as its residual: a retarget landing after the final pre-write count leaves a stale `success` with the `edited` event already consumed by a successor that short-circuited, so nothing remains to reclassify. Re-counting afterwards means a retarget later than the re-check necessarily queues a successor that STARTS after the stale `success` exists, and a machine-written `success` is re-derived rather than inherited. 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. An untrusted post-POST count repairs too — reaching that point with `success` means both earlier counts were trusted, so a third unreadable read is a fresh failure and "I cannot tell whether the base moved" must not resolve to leaving a green. FINALLY, every path that cannot establish what the head carries REPLACES the unknown state instead of merely declining to write — the combined read (retried once first), all four page-2 completeness refusals, and the fence branch that cannot trust its retarget count while holding a derived `success`. That last one is reached only AFTER the classification declined to inherit the row the head carries, so posting nothing left the declined row current; its message used to say the context "stays absent", which is true only of a head that had none. The two OBSERVED-mutation arms take the same decision for the same reason, scoped to a head that actually carries a declined row: they still refuse to post their CLASSIFICATION, which was computed against a base or head the PR may no longer have, but a row this run declined 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. Every element and every consumed field of the combined response is TYPE-CHECKED before extraction, and a schema failure routes to the same replacement rather than dying: `.statuses` being an array was checked and 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. The path-predicate failure replaces too. And the write helper 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 exited 0 as though the head had been marked. 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 an unreadable read is the one thing that stops it while the job goes red on a status branch protection does not read. Nothing is destroyed: the per-POST history keeps the masked verdict and the next run''s reconciliation upgrades to the repair sentinel, telling the reviewer to re-post rather than silently un-approving them. The page-2 refusals were briefly 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. WRITING THE SENTINEL AND FAILING THE JOB ARE SEPARATE DECISIONS: the read refusals were already non-zero exits on `main` and stay red, while the fence branch exited 0 there and still does, because an unreadable timeline is an ordinary hiccup and reddening every one of them is noise this file elsewhere refuses to add. The repair also has a FLOOR — it may never write a description weaker than the one this run decided, or a transient post-write read rewrites a correct `$REPAIR_DESC` carry-forward with the machine-clearable sentinel — and it is skipped entirely when it would rewrite what is already there.' signals: 'exemption success standing over a human failure, generic pending re-derived into an exemption, unverified write sentinel, reconcilable sentinel vs repair sentinel, no high-water mark could be established, post-write verification runs for every write, retarget after the POST leaves a permanent forged green, unreadable combined status read leaves an off-list green, why did my docs-only PR lose its exemption, why does the gate say the write could not be verified · paths: `.gitea/workflows/review-verdict.yml`, `scripts/tests/test_pr_changed_files.py` · issues: #849, #742, #763, #706, #803' mechanics: '`UNVERIFIED_DESC` ("Status write could not be verified — re-post the verdict") beside `REPAIR_DESC`; `read_existing_verdict()` sets `ex_unverified` from a prefix match, retries the combined read once and on persistent failure calls `repair_status_to "$UNVERIFIED_DESC"` before `exit 1`; the RECONCILE block runs `page_statuses` and counts `Review-verdict:` rows with a non-null creator, clearing `ex_unverified`, upgrading to `ex_repair`, or carrying the sentinel forward on `ph_ok != yes`; the classification chain is `exempt` → `ex_repair` → `ex_unverified` → generic, so the repair sentinel outranks the unverified one; the no-mark downgrade sits AFTER the mark (referencing `$max_id_before` earlier is an unbound variable under `set -u`); the mid-run guard is keyed on `$row_replaced`, which the repair guard does not need because a pre-existing repair sentinel forces its own description while a pre-existing unverified one is deliberately replaced after reconciliation; the post-write gate is `if [ "$max_id_before" -ge 0 ]`, with the no-mark case handled before the POST instead; `replace_unknown_state()` is the shared writer for every path that cannot establish the head''s state and `replace_unknown_and_die()` wraps it for the two read refusals that were already non-zero exits; the reconciliation witness is a count of rows carrying the `id` the combined read reported, falling back to a description match only where the server omits `id`; `pre_id`/`ex_id` extend the mid-run changed-row comparison to row IDENTITY through a shared `row_replaced` flag, since two sentinel POSTs are byte-identical by design — and an id difference counts only when BOTH reads supplied one, because a response that omits `id` beside one that includes it would otherwise report a replacement that did not happen and make the run abstain over a row it had already declined (the combined endpoint carries `id` — measured 2026-08-29 at 1.27.1, head 736649b3, 8 rows, ids 14..30); `--arg own "$desc"` excludes the job''s OWN row from both machine-sentinel selectors, which became necessary the moment the block started running for every write; `.description` is TYPE-TESTED before `startswith`, since `(.description // "")` does not replace a NUMBER and `startswith` then hard-errors, killing the whole count and losing a genuine verdict beside the malformed row; `repair_status_to()` is the shared retry-once writer for all three repair sites; tests `test_a_verdict_racing_a_PENDING_write_is_repaired_to_the_repair_sentinel`, `test_a_raced_PENDING_write_repairs_and_the_repair_SURVIVES_the_next_run`, `test_the_unverified_sentinel_is_a_FIXED_POINT_while_it_cannot_be_reconciled`, `test_the_unverified_sentinel_is_RECONCILED_AWAY_once_the_history_can_be_read`, `test_the_reconciliation_UPGRADES_to_the_repair_sentinel_when_a_verdict_is_BURIED`, `test_a_RETARGET_AFTER_the_POST_replaces_the_exemption`, `test_an_UNREADABLE_retarget_count_AFTER_the_POST_also_replaces_the_exemption`, `test_a_MALFORMED_description_row_does_not_kill_the_post_write_count`, `test_a_transport_failure_on_the_STATUS_READ_REPLACES_the_unknown_state`, each paired with a `test_MUTATION_…` proof that disarms the shipped clause it names through `_run_classify(mutate=…)`, whose count assertion is the binding. Some of those mutants restore text this branch or `origin/main` actually shipped — every clause that HAS a predecessor is mutated back to it — and the rest disarm clauses that have none, because the blocks they gate are new — the section header in that file enumerates which is which, because "restores the exact predecessor" is true of only some of them. SIX CLAUSES ARE DELIBERATELY UNPROVEN and are ENUMERATED rather than counted, because an inventory that undercounts is the same overclaim as one that invents coverage: the path-predicate failure branch (fires only when `grep -c` exits above 1); the empty-`row` refusal (`first // {}` always yields an object for any body that passed the shape gate); page 2''s non-numeric length (`(.statuses // []) | length` is numeric for every body that reaches it, and a non-JSON body is caught one branch earlier by `more_kind`); the `$witness` non-numeric normalisation — `select(((.id | numbers) // -1) == $wid)` cannot error and `$ph_rows` is a validated array, which is the operative reason; the schema-fault route is NOT (it governs the COMBINED endpoint''s row, while the `.id` inside that filter belongs to `/statuses/{sha}` rows, which nothing type-checks); the unusable-`buried`-count arm (same argument, plus its own description type test); and the unusable-`raced_unverified`-count arm, whose filter is type-safe for every row a fixture can pose. 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 carries. They were found by a mutation SWEEP over every clause the diff adds — disarm each, run the whole suite per mutant — which is the technique that finds this class; a per-finding review reads what the diff says it does, and a sweep measures what the tests pin. The sweep''s surviving MUTANTS outnumber the clauses, since several clauses admit more than one disarming edit; the six here are the distinct clauses those survivors collapse onto. Provenance and the run-by-run numbers are in ersatztv#849.' --- `ci.verdict-write-retarget-fence` fenced the write and verified it afterwards. Five routes survived that, all reaching the same outcome — an exemption `success`, or a generic `pending` a later run turns into one, standing over a human `failure`. Two were attempted inside #742 and withdrawn; the reasons for the withdrawal are what shaped this record, so they are stated before the design. ## Why the #742 attempt was withdrawn, and what it forced That attempt withheld the exemption when no mark could be established, and repaired to the sentinel when the post-write history was unreadable. Both were withdrawn on grounds that generalise: - **The withheld exemption wrote 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. - **That `pending` had no retry path.** The workflow triggers only on `pull_request_target` types — no `schedule`, no `workflow_dispatch` — so a transient failure on a PR's *last* event stalls an exempt PR until a human nudges it. Trading a race that needs BOTH a read failure and a reviewer posting inside the write window for a stall that needs only the read failure is not obviously the safe direction. So the fix needs two properties at once: **sticky**, so a later run cannot re-derive it, and **reconcilable**, so a transient failure does not cost a head its exemption permanently. Neither the repair sentinel nor a generic `pending` has both. That is why there is a second sentinel rather than a reuse of the first. ## Why two sentinels and not one flag They record different things and clear by different means, and collapsing them is a fail-open in one direction and a permanent stall in the other: | | records | cleared by | | --- | --- | --- | | `REPAIR_DESC` | a human verdict existed on this sha and a write buried it | a human re-posting | | `UNVERIFIED_DESC` | a write on this sha could not be checked against the status history | reconciliation, or a human | The first is a fact that stays true, so nothing automatic may clear it. The second is an open question, and a run that can read the history closes it — upward to `REPAIR_DESC` when a verdict is found, or away entirely when none is. `read_existing_verdict` therefore tests the two descriptions separately; a single "is it a sentinel" flag would let reconciliation clear the one that must never be cleared by anything but a human. The ordering in the classification chain follows from the table: `ex_repair` is written before `ex_unverified`, because reconciliation can turn the second into the first but never the reverse, and when both are somehow set the stronger fact must be the one written. ## The reconciliation is sound because the two endpoints disagree The combined endpoint (`/commits/{sha}/status`) returns the latest row per context. Once a sentinel is written that row IS the sentinel, so a verdict underneath it is structurally invisible there — which is also why the post-write check reads the other endpoint. `/statuses/{sha}` returns one row per POST, so a verdict this job masked is still in that list for a later run to find. The same asymmetry that makes the post-write check possible is what makes reconciliation possible. It is deliberately **broader** than the inheritance test — no `H10_REVIEWERS` membership and no `(base: …)` binding. The two errors are not symmetric: counting a row that is not really a verdict for this base upgrades to a stall a human clears with one command, while missing one clears the sentinel and lets a later run exempt a head that carries a buried rejection. Over-counting is the affordable error, and this is the same reasoning the post-write raced check already applies to `creator`. ## What the post-POST retarget re-check buys, precisely `ci.verdict-write-retarget-fence` stated its residual as a PERMANENT forged green: a run passes its final pre-write count, the PR is retargeted back to `main`, the successor consumes the `edited` event and short-circuits on the base-matching `failure`, and the stale run then posts last with no event remaining. Re-counting after the POST closes the induction rather than narrowing the window again — a retarget later than the re-check necessarily queues a successor that starts *after* the stale `success` exists, and a machine-written `success` is re-derived, not inherited. **What that buys is TRANSIENT instead of PERMANENT, not "the green never stands".** The `success` is live between its POST and the repair, including the timeline round trips in between, so branch protection or an already-scheduled auto-merge can observe it. Closing *that* window needs a compare-and-set or serialization the API does not offer. What is removed is the case where no event remained to correct it. **The retarget axis only.** A push after the POST moves the head, so the status is no longer on the PR's head and cannot gate its merge; a retarget changes the effective diff while the sha stays, which is why the status remains authoritative for a diff it no longer describes. Fencing pushes here would punish the ordinary case — a contributor pushing straight after the run — by stranding a sentinel on a sha that becomes the head again after a revert. ## Residuals, stated rather than implied 1. **A retarget landing between the post-POST count and the end of the job** is not caught by this run. It IS caught by the successor, for the reason above, so it is a bounded transient rather than the permanent green it was — but the successor is what closes it, not this check. 2. **Both endpoints failing at once** still leaves an off-list `success` standing: the replacement write itself needs the API. It is reported as an `::error::` naming the head and the command to clear it. 3. **A PR whose last event coincides with an unreconcilable failure** stalls until a human posts a verdict. Reconciliation bounds the stall only when another event arrives; the workflow has no `schedule` or `workflow_dispatch` trigger to create one, and adding a trigger that re-runs the gate on a timer was not attempted here. 4. **The reconciliation reads the same paged endpoint the rest of the job does**, so it inherits the 20-page cap: a history past 950 rows cannot be reconciled and the sentinel stands until a human clears it. 5. **A head carrying more CONTEXTS than the combined endpoint's page cap stalls on every run**, since the page-2 refusal fires each time and now writes the sentinel each time. Measured 2026-08-29, this repo puts 8 contexts on a `main` head against a cap of 50. That case already stalled before this change — with an ABSENT required check, which reads as "not reviewed yet" — so what changed is that the stall says why. 6. **A transient combined-read failure on an APPROVED PR costs that PR its verdict.** The reconciliation finds the masked `Review-verdict:` row and UPGRADES to the repair sentinel, which only a human can clear — so the recovery for the case where the replacement actually cost something is "you are asked to re-post", not "it clears itself". The operator messages say so.