Skip to content

CI runs the checks a card declares, and a second job records what they proved - #36

Merged
illodev merged 3 commits into
mainfrom
feat/t-0189-ci-verifies-cards
Aug 7, 2026
Merged

CI runs the checks a card declares, and a second job records what they proved#36
illodev merged 3 commits into
mainfrom
feat/t-0189-ci-verifies-cards

Conversation

@illodev

@illodev illodev commented Aug 7, 2026

Copy link
Copy Markdown
Owner

T-0189, the ci tier of ADR-0016 — the only tier with a witness. local is 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 cards job runs that command, the record job 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: {} with persist-credentials: false — nothing to take. Writing evidence needs contents: write, so the other job holds that and runs no repository code at all, not even Workfile: every Workfile command import()s project.config.mjs from 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_request from 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 --check refuses 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 in backlog with 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 --base is 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: null from 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 validateCardCandidate because candidate.id is set by then. 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, 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.ts asserted that no generated target contains the string card verify or an Actions expression, commented as "the pin that keeps the next card honest". This is that card.

What it protected is real: a verify[].run is an argument vector so that no shell parses it, and an expression inside a run: 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's verify block into the template, and no Actions expression reaches a shell line, checked across run: and script: 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"]] in project.config.mjs. Empty means nothing may run, so this is the first thing declaring what a card here may say.
  • A bot commit on pull requests that check a bound box. It converges: the push re-triggers the workflow, the second run finds the boxes checked, produces an empty patch and commits nothing.
  • The top-level permissions: moved from contents: read to {}, with contents: read stated on doctor. Same effect, and it is what lets the two new jobs differ as sharply as they do.

pnpm run check green — 477 + 10 tests, strict ratchet with no regressions. doctor 0 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

…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
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
workfile Ready Ready Preview Aug 7, 2026 9:44pm
workfile-site Ready Ready Preview Aug 7, 2026 9:44pm

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
@illodev
illodev merged commit a193aaf into main Aug 7, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant