CI runs the checks a card declares, and a second job records what they proved - #36
Merged
Conversation
…y proved
T-0189, the `ci` tier of ADR-0016 and the only one with a witness. `local` is a
command that ran on the author's machine and is still self-reported.
The three questions the card said to settle first are settled in its notes. The
one that shaped everything: the run and the write are two jobs, and they cannot
be one. A criterion bound to a command can only be checked by running it, so one
job executes commands a pull request declared and therefore holds
`permissions: {}` with no credentials left in .git/config. Writing evidence needs
`contents: write`, so the other holds it and runs no repository code at all — not
even Workfile, because every Workfile command import()s project.config.mjs from
the checkout. It applies a patch bounded to the protocol directory and pushes. A
fork records nothing: GitHub issues a read-only token there whatever the workflow
says, and the job declines to start in order to say so.
CI closes a card only when every one of its criteria is bound to a command. A
narrative criterion is not something a runner has an opinion about, so a card
carrying one gets its bound boxes written and stays open with the reason
reported. That conservatism is the whole safety of the write-back.
T-0161 rides along as the dogfood, and its premise was wrong in a way worth
recording. It says the fix is one branch in `validateCardCandidate` because
`candidate.id` is set by then, and it is not: creation validates against
`id: "pending"` and the allocation decides the id later, under a lock. A self
`parent` on create is refused by `CARD_PARENT_NOT_FOUND` — the right outcome for
the wrong reason — and `origin` has no existence rule to borrow, which is exactly
why nothing caught it. So the guard sits at the allocation, where the id exists,
and a `ValidationError` there leaves the retry loop rather than being read as a
collision and retried onto the next id.
Its four machine-decidable criteria are left unchecked on purpose. This branch
touches the card, so the run should check them and push the commit that records
it — which is the evidence criterion 1 of T-0189 asks for and the only thing a
local run cannot produce.
T-0188 left a tripwire for this card, asserting that no generated target contains
`card verify` or an Actions expression. What it protected stands and is now stated
as the two rules it stood for; the details are on T-0189. My first version of the
replacement was broken in the dangerous direction and a mutation caught it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3LTdq3mzMAQ98rwBegGjU
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The audit gate went red on this branch for a reason unrelated to it, which is the cost that was named when the floor moved to `moderate`: GHSA-2v37-7h3g-55p8 against `nanoid <3.3.17`, reached through `shadcn > postcss` and `vite > postcss` — both devDependency paths, so the consumer tree was never affected and `audit:consumer` stayed clean. One override, and the workspace audits clean at `low` again. And a correction the run itself produced. The record job's comment said the push converges because the second run finds nothing to write. That is true and it is not the operative reason: a push made with GITHUB_TOKEN does not start a workflow run at all, which showed up on #36 as two runs created in `action_required` that never executed. Both reasons are stated now, the load-bearing one first, because somebody debugging a loop would otherwise be looking at the wrong one. T-0161 closes with `method: ci` against the run that proved it. T-0189 goes to review: three of its four criteria are proven, and the fourth needs a pull request from a fork to demonstrate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D3LTdq3mzMAQ98rwBegGjU
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
T-0189, the
citier of ADR-0016 — the only tier with a witness.localis a command that ran on the author's machine and is still self-reported.This PR is also its own test. T-0161 rides along with four criteria bound to a command and deliberately left unchecked. If this works, the
cardsjob runs that command, therecordjob pushes a commit checking those four boxes, and that commit is the evidence criterion 1 asks for — the one thing a local run cannot produce.The run and the write cannot be one job
A criterion bound to a command can only be checked by running it. So one job executes commands a pull request declared, and it holds
permissions: {}withpersist-credentials: false— nothing to take. Writing evidence needscontents: write, so the other job holds that and runs no repository code at all, not even Workfile: every Workfile commandimport()sproject.config.mjsfrom the checkout, which is the thing ADR-0019 exists to say out loud.The second job applies a patch bounded to the protocol directory and pushes. The bound is checked before the patch is applied rather than trusted because of where it came from.
A fork records nothing. GitHub issues a read-only token for
pull_requestfrom a fork, so the push cannot land whatever this file says; the job also declines to start there, in order to say so rather than fail at the last step. That is criterion 3, and it is enforced by the platform rather than by our condition.CI closes a card only when every criterion is bound
A narrative criterion — "the recut demo video reads correctly" — is not something a runner has an opinion about. So a card carrying one gets its bound boxes written and stays open with the reason reported, and a card with none of its criteria bound is never touched because it declares no commands. That conservatism is the whole safety of the write-back:
card ac --checkrefuses a bound criterion and only the runner writes it, so the boxes CI touches are boxes no person was going to check either way.Proven locally before any of this reached CI, in a scratch repo with three cards: a fully bound card closed with
method: ci, its commit and the run URL; a card with one narrative criterion got its bound box and was held open naming the count; a failing command left its card inbacklogwith the box unchecked and exited 1 without abandoning the cards after it in the list. An unknown base says "No card was verified" rather than reporting an empty diff, and--baseis required because guessing it wrong means running the declared commands of cards the branch never opened.Two real bugs came out of that: an already-closed card aborted the whole run, and passing
commit: nullfrom an unset flag suppressed the door's own HEAD resolution — dropping the exact field criterion 2 asks for.T-0161's premise was wrong
It says the fix is one branch in
validateCardCandidatebecausecandidate.idis set by then. It is not: creation validates againstid: "pending"and the allocation decides the id later, under a lock. A selfparenton create is refused byCARD_PARENT_NOT_FOUND— the right outcome for the wrong reason — andoriginhas no existence rule to borrow, because an origin may name a record not written yet. Which is exactly why nothing caught it.So the guard sits at the allocation, where the id exists. Neither sibling guard had a test either, so all three are pinned now.
The tripwire T-0188 left
ci-targets.test.tsasserted that no generated target contains the stringcard verifyor an Actions expression, commented as "the pin that keeps the next card honest". This is that card.What it protected is real: a
verify[].runis an argument vector so that no shell parses it, and an expression inside arun:block is expanded before the shell sees it. Neither is what invoking the runner does — the card's command never appears in the workflow. So the two blanket assertions became the two rules they stood for: no target reads a card'sverifyblock into the template, and no Actions expression reaches a shell line, checked acrossrun:andscript:in all three formats.My first version of that second check was broken in the dangerous direction — a mutation putting an expression on a continuation line passed it. Found by mutating, not by reading. It is a line-based scan now with a per-format floor.
What this repository is opting into
cards.verification.commands: [["node", "--test"]]inproject.config.mjs. Empty means nothing may run, so this is the first thing declaring what a card here may say.permissions:moved fromcontents: readto{}, withcontents: readstated ondoctor. Same effect, and it is what lets the two new jobs differ as sharply as they do.pnpm run checkgreen — 477 + 10 tests, strict ratchet with no regressions.doctor0 errors 0 warnings.Criterion 3 needs a fork pull request to demonstrate, which needs a second account; it is argued from the platform's behaviour and pinned by a test, not run.
🤖 Generated with Claude Code
https://claude.ai/code/session_01D3LTdq3mzMAQ98rwBegGjU