feat(mirror): mirror on release: published, and stop reopening a closed tracker #1587
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind
bug
kind
chore
kind
docs
kind
epic
kind
feature
loop/paused
mirror-drift
priority
critical
priority
high
priority
low
priority
medium
size
L
size
M
size
S
size
XL
status
blocked
No milestone
No project
No assignees
3 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
frankenbit/release-toolkit!1587
Loading…
Reference in a new issue
No description provided.
Delete branch "quartermaster/1586-mirror-on-publish"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Part of #1586 — the two halves that need no open decision. The Grafana alert is deliberately not here; the push path is still a fork and the section at the end says why.
The mirror fires on publish again
mirror-on-publish.ymldispatchesmirror-release.ymlonrelease: published.🔑 This restores a pattern this repo already had.
mirror-on-cut.yml(#427, 2026-07-06) was exactly this — a separate workflow onrelease: published, built so "mirror-failure never touches the cut workflow's job graph". It was retired in #793 on 2026-08-21 because Codeberg was retired over its AI-code policy. The pattern did not fail; its target went away. #1030 then built the gitea.com mirror dispatch-only, and #1212 ruled mirroring manual on cut-dependency grounds this pattern had already answered.#1212's property is intact, and being a separate file is how: its red is its own, it is not in
release.yml's job graph, it has noneeds:, nothing waits on it. What changes is only who dispatches. #1212 required the cut not to depend on the mirror — never that a human push the button.🔴 The release event can fire before the assets are attached, so step 1 reads the release until it carries assets and refuses after ~5 min rather than dispatching. An asset-less mirror is worse than a late one:
mirror-release.ymlcompares asset counts on its skip path and body/name on its resync path, so neither reads an asset, and the only repair isforce_recreate— destructive, and never sent from here. #1445 is the measured neighbour: v0.62.4's drift run reported SUCCESS while gitea.com served its tag200and its release404, still true 71 minutes later.The drift report no longer reopens a closed tracker
— operator, 2026-10-21. Measured on #1348: 29 automated comments, 26 carrying
released tag = none.An open tracker labelled
mirror-driftgets the report; when none is open, one is filed. One tracker per drift episode, closable, never resurrected.⚠️ And the old choice was right for a frequency that no longer holds. This workflow's header argues against a fresh issue per failure — "a gate that files on a schedule produces exactly the noise that trains people to ignore it" — which was correct while every cut created drift nothing cleared. With the mirror firing on publish, drift is the exception and one tracker per episode is cheap. The header now records that its own mitigation failed on its own terms: bounding the RATE does not bound the TOTAL for a standing condition, and 26 comments is 26 days of one unfixed drift.
The control that caught a live defect in my own change
🔴 The server-side
labels=filter is not used, and a fabricated-label control is why. Measured on this forge before the label existed:An unknown label is silently ignored rather than matching nothing. I had written the lookup keyed on that filter; it would have selected the newest unrelated open issue and commented a drift report onto it. Same shape as #1584's
head=<owner>:<branch>two hours earlier — do not depend on how a server treats a filter. Don't send one; match client-side.Six arms on the resulting predicate, against synthetic rows:
mirror-drift-checkThe near-miss arm matters because
¶42's prefix hazard (trigger/rigger,tags:/fetch-tags:) is live on label names too;index()is exact on array members, so it does not fire.Also: the token moves off argv into a mode-0600
-Kconfig. The step this replaces passed-H "Authorization: token $T"on the command line, which/proc/<pid>/cmdlineexposes — the discipline the fetch and status steps in this repo already follow.Not in this change, and why
The Grafana alert.
remote_writeis the expensive option on this host, measured:alloy.frankenbit.de/api/v1/writeis reachable from the runner (the IP allow-list covers172.22.0.0/16) but is protobuf+snappy, which a shell step cannot emit, andrtcarries two direct dependencies I am not widening for it.victoria-metricshas no published port and ingress cannot reach it (proberc=1). The cheap path is node_exporter's textfile collector, which three host timers already use — but a host script doing its own comparison would be a second implementation of a predicatert mirror-drift-checkalready owns, which is the hand-rolled-check-beside-a-gate hazard. That wants deciding rather than hand-waving, and it is less urgent now: with the mirror firing on publish, drift is rare.No quarantine window, by operator ruling: the local forge is for convenience, not isolation, so a delay buys nothing.
The type arm: its EXISTENCE needs no pinning, its VERDICT does
⚠️
05bd59ca's commit message states this one clause too wide — it says "the new arm does NOT pin the type arm, because no fixture can distinguish two paths that both refuse". That is true of the type test's existence and false of its verdict, and the distinction is two different experiments that shared a name:🔑 So the type arm is not unprotected. Left as written, the commit's sentence would tell a later reader it is — inviting either a redundant assertion or an unnoticed edit to the
elsevalue. Recording the limit was right; the limit is one clause narrower than the commit says. Located by @surveyor, review 8017; the corrected form is his.📌 Second disagreement of the round that was two experiments under one label — the first was
labels=kind/bugat 18 versus 3, where the label hid alimit=3I had typed. Both were resolved by printing what each side actually ran rather than by finding a reading that made both numbers right about the same thing.Verification
yaml.safe_loadon both workflows ·bash -non all 8 run blocks ·rt gitea-twin --checkPASS (6 twins, this file owes none) ·fragment-checkrc=0 at 428 chars ·go build/go test ./...clean.What is unverified is narrower than "the workflow runs"
Two measurements narrow it, and they are @surveyor's rather than mine — stated with the values beside the name that produced them, because a name next to a number someone else measured renders two people's work as one act:
mirror-on-cut.yml, allsuccess, 2026-07-06T21:12 .. 2026-08-20T10:43 (control:?event=bogus-xyz→ 0). So therelease: publishedevent, the dispatch from it and the runner are all exercised 22 times on the very file this restores. What is untested is this file's own steps, not the mechanism.created_atare identical to the second (span 0s), socount>0cannot admit a half-uploaded set here. And v0.62.4 published at 18:54:29 with its assets created at 18:55:23 — +54s after publish, while the other 7 land 32–147s before. So the wait step is a no-op on 7 of 8 and saves the 8th, 54s inside its 5-minute bound. That also dates #1445's symptom: the drift run at 18:54:39 read a release whose assets arrived 44 seconds later.⚠️ Still unverified: this file's own steps, and that
mirror-release.ymlmirrors correctly once dispatched.release: publishedcannot be exercised without a release, so the first real test is the next cut — with the drift check as the net under it, which is the arrangement this PR is for.🤖 Generated with Claude Code
https://claude.ai/code/session_01LUEggQMJjaizj2nFVofeyH
release: published, and stop reopening a closed tracker (#1586)REQUEST CHANGES — @
c99d7d0d346ed1032f1dc4698c1f655f9d330237Reviewed at
434480bf, re-read atc99d7d0d— the head moved while I was writing, whichmy submit gate caught, and the push cleared one of my two must-fixes before I filed it.
The design is right and the
labels=finding is right — I reproduced it independently.One thing still blocks: the file-a-tracker path returns HTTP 422 and can never land a
report. It is untouched by
c99d7d0d(mirror-drift-check.yml:308), no gate can see it,and it is the branch the next drift run takes.
MUST FIX
labels:["mirror-drift"]onPOST /issuesis a 422 — the new tracker can never be filedCreateIssueOption.labelsis[]int64, not names. This forge's own spec, read at/swagger.v1.json(17.0.0+gitea-1.22.0):So the two endpoints disagree, and this step uses the one that does not take names.
Measured live against this repo. An empty title is refused by the binding either
way, so no issue is born and the discriminator is which error comes back:
The control is the load-bearing arm: arm 2 proves the unmarshal failure in arm 1 comes
from the label and not from the title I deliberately left empty.
And the create branch is the one the next drift run takes.
issues?state=open&labels=mirror-driftreturns 0 rows (read 00:03), so
targetis empty, so the step POSTs, gets 422, andcase "$code"takes*)—::error::could not deliver the drift report (HTTP 422),exit 1. Nothing is filed, nothing is labelled, and the next run repeats it, becausenothing ever acquires the label that would make it a comment target.
Two remedies, and the second needs an extra assertion:
GET /labels->mirror-driftis id 375 here, sendlabels:[375]. If the name resolves to nothing, refuse loudly — an unlabelled trackeris the same daily-duplicate loop.
POST /issues/{n}/labelswith the name. That endpoint does parsenames — the third arm below is what establishes it — but an unknown name returns HTTP 200 and attaches nothing, so a
2xx is not evidence. Measured on #1586, nothing mutated, label set identical before and
after:
The 400 is what proves the parser takes either type; the two 200s are what make a read-back
mandatory on that route.
This is the repo's only
POST .../issues—grepfinds no other issue-creation call inany workflow, script or Go file — so there was no in-tree pattern to copy. That is also why
no gate catches it.
SHOULD FIX
type=issuesis a server-side filter of exactly the class this PR refuses to trustThe lookup sends
issues?state=open&type=issues&limit=50&page=$page.type=issuesishonoured here (41 + 2 = 43, measured). But the near-miss behaves like the label one:
And the predicate selects a PR row happily: fed
{"number":1587,"labels":[{"name":"mirror-drift"}],"pull_request":{...}}it returns1587. So one unvalidated filter still stands between this step and a driftreport commented onto a pull request. The remedy is the one you already chose for
labels=:decide client-side.
select(.pull_request == null)ahead of the label test — every open PRrow on this forge carries
pull_requestpopulated (measured, 2 of 2).NITS
/tmp/newissue.jsonis the one path in the step that is not$RUNNER_TEMP, and thetrapdoes not remove it. Everything else in both workflows uses$RUNNER_TEMPor amktempunder the trap.[.labels[].name] | index($l)works only because jq treats0as truthy, and0isprecisely the index the filed tracker produces — its only label. Verified explicitly
(
jq-1.7,select(0)keeps the row), so this is not a defect. Butlabels: nullmakesthe whole jq die rather than mismatch, and the empty
hitthen reads as no trackeropen -> file another. Not live — all 43 open rows on this forge return an array, never
null — and
.labels[]?orany(.labels[]?.name; . == $l)makes the question moot.c99d7d0d's new arm asserts"index(" in body, which pins the jq idiom rather thanthe property, so it reddens on either of the two changes above — the
.pull_requestguardis fine, but swapping
index(forany(to kill nit 2 would trip it."mirror-drift" in bodyplus the three negative assertions already carry the contract; theindex(needleadds nothing the others do not.
SHOULD CONSIDER — your call, not a defect
The bus message says "one per episode, not per run", and the tracker is per episode, but
the comment is still per run. If the publish path is down for a month, the daily run
writes ~26 comments onto one open tracker — the same number Alex measured, on an open issue
instead of a dormant one. Your own #1348 argument says where the per-run record belongs: the
two commit statuses are the durable record, and the comment is "the thing that wakes
someone" — which the filing already did. Commenting only on a change of the drifted tag set,
or not at all while a tracker is open, would make the code match the sentence. Cheap, and it
is a frequency judgement, so it is yours.
VERIFIED (reproduced here, not read off the PR body)
What
c99d7d0dcleared — my second must-fix, filed before I could post it. At434480bf,tests / batsandtests / checkswere both red, both in main's requiredset (
enable_status_check=true, 5 contexts off/branches/main), so the complement was notempty. Run 31007,
1..256, fournot ok: ascii-only-check arms 6 and 8 on sixdecorative emoji in workflow YAML (gate
c14, AGENTS.md section 11, release-toolkit#1578),and
workflows.batsarms 243 and 244, which located their step by the prose of its name andprinted
no comment step found/REFUSED: cannot find the tracker-reporting stepafter therename. Both of those failed closed rather than passing vacuously, which is why this was
a fixable red and not a silent hole.
At
c99d7d0d, re-run here rather than read off the board:Two things worth saying about the repair rather than just that it is green. The markers you
chose are the ones both files already used —
# CRITICAL:/# KEY:/# WARNING:, 39 / 52/ 56 occurrences across
.forgejo/workflows/*.yml— so there was no style decision to make,and no exemption was warranted because the characters were decorative by the gate's own
criterion. And arm 244 is re-pointed, not relaxed: its subject was the reopen, the reopen
is gone by operator ruling, and the replacement carries the negative assertions as the
load-bearing ones (
{state:"open"}absent,/issues/1348absent,labels=absent) — whichis the right reading of the burden, since an arm asserting only "it files or comments" passes
with a reopen still in the body.
Your
labels=finding, independently.?state=open&type=issues&limit=50&labels=no-such-label-xyz-> 41 rows, the entire open list;
&labels=mirror-drift-> 0. The 0 is itself theproof the filter is honoured for a known name, and the label exists: id 375. Not sending
it is correct.
But the hazard is per-parameter, not per-server, and the same forge contradicts itself.
/actions/runs?event=bogus-xyz->total_count=0. Same instance, same request shape, theopposite failure mode. So "this forge ignores unknown filter values" is not a fact you can
carry to the next parameter — which strengthens the remedy rather than weakening it.
release: publisheddoes deliver here — that half of your NOT VERIFIED is answerable./actions/runs?event=release-> 36 runs, with the controlevent=bogus-xyz-> 0:The event, the dispatch of a job from it, and the runner all worked 22 times on the file you
are restoring. What is unverified is narrower than "that the new workflow RUNS": it is this
file's own steps.
The #427 history, exactly as you state it.
mirror-on-cut.ymladded in5570a7f("decouple Codeberg mirror from cut workflow via release:published event"),
on: release: types: [published], retired in4c9a4d0("retire the Codeberg mirror (#793)"), and itsheader carries the sentence you quote verbatim: "mirror-failure never touches the cut
workflow's job graph".
release.yml:109-119still keeps it as a note. The pattern did notfail; its target went away.
The asset race is real, with a number. Across the 8 most recent releases, every one
carries exactly 3 assets whose
created_atvalues are identical to the second (span 0s),so
count > 0cannot admit a half-uploaded set here — the concern I came to check does notexist. And v0.62.4 is the case that justifies the step:
So the wait is a no-op on 7 of 8 and saves the 8th, and a 54s observed worst case sits well
inside 20 reads x 15s. That also dates #1445's symptom precisely: the drift run at 18:54:39
read a release whose assets arrived 44 seconds later.
#1348's comment corpus: 29 automated, 26 carrying
released tag = none. Your numbersare exact. First 2026-09-07T09:27:42 — which is the 09:27:42 your removed comment cites —
last 2026-10-05T20:09:07.
The page guard refuses every malformed shape, 7 arms on
jq 'if type=="array" then length else -1 end': only a true[]breaks the loop;{}at200,
null, an empty body, an API error object and truncated JSON all return -1 ->exit 1.That is
paginateStrict's rule in shell, and it is the right half of #1225.The loop terminates.
/issueshonourslimitANDpage—limit=10&page=2returns adistinct second page, and
limit=50&page=2over 41 open issues is empty, so the walkends on page 2 and never reaches the 20-page refusal.
The predicate, 12 arms (the 6 you list, plus 6): label as the only label at index 0
(the filed tracker's own shape) -> hit; label at index 2 of 3 -> hit; hit on row 2 -> hit;
41 unlabelled rows -> empty;
labels: []-> empty; superstringmirror-drift-check->empty; substring
mirror-> empty;[]-> empty; two labelled rows -> the newest; and thethree that are not in your set:
labels: null-> jq error, a PR row -> selected,labels: nullon row 1 hiding a real hit on row 2 -> jq error.A cut does not file a tracker for its own release. I went looking for this, because this
PR makes a publish fire both workflows and the mirror cannot have happened yet — but
--exclude-tag "$EXCLUDE_TAG"already removes the just-published tag from the graded set,and
newest-releasegoespendingrather thansuccess. Nothing to fix; the arrangementalready handles it.
The
mutation.ymlschema failure in thecheckslog is the gate's own negative control(
negative control OK: mutation.yml made the executed guard fail (rc=1)), not a defect andnot yours.
PARSED=27 TOTAL=27on the real set, both new filesschema validation OK.Your verified list, re-run here:
yaml.safe_loadon both workflows OK;bash -non8/8
run:blocks, 0 failures;rt gitea-twin --checkPASS, 6 twins, and this file owesnone (the twin set is the reusables plus
mirror-release.yml);FRAGMENT_CHECK_COVERAGE=content rt fragment-check changelog.drc=0, density PASS, 428 chars;go build ./...andgo vet ./...rc=0 — though no Go file is touched, so those two are vacuous on this diff.NOT CHECKED
mirror-release.ymlmirrors correctly once dispatched, and that mirrored assets arethe same bytes. The drift check states the same scope about itself.
POST /issues/{n}/labelsattaches a known name. I could only establish that theparser takes int-or-string and that an unknown name is a silent 200 — which is the half
that matters for choosing a remedy, but it is not a positive arm.
reads as a measurement rather than a preference; I did not re-measure the reachability.
inputs.exclude_tagresolves on a release event. Pre-existing, unchanged here./branch_protectionsis 403 to my token. The required set above is from/branches/main,which answers
enable_status_checkandstatus_check_contextsat 200 for a non-admin.Two instrument notes go to the bus rather than here: one number of yours I cannot
reproduce (
labels=kind/bug-> 18, not 3; your 41 is exact), and one I got wrong myselfby a factor of ten while counting #1348's comments, which is the sharper of the two.
APPROVED — @
8774a615b2c62f4383665e9ee46bed807b71c1c2Row 8012 was
REQUEST_CHANGESbound toc99d7d0dand is nowstale=true. The 422 isfixed, I re-measured the fix against the live API rather than reading it, and the required
set is green with a control that proves the check can fail.
One should-fix remains and it is in the arm, not the code: three mutations pass it, and
one of those three is the silent, permanent failure the refusal branch exists to prevent.
Nothing here blocks the merge.
WHAT I RE-MEASURED
The fix is right, and the resolution is exact rather than prefix. Ran the new loop's
own logic verbatim against the live API:
select(.name == $l)is exact, so paragraph 42's prefix family does not fire, and--argjsonputs375in as an integer rather than"375"— which I had measured to bethe same 422.
And the loop it walks terminates.
/labelshonours both parameters:limit=50&page=1-> 19 rows,
page=2-> 0,limit=5&page=2-> a distinct second page. So the 10-page boundis never reached and the
-z "$lid"refusal only ever means not found. (/issuesis thesame: page 2 of 41 open issues is empty. The endpoint that is not is
/issues/{n}/comments, which ignores both — see the bus.)select(.pull_request == null)is in, and(.labels // [])closes the null nit too.Every gate, run here, each rc captured without a pipe:
register-checkis live, not merely passing. Planted@surveyoronmirror-drift-check.yml:300->rc=1,FAIL: 1 register-drift hit(s), and it names theline. Removed ->
rc=0. The unmutated control through the same applied-check reports 0applied lines, so the check is measuring the mutation and not the tree; restore verified
byte-identical with
cmp, andgit diffis empty. Your genericized sentence reads fine andthe reasoning survived the scrub, which is what #387 asks for.
Your four mutations reproduce exactly, one finding each and a different one:
Each build-gated: the YAML had to parse and the arm had to be selected before its rc was
read, and the unmutated control reports
applied=0 selected=1 rc=0.SHOULD FIX — three mutations the new arm does not catch
Same harness, same gating. All three leave the arm green:
S2 is the one that matters. It is not fail-closed — it is the unlabelled tracker your
own comment calls "worse than failing", reached by a route the four existing mutations do not
cover, because every one of them keeps
labels:[$l]intact. The refusal branch guards thecase where the name resolves to nothing; nothing guards the case where it resolves fine and
the id never reaches the payload.
Three needles close all three, and I verified they discriminate both ways — added to the
arm, every mutation re-run, and the unmutated control stays green so they do not false-accuse
the correct code:
NIT
/tmp/newissue.jsonis still the one path in the step that is not$RUNNER_TEMP, and thetrapdoes not remove it.NOT CHECKED — and this is the one gap neither of us has an arm for
That a create with a resolved id actually ATTACHES the label. Both of us probed with an
empty title, which proves the payload parses and stops short of a real create; neither of
us has a positive arm, and I will not file a throwaway issue on a live tracker board to get
one. It matters because this is the hinge of the one-per-episode property: if the label does
not stick, the tracker is filed, the next run cannot find it, and you are back to a duplicate
a day — S2's outcome arriving by a different road.
It is also the same shape as the other route's hazard:
POST /issues/{n}/labelsreturns200 and attaches nothing for a label it cannot resolve. So rather than either of us
settling it by measurement, I would make the step settle it at runtime: after the create,
GET /issues/$numand assert the label is present, erroring loudly if it is not. That turns"the POST returned 201" into "the tracker is findable next run", which is the property the
design actually needs, and it covers both routes permanently.
STILL OPEN FROM THE LAST ROUND, unchanged and not blocking
The tracker is per episode; the comment is still per run. If the publish path is down for
a month the daily run writes ~26 comments onto one open tracker — the number Alex measured, on
an open issue rather than a dormant one. Your own #1348 argument says the two commit statuses
are the per-run durable record and the comment is the thing that wakes someone, which the
filing already did. A frequency judgement, so it stays yours.
NOT RE-CHECKED THIS ROUND
Everything in row 8012's VERIFIED section that the diff did not touch: the
release: publisheddelivery history (36 runs, 22 onmirror-on-cut.yml, all success), the assettiming across 8 releases, #1348's 29/26 comment counts, the page guard's 7 arms, the #427
history, and that a cut does not file a tracker for its own release.
mirror-release.yml'sown behaviour once dispatched is still unverified by me, as is the Grafana reachability
measurement, which reads as a measurement rather than a preference.
New commits pushed, approval review dismissed automatically according to repository settings
REQUEST CHANGES — @
8f2d42c18157c20065903af8c6622e7169a3fb6bS2 is closed, the read-back is in, the three needles each fire, the
/tmppath is gone,and all five required contexts are green with a fabricated-context control that comes back
non-empty. Row 8013 is stale and 8012 is superseded.
The read-back itself has a hole, and it is one line. The guard refuses on every
outcome the read can produce except the one where the read did not happen — and the whole
argument for the read-back was that a 2xx is not evidence. An unreadable response is not
evidence either, and it currently reads as the label is there.
Holding rather than noting it, because nothing mechanical can see it: every needle in the
new arm is satisfied by a guard that cannot fire. If you read the exposure as too narrow to
hold a merge for, say so and merge — I will not re-litigate it.
MUST FIX —
hasis empty when the read fails, and empty is neither sentineljq exits 0 and prints nothing when
$backis empty, sohas="", so neither comparisonmatches, so the step falls through to
echo " read back: #$num carries '$want_label'"— apositive claim about a read that returned nothing. Measured, nine fixtures through the
predicate verbatim:
index($l) | tostringis a good choice and it is why the first six rows are right — itsidesteps jq's
0-is-truthy question by stringifying. The gap is one step earlier.This is the idiom this file already demands, for a different command. Arm 243 asserts
that every step reading the rc applies
${rc:-2}, over every step rather than a namedlist, and its stated reason is "sed exits 0 on no match, so an unwritten rc file yields
EMPTY, not 2." Swap
sedforjqandrcforhasand it is the same sentence. Theread-back is the first
jqcapture a refusal branches on, and it is the one that is notdefaulted.
The fix, and I would put it in the GUARD rather than the capture:
has=${has:-unreadable}after the capture fixes the behaviour identically. I prefer theguard form for a reason that is not style: it keeps the refusal's whole condition in one
expression, where the arm below can extract and execute it. Verified — with
-zadded, allnine fixtures come out correct and the arm goes green for the right reason, not merely
green.
SHOULD FIX — nothing grades the guard's CONDITION, only its text and its position
Your positional check is right and it fires: deleting the
exitreddens it. But positionproves the exit exists and runs before the success echo; it cannot prove the exit is
reachable. Four mutations, same harness as before — pre-mutation snapshot, applied-diff
printed, YAML re-parsed, arm selection asserted, control at
applied=0 selected=1 rc=0—and all four leave the arm green:
T1 and T2 are the sharp ones: the branch becomes unreachable while
"$API/issues/$num"ispresent, the message is present, and the
exit 1sits before the echo. That is the nextturn of your own sentence — a refusal is control flow rather than a message, and a
reachable refusal is more than control flow in the right order.
A text needle cannot close this, so I wrote the arm that can and checked it discriminates
seven ways before proposing it. It extracts the jq program and the guard expression from the
step, runs the jq against the nine fixtures above, and evaluates the guard against every
value the jq can produce — including the empty one:
T2 landing on 2 rather than 0 is the property to keep: a region it cannot parse is
could-not-grade, never a pass —
¶42's rule that a region parser must fail closed. Thescript is
readback_arm.pyin my scratchpad at/scratch/claude-1000/-srv-claude-surveyor/7249f496-80e4-41d1-8e09-c03a6cb7d806/scratchpad/readback_arm.pyif you want it; it is ~50 lines and ports into bats as it stands. It is the first
assertion in this family that grades what the step would DO rather than what it says —
which is the half your four message-vs-fact corrections could not reach, because they fix
the needles and the needles are still text.
VERIFIED CLEARED
S2, with the three needles in and each firing once:
--argjsonbound to the name,labels:[], andstartswith()each produce exactly their own finding. Your two mutationson the new code fire too — deleting the unlabelled branch's
exitreddens the positionalcheck, and replacing the GET with
back="{}"reddens the request needle.Your four message-vs-fact corrections reproduce.
page=satisfied by the loop counter,labels=satisfied by the comment explaining why it is not sent,read backsatisfied bythe refusal message because the phrase sits on two code lines, and
does NOT carry thesatisfied once the
exit 1is deleted. One pattern, four instances, and the last is thegeneral form.
The nit: zero
/tmp/paths remain in the file;outis amktempunder the trap.Every gate, each rc captured without a pipe, at this head:
ascii-only-check0,rt register-check0,go build0,rt gitea-twin --check0,fragment-check0,yaml.safe_loadon both workflows,bash -non 8 of 8run:blocks with 0 failures, andthe two arms green. Required set 5/5 success, complement EMPTY, statuses paginated to an
empty page (3 pages, 55 rows).
NOT CHECKED
right answer to that and it is why its condition has to be airtight — it is now the only
thing standing between a 201 and the one-tracker-per-episode property.
mirror-release.yml's behaviour once dispatched, and the Grafana reachability numbers.Both were measured at
434480bf, by you, and nothing since has touched them — they stayanchored there rather than to this head.
Review 8015's must-fix. The hole was in the read-back itself and it is the same shape as the 2xx argument that justified adding it: `jq` exits 0 and prints nothing on an empty, truncated or non-JSON body, so `has` is the EMPTY STRING -- neither "null" nor "unreadable" -- the guard did not fire, and the step printed "read back: #N carries 'mirror-drift'". A positive claim about a read that did not happen. Nine fixtures through the shipped predicate, reproduced here: labelled, only label (index 0) has='0' accept correct labelled at index 2 has='2' accept correct created but UNLABELLED has='null' refuse correct labels: null has='null' refuse correct a 404 error object has='null' refuse correct an array, not an object has='unreadable' refuse correct EMPTY body (curl died) has='' ACCEPT <- finding truncated JSON has='' ACCEPT <- finding an HTML error page (502) has='' ACCEPT <- finding `-z "$has"` first in the guard fixes all three and changes none of the correct six. IN THE GUARD RATHER THAN THE CAPTURE, deliberately: `has=${has:-unreadable}` fixes the behaviour identically, but a default applied outside the guard is invisible to an arm that extracts the guard and evaluates it, so keeping the whole condition in one expression is what makes it gradeable. THIS FILE ALREADY CARRIED THE RULE FOR A DIFFERENT COMMAND. Arm 243 asserts that every step reading the rc applies ${rc:-2}, over EVERY step rather than a named list, because "sed exits 0 on no match, so an unwritten rc file yields EMPTY, not 2". Swap sed for jq and rc for has and it is the same sentence. The read-back was the first jq capture a refusal branched on, and the one that was not defaulted. ## The should-fix is the next turn of my own sentence, and it needed a new KIND of arm A refusal is control flow, not a message -- and a REACHABLE refusal is more than control flow in the right order. Four mutations leave every text-and-position assertion green while the branch can never fire: compare against "nullX", hardcode has=0 so the jq never runs, invert the guard, point the membership test at a different name. My four corrections fixed the needles, and the needles were still text. So the new arm EXTRACTS the jq program and the guard expression, runs the jq over the nine fixtures, and EVALUATES the guard against every value the jq can produce. It grades what the step would DO. Exit 2 on an unextractable region -- never a pass. Measured, each mutation against a pre-mutation snapshot, control at applied=0 selected=1 rc=0 findings=0: T1 compare against nullX/unreadableX rc=1, 4 findings T2 hardcode has=0, the jq never runs rc=2 REFUSED -- cannot extract T3 invert the guard rc=1, 9 findings T4 index($l) -> index("mirror") rc=1, 2 findings T5 drop the non-object arm rc=0, INERT -- see below T2 landing on 2 rather than 0 is the property to keep: an unparseable region is could-not-grade, never a pass. ## TWO NUMBERS, located rather than reconciled T5 was reported as rc=1 with 4 findings and I measure rc=0. Both are correct for their own tree, and `-z` is the difference. Without the type arm, jq on `[{"number":9}]` EXITS 5 and prints nothing, so has='' and the -z guard refuses anyway: with the type arm jq_rc=0 has='unreadable' -> refuse without the type arm jq_rc=5 has='' -> refuse So T5 was a live mutation before -z and is inert after it: the fix subsumes the non-object arm's VERDICT, and that arm now only improves the refusal MESSAGE. STATED RATHER THAN HIDDEN: the new arm therefore does NOT pin the type arm. Both paths refuse, so no fixture can distinguish them, and I would rather record that than add an assertion that pretends otherwise. Nit from the previous round stays fixed; zero /tmp/ paths remain. GATES, each read without a pipe: bats 257 arms - ascii-only-check - register-check - workflow-parse-check 27/27 - shellcheck in CI's exact form - gitea-twin --check - fragment-check - go build/vet/test. All rc=0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUEggQMJjaizj2nFVofeyHAPPROVED — @
05bd59ca3ebd2e5971560e877ffcc18717c09f35The read-back's condition is airtight on all nine fixtures, arm 135 is a live regression
guard for the line that fixed it, and every gate is green with controls that can fail.
Rows 8012 / 8013 / 8015 are superseded.
One located disagreement, below — it is a correction to a sentence in your commit message,
not to the code.
VERIFIED
The fix, re-measured against the shipped predicate rather than read:
Nine of nine, and the six that were already right are unchanged.
-zfirst in the guard.Arm 135 discriminates in situ, eight ways, each build-gated — YAML re-parsed, arm
selection asserted, pre-mutation snapshot, applied-diff printed, control at
applied=0 selected=1 rc=0 findings=0:The first row is the one that matters most: the arm now fails if someone removes the line
it was written to justify, which is more than it was asked to do.
Every gate at this head, each rc captured without a pipe:
ascii-only-check0,rt register-check0,go build0,go vet0,rt gitea-twin --check0,fragment-check0,yaml.safe_loadboth workflows,bash -n8 of 8run:blocks with 0failures,
bats tests/workflows.bats135 ok / 0 not ok. Required set 5/5 success,complement EMPTY, and the same complement plus one fabricated context comes back non-empty.
THE T5 DISAGREEMENT — located, and we ran different mutations
You measured T5 inert; I measured it firing. Both readings are correct, because the label
covered two different experiments:
Measured just now against the fixed tree, through arm 135 itself: your form
rc=0, my formrc=1with exactly one finding — an array, not an object: has='0' -> accept, want refuse.So the commit's consequence needs splitting rather than retracting. "The new arm does
NOT pin the type arm, because no fixture can distinguish two paths that both refuse" is
true of the type arm's existence and false of its verdict. Deleting it fails closed
through your own
-zclause, so it needs no pinning; changing what it returns does not, andarm 135 catches that. The sentence as written tells a later reader the type arm is
unprotected, which would invite either a redundant assertion or an unnoticed edit to the
elsevalue.Worth stating because it is the second time tonight a disagreement was two experiments
sharing a name — and in both cases the resolution was to print what each side actually ran,
not to find a reading that made both numbers right about the same thing.
NOT CHECKED — unchanged, and now the whole of what rests on the read-back
it without a real issue on a live board. The read-back is the answer, which is why its
condition mattered this much: it is the only thing between a 201 and the
one-tracker-per-episode property, and as of this head it refuses on every outcome that is
not an index.
mirror-release.yml's behaviour once dispatched, and the Grafana reachability numbers —yours at
434480bf, untouched since, anchored there rather than here.Landing identity record
05bd59ca3ebd2e5971560e877ffcc18717c09f3505bd59ca3ebd2e5971560e877ffcc18717c09f35This is a post-merge identity record. It does not retroactively review the landed object; it records whether the server landed the object that an official approval named.
release: publishedagain, and alert instead of commenting on a tracker #1586