Skip to content

chore: Preview workers verify logins with the same signing secret as production, so no agent can be handed a safe copy - #9635

Open
usirin wants to merge 2 commits into
mainfrom
build/9533-preview-auth-key-49f66d24
Open

usirin wants to merge 2 commits into
mainfrom
build/9533-preview-auth-key-49f66d24

Conversation

@usirin

@usirin usirin commented Sep 21, 2026

Copy link
Copy Markdown
Member

Fixes #9533.

Preview workers stop verifying logins with the production signing secret. Every pr-<n> preview now deploys with the key committed at infra/preview-auth-key/key.txt, which is public on purpose, so any agent on any machine can sign a preview session with no setup and no credential. Production, audit and every hand-named stage keep the founder-held BETTER_AUTH_SECRET. This is the founder's route-1 ruling on #9533, transcribed.

The committed key cannot reach a non-preview stage, and that rests on structure rather than on a note saying not to:

  • .github/workflows/deploy.yml picks the value in its own step and echoes which source the stage took. The predicate is isPreviewStage (/^pr-\d+$/) in apps/web/worker/environment.ts — deliberately not environmentForStage(...) === "preview", which is fail-open on the stage axis and would hand a hand-named stage the public key. The founder-held secret is the default; the committed key is the exception.
  • The worker refuses to boot. judgeAuthSecret in apps/web/worker/preview-auth-key.ts rejects any preview_-prefixed secret on any ENVIRONMENT that is not preview, and BetterAuthLive throws on it. A worker that would verify forgeable sessions serves nothing. The check keys on the prefix, not the literal bytes, so a rotation cannot outrun it; a unit test reads the real committed file and asserts it carries that prefix.

review-ui render resolves the committed key off the checkout it stands in — no --auth-secret-from, no environment variable, no ci-credentials read. --auth-secret-from still overrides, and the ambient $BETTER_AUTH_SECRET remains only as a fallback for a checkout with no committed key. No refusal points a seat at $ALCHEMY_PASSWORD any more.

The blast-radius check is written down in .decisions/0406-preview-workers-sign-with-a-committed-public-key.md, which cites the ruling comment in its own text. Summary: a forged preview session reaches one preview origin's worker and its own throwaway per-PR D1 (ADR 0349), anyone who reads the repo can forge one, and production rejects the key twice over. The cross-stage question the criteria asked — does anything expect a session signed on one stage to verify on another — is answered no, with infra/depo checked by name: it is absent from .github/app-roster.json and from every workflow, both its stacks are hand-deployed, its sharing is production-to-production, and its doorman authenticates a pasaport apiKey rather than a session cookie.

Live evidence for the last criterion

A review-ui render of an :auth surface against this PR's own preview (pr-9635, head 4ffdaf9), run with $BETTER_AUTH_SECRET explicitly unset and no --auth-secret-from, so the committed key resolved from the checkout is the only source the cookie could have been signed with. preview-seed test-account seeded the two tiers on this preview's own D1 first.

$ env -u BETTER_AUTH_SECRET node packages/fabrika-cli/src/bin.ts \
    review-ui render --pr 9635 --out auth-proof --surface /pano:auth
review-ui render: scanned 1 comment.
review-ui render: surface "/pano:auth" at desktop captured: 1280x800, 0 page error(s)
{"set":"auth-proof","pr":9635,"head":"4ffdaf91fdbc59f032448b0762eb256780012c7b",
 "previewUrl":"https://phoenix-phoenix-pr-9635-7m5w5ttgj2i4ddfl.kampusinfra.workers.dev",
 "captures":[{"surface":"/pano:auth","viewport":"desktop","width":1280,"height":800,
 "sha256":"4c05725d94de6eff9687e5788155d7f63cc0106617a1706bcdcdeba94f9f3e18",
 "pageErrors":{"rows":[],"more":0}}]}
exit 0

Exit 0, not 11. Before recording that shot the verb asked the preview's own /api/auth/get-session from the same browser context and required a user back at the yazar tier, so the signature verified against what the worker actually deployed with. The capture path is omitted here because it is machine-local.

Deviations

  • Pre-existing test or fixture changedSaid: the criteria name render-verb.ts's visitor-answer message and nothing about its tests. Did: changed the refuses a placeholder-prefixed ambient secret case to assert the new route text (the committed key path, and no ALCHEMY_PASSWORD) instead of the old --auth-secret-from pointer. Why: the criterion required that message to change, and the assertion pinned the exact sentence it required changing. Disposition: stated here; the case still proves the same refusal, only against the message the ruling asks for.
  • Pre-existing test or fixture changedSaid: nothing about capture/auth.unit.test.ts's helpers. Did: gave its verify helper an optional secret parameter, defaulting to the existing fixture. Why: the new case verifies a signature made with the real committed key, and the helper was pinned to one module-level constant. Disposition: stated here; every existing call site is unchanged and passes the default.
  • Out-of-scope changeSaid: the criteria name six source files, a decision record and the deploy workflow. Did: also added a .gitleaks.toml allowlist entry for the committed key, and corrected the two lines in .patterns/alchemy-ci-cd.md that said the one Actions secret is handed to every stage. Why: the allowlist entry keeps the credential scanner from reding a value the founder ruled must be committed, and the pattern lines became false with this change. Disposition: stated here.
  • Known defect left unfixedSaid: nothing about teardown. Did: left .github/workflows/pr-cleanup.yml passing secrets.BETTER_AUTH_SECRET into the alchemy destroy of a preview stage. Why: it is a teardown, not a serving worker — the value only has to resolve for a config read — and it is a pull_request_target workflow whose failure leaks a worker and a D1, so it is not something to change alongside this. Disposition: recorded in ADR 0406's own text and filed as Preview teardown still hands the founder-held BETTER_AUTH_SECRET to a pr-<n> stage #9636.
  • Out-of-scope changeSaid: the criteria name no behavior in resolveAuthSecret's root-discovery branch. Did: made a failed discoverRepoRoot refuse on 11 naming the unreadable ancestor, instead of falling through to the ambient variable, and added a unit case for it. Why: discoverRepoRoot keeps "could not look" on its E channel and "no repo here" on undefined, and the fall-through reported the first as the second; the review-code verdict raised it as an advisory on this round. Disposition: stated here; the exit code is unchanged and only the message a seat reads improves.

@github-actions

github-actions Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

🚀 Preview deployed

@usirin

usirin commented Sep 21, 2026

Copy link
Copy Markdown
Member Author

governance: FAIL @ 4ffdaf9 content:3bda1fedf4fa — ADR id 0405 collides with a landed record

This retracts the PASS above at the same head. That verdict asserted "0405 itself is status: accepted, mints an unclaimed id" — I had not checked the id, and it is not unclaimed. Everything else in the retracted body stands and is restated below; the corpus half now carries a finding, so the polarity flips.

The finding — 0405 is already taken, and the whole diff points at it

.decisions/0405-fail-floor-subtracts-routed-namespaces.md is on origin/main. This PR adds .decisions/0405-preview-workers-sign-with-a-committed-public-key.md with frontmatter id: 0405. Two records, one number.

The repo's own lock says it in the gate's words:

duplicate ADR id 0405 in: 0405-fail-floor-subtracts-routed-namespaces.md, 0405-preview-workers-sign-with-a-committed-public-key.md
 — the ADR number lock requires the filename prefix and the frontmatter `id` to name one record,
 uniquely. Fix: renumber the newer record and rename its file to match.

validate ADR files — no duplicate/mismatched ADR number is red at 4ffdaf9, exit 12. It is outside the branch ruleset's four required contexts, so it does not block the queue — which is exactly why it needs naming here rather than being left to the merge gate. fabrika adr next computes 0406 over the union of the corpus, in-flight pull requests and branch refs.

Why this is the corpus half and not a lint. A decision record's id is how every other record, docblock and comment addresses it. This diff writes "ADR 0405" into eight places outside the record: .github/workflows/deploy.yml (two comment blocks), apps/web/worker/environment.ts, apps/web/worker/preview-auth-key.ts, apps/web/worker/preview-auth-key.unit.test.ts, apps/web/worker/features/pasaport/better-auth-live.ts, infra/ci-credentials/github.ts, .gitleaks.toml, infra/preview-auth-key/README.md, and .patterns/alchemy-ci-cd.md (a resolvable link to the filename). Landed as is, every one of them addresses the fail-floor record instead — and the .patterns link resolves to a file that will not exist under that name. Two lanes minting one number is the shape the id lock exists to catch; the remedy is the gate's own: renumber to 0406, rename the file, and carry the new number through all nine call sites and the link.

Scope

governance scope derived required over 5 declared roots: claude-plugins/ (2), .decisions/ (1), .github/ (1). self false, so §4's merge-base fence did not apply and this verdict judges by the head's rules. One record in the diff: 0405 (added).

This diff derives the governance namespace. Whether it also needs a code-owner approval is a separate question CODEOWNERS answers.

The corpus half — does this contradict standing law

Questions the change decides, written before the sweep:

  1. May a session-signing key live in the repository in the clear?
  2. Which value does a given deploy stage bind, and what decides it?
  3. What refuses the public key where it must not be accepted?
  4. May a true-positive credential literal enter the secret scanner's allowlist?

governance sweep --record 0405 returned shortlist over 330 uncited live-accepted records. Nothing on it governs preview/production secret separation — the top rows (0250 hook fail-open, 0322 lane demotion, 0313 queue dwell, 0210 intake) are lexically adjacent on words like "fails open" and "gate" and answer none of the four questions. Read and dispositioned, not cleared by the outcome.

The genuinely adjacent records were read by hand, since the sweep is citation-independent and would not surface them from 0405's own reference list:

  • 0349 — cited by the new record and consistent with it. 0349 fences preview seeding on the per-PR D1 name; the new record leans on exactly that fence for its blast radius and adds a second axis (which key) one layer up. No tension.
  • 0338 — the closest call, and the reason question 4 was written down. 0338 carries the binding constraint "No finding is allowlisted to make the new scope work." That sentence is scoped to 0338's own change: it refuses allowlisting as a way to make merge-result scanning viable. It is not a standing ban on the allowlist, which 0338 leaves in place and which already carried three triaged entries before this diff. 0338's own guard — the scan basis, the exit-0-only rule, the zero-scope skip — is untouched here. No contradiction.
  • 0092 — the fail-closed posture. The new deploy selector inverts correctly under it: the founder-held secret is the default arm and the committed key is the exception, gated on isPreviewStage (/^pr-\d+$/). An unrecognized stage lands on the secret, not the public key.
  • 0394 — the two new @ruling tags under packages/fabrika-cli/ name the issue comment, never an ADR number or a .decisions/ path. guard portability-guard check is clean at head (1473 files, 337 references, none above a ceiling), so this holds mechanically too.

So the substance of the record contradicts nothing standing. Its address is the defect.

The gate half — does this quietly weaken a guard

governance guards --sha 4ffdaf9: 19 files scanned, 3 anchored invariants in reach, no-anchor-change — all three sit in claude-plugins/fabrika/skills/review-ui/SKILL.md and none was removed or modified. .github/workflows/deploy.yml carries 0 anchored invariants. The reach is stated and the anchored half is empty.

Three unanchored candidates, read by hand, since an unanchored invariant is invisible to that scan:

1. .gitleaks.toml gains an allowlist row for a real credential literal. This is the one place the diff touches a security control directly, so it gets the full disposition rather than a note.

  • What it is: one exact-literal regex for the committed key value. regexTarget = "match" and a full-value pattern, so it is a narrow value match — not a path mute, not a file mute, not a rule mute. A different secret added to the same file, or to infra/preview-auth-key/, is still caught. The file's own stated invariant ("a narrow value match, never a whole-file or whole-rule mute, so a real secret accidentally added to the same test file is still caught") is honored, not softened.
  • Is it authorized? Yes. The founder ruled route 1 on #9533 with the cost stated in his own words, and the new record writes that ruling and the blast radius down. This is not an invariant narrowing with no authorizing decision behind it.
  • Residual, named rather than failed on: the file's header describes the allowlist as "triaged false positives" and this fourth entry is a triaged true positive — a real key, deliberately public. The row's own comment says so plainly, but the header now over-claims. Routed to review as a code-class note: the control's behavior is unchanged and its narrowness invariant is intact.

2. deploy.yml's BETTER_AUTH_SECRET binding gains a conditional. Read specifically for the "still reads as a guard, no longer guards" shape. It does not have it. The old expression bound one value unconditionally; the new one keys on a step-resolved PREVIEW_AUTH_KEY that is non-empty only for /^pr-\d+$/. The default arm is the founder-held secret, the predicate is narrower than the fail-open environmentForStage map it deliberately does not use, and the resolving step echoes which source it took into the deploy log. For production this is a strengthening: a value that previously could only be the secret still can only be the secret, and a second independent runtime refusal now sits behind it.

3. review-ui render's secret resolution gains a source. The guard that could have been softened here is classifyAuthSecret's refusal of empty and insecure_-prefixed values. It is unchanged and still applies to whatever the new chain resolves — confirmed by the surviving refuses a placeholder-prefixed ambient secret case, which still refuses on 11 and now asserts the new route text. The order changed; the refusal did not.

The real weakening, and why it is not a finding. This diff deliberately makes every pr-<n> preview signable by anyone who can read the repository. That is a genuine reduction, it is the point of the change, and it is authorized by a founder ruling that states the accepted cost and by a decision record that writes the blast radius down — including the cross-stage check (infra/depo), which I re-verified against source: .github/app-roster.json lists web alone, no file under .github/workflows/ mentions depo, infra/depo/worker/index.ts reads its secret from process.env at a hand-run deploy, and infra/depo/worker/verifier.ts authenticates a pasaport apiKey against pasaport's own table rather than a session cookie. The record's claims hold.

Disposition

FAIL, on the id collision alone. No contradiction with standing law in the record's substance, no guard weakened without a record authorizing it, nothing left unread. Renumber to 0406, rename the file, and carry the number through the nine call sites and the .patterns link; the rest of this diff's governance surface is clean and does not need re-deciding.

Superseded verdict — 2026-09-21

governance: PASS @ 4ffdaf9 content:3bda1fedf4fa — no contradiction, no weakening

Both halves read at 4ffdaf9. No contradiction with standing law, and no guard weakened without a record that authorizes it.

Scope

governance scope derived required over 5 declared roots: claude-plugins/ (2), .decisions/ (1), .github/ (1). self false, so §4's merge-base fence did not apply and this verdict judges by the head's rules. One record in the diff: 0405 (added).

This diff derives the governance namespace. Whether it also needs a code-owner approval is a separate question CODEOWNERS answers.

The corpus half — does this contradict standing law

Questions the change decides, written before the sweep:

  1. May a session-signing key live in the repository in the clear?
  2. Which value does a given deploy stage bind, and what decides it?
  3. What refuses the public key where it must not be accepted?
  4. May a true-positive credential literal enter the secret scanner's allowlist?

governance sweep --record 0405 returned shortlist over 330 uncited live-accepted records. Nothing on it governs preview/production secret separation — the top rows (0250 hook fail-open, 0322 lane demotion, 0313 queue dwell, 0210 intake) are lexically adjacent on words like "fails open" and "gate" and answer none of the four questions. Read and dispositioned, not cleared by the outcome.

The genuinely adjacent records were read by hand, since the sweep is citation-independent and would not surface them from 0405's own reference list:

  • 0349 — cited by 0405 and consistent with it. 0349 fences preview seeding on the per-PR D1 name; 0405 leans on exactly that fence for its blast radius and adds a second axis (which key) one layer up. No tension.
  • 0338 — the closest call, and the reason question 4 was written down. 0338 carries the binding constraint "No finding is allowlisted to make the new scope work." That sentence is scoped to 0338's own change: it refuses allowlisting as a way to make merge-result scanning viable. It is not a standing ban on the allowlist, which 0338 leaves in place and which already carried three triaged entries before this diff. 0338's own guard — the scan basis, the exit-0-only rule, the zero-scope skip — is untouched here. No contradiction.
  • 0092 — the fail-closed posture. The new deploy selector inverts correctly under it: the founder-held secret is the default arm and the committed key is the exception, gated on isPreviewStage (/^pr-\d+$/). An unrecognized stage lands on the secret, not the public key.
  • 0394 — the two new @ruling tags under packages/fabrika-cli/ name the issue comment, never an ADR number or a .decisions/ path. guard portability-guard check is clean at head (1473 files, 337 references, none above a ceiling), so this holds mechanically too.

0405 itself is status: accepted, mints an unclaimed id, and cites the founder's ruling comment in its own text rather than asserting authority.

The gate half — does this quietly weaken a guard

governance guards --sha 4ffdaf9: 19 files scanned, 3 anchored invariants in reach, no-anchor-change — all three sit in claude-plugins/fabrika/skills/review-ui/SKILL.md and none was removed or modified. .github/workflows/deploy.yml carries 0 anchored invariants. The reach is stated and the anchored half is empty.

Three unanchored candidates, read by hand, since an unanchored invariant is invisible to that scan:

1. .gitleaks.toml gains an allowlist row for a real credential literal. This is the one place the diff touches a security control directly, so it gets the full disposition rather than a note.

  • What it is: one exact-literal regex for the committed key value. regexTarget = "match" and a full-value pattern, so it is a narrow value match — not a path mute, not a file mute, not a rule mute. A different secret added to the same file, or to infra/preview-auth-key/, is still caught. The file's own stated invariant ("a narrow value match, never a whole-file or whole-rule mute, so a real secret accidentally added to the same test file is still caught") is honored, not softened.
  • Is it authorized? Yes, and this is the part that decides it. The founder ruled route 1 on #9533 with the cost stated in his own words, and ADR 0405 records that ruling and the blast radius. This is not an invariant narrowing with no authorizing decision behind it.
  • Residual, named rather than failed on: the file's header describes the allowlist as "triaged false positives" and this fourth entry is a triaged true positive — a real key, deliberately public. The row's own comment says so plainly, but the header now over-claims. Routed to review as a code-class note, not a governance finding: the control's behavior is unchanged and its narrowness invariant is intact.

2. deploy.yml's BETTER_AUTH_SECRET binding gains a conditional. Read specifically for the "still reads as a guard, no longer guards" shape. It does not have it. The old expression bound one value unconditionally; the new one keys on a step-resolved PREVIEW_AUTH_KEY that is non-empty only for /^pr-\d+$/. The default arm is the founder-held secret, the predicate is narrower than the fail-open environmentForStage map it deliberately does not use, and the resolving step echoes which source it took into the deploy log. For production this is a strengthening: a value that previously could only be the secret still can only be the secret, and a second independent runtime refusal now sits behind it.

3. review-ui render's secret resolution gains a source. The guard that could have been softened here is classifyAuthSecret's refusal of empty and insecure_-prefixed values. It is unchanged and still applies to whatever the new chain resolves — confirmed by the surviving refuses a placeholder-prefixed ambient secret case, which still refuses on 11 and now asserts the new route text. The order changed; the refusal did not.

The real weakening, and why it is not a finding. This diff deliberately makes every pr-<n> preview signable by anyone who can read the repository. That is a genuine reduction, it is the point of the change, and it is authorized by a founder ruling that states the accepted cost and by a decision record that writes the blast radius down — including the cross-stage check (infra/depo), which I re-verified against source: .github/app-roster.json lists web alone, no file under .github/workflows/ mentions depo, infra/depo/worker/index.ts reads its secret from process.env at a hand-run deploy, and infra/depo/worker/verifier.ts authenticates a pasaport apiKey against pasaport's own table rather than a session cookie. The record's claims hold.

Disposition

PASS. Every question was answered against the corpus, the anchored reach was empty and stated, the three unanchored candidates each have a disposition, and the one real weakening carries an authorizing ruling and a record. Nothing here was left unread.

@usirin

usirin commented Sep 21, 2026

Copy link
Copy Markdown
Member Author

review-code: FAIL @ 4ffdaf9 content:3bda1fedf4fa — ADR citations address a record number already taken

Graded against the amended acceptance-criteria block on #9533 (appended 2026-09-21, 8 rows, route 1 only) plus the founder's standing ruling row. The earlier route-2 criteria above it are superseded and were not graded.

The engineering here is good and the two fences are real. It fails on one thing: every ADR citation in this class points at the wrong record.

The finding — the cited record number is already taken

.decisions/0405-fail-floor-subtracts-routed-namespaces.md is on origin/main. This PR's record claims the same id, and fabrika adr next computes 0406. That makes the following citations wrong at this head — each one currently addresses the fail-floor record:

File What it says
.github/workflows/deploy.yml two comment blocks, "(ADR 0405)"
apps/web/worker/environment.ts isPreviewStage docblock, "(ADR 0405)"
apps/web/worker/preview-auth-key.ts module docblock, "ADR 0405 for the ruling"
apps/web/worker/preview-auth-key.unit.test.ts file docblock, "(ADR 0405)"
apps/web/worker/features/pasaport/better-auth-live.ts fence comment, "(ADR 0405)"
infra/ci-credentials/github.ts roster docblock, "(ADR 0405)"
.gitleaks.toml allowlist row comment, "(ADR 0405)"

The repair is the renumber plus these seven carry-throughs. Nothing about the code's behavior changes. Details and the gate's own text are in the governance verdict on this PR.

Per-criterion

2 — deploy.yml's needs-auth leg is stage-keyed. Discharged. The new Resolve the session-signing key source from stage step reads isPreviewStage($STAGE) and sets PREVIEW_AUTH_KEY non-empty only for /^pr-\d+$/; the deploy step binds matrix.needs-auth && (env.PREVIEW_AUTH_KEY != '' && env.PREVIEW_AUTH_KEY || secrets.BETTER_AUTH_SECRET) || ''. I checked the two things that could have made this not run: the node --eval "import('./apps/web/worker/environment.ts')" shape and $STAGE's availability are both already proven in CI by the Resolve deploy ENVIRONMENT from stage step 17 lines above, which uses the identical form against the same workflow-level STAGE; and env context in a step's own env: block is already used at VITE_SENTRY_DSN: ${{ env.ENVIRONMENT == 'production' && ... }} in this same job.

3 — the committed key can never reach a non-preview stage, and the deploy path shows which value each stage resolves to. Discharged, and I checked both fences independently rather than taking the claim.

Fence one holds alone. The predicate is /^pr-\d+$/, not environmentForStage(stage) === "preview". That distinction is load-bearing and the tests prove it: environment.unit.test.ts asserts pr-, pr-1-prod, xpr-1 and PR-1 all map to preview under the old map while isPreviewStage refuses every one of them. So the fail-open axis is closed, and the default arm is the founder-held secret.

Fence two holds alone, and it is genuinely on the boot path. assertAuthSecretForEnvironment is called in BetterAuthLive's Layer.effect body, at the one point that holds both the resolved secret and the resolved environment. I traced that layer to apps/web/worker/index.tsBetterAuthLive.pipe(Layer.provideMerge(DatabaseLive), ...) sits inside the single combined Effect.provide that builds the worker's fetch handler at init, not inside a per-request or auth-route-only path. A throw there dies the init effect, so the worker produces no handler. "Refuses to boot" is accurate, not a figure of speech.

The echo. The resolving step prints which source the stage took on both arms, so a deploy log answers the question rather than leaving it to be read out of a nested YAML ternary.

4 — capture/auth.ts accepts the committed key. Discharged, and the test is not a test of a stub: auth.unit.test.ts reads the real infra/preview-auth-key/key.txt off disk, asserts classifyAuthSecret returns Usable, asserts the value does not carry PLACEHOLDER_SECRET_PREFIX, and then signs a cookie with it and verifies the HMAC through webcrypto.subtle — a different implementation from the node:crypto one that signed it. I ran it: 62 tests pass across auth.unit.test.ts and render-verb.unit.test.ts.

5 — render-verb.ts resolves the key from the repo with no flag and no ci-credentials read, and the visitor-answer message stops pointing at $ALCHEMY_PASSWORD. Discharged. resolveAuthSecret orders named flag → committed key via discoverRepoRoot(options.cwd) → ambient. Both route strings were rewritten and the test now asserts expect(said).not.toContain("ALCHEMY_PASSWORD"), so the old route cannot come back silently. discoverRepoRoot resolves the workspace root rather than the nearest package, so a seat standing in apps/web still finds the key — I read chooseRoot's preference rule to confirm that rather than assuming it.

6 — infra/ci-credentials/github.ts still mints and pushes production's secret unchanged, and its docblock says the preview key is not one of its secrets. Discharged. The diff is docblock-only; the Random mint and the GitHub.Secret push are untouched, and the new == WHAT IS DELIBERATELY NOT IN THE ROSTER == block says it plainly.

8 — a review-ui render of an :auth surface on this PR's own preview returns a signed-in capture rather than exit 11. [evidence: that render's own output, quoted in the PR body] — read and graded on that quote, and corroborated rather than taken on trust. The PR body's ## Live evidence for the last criterion section quotes a run at --pr 9635 --out auth-proof --surface /pano:auth, with $BETTER_AUTH_SECRET explicitly unset via env -u and no --auth-secret-from, returning exit 0 with one 1280x800 capture and zero page errors. Two independent corroborations: the quoted previewUrl (https://phoenix-phoenix-pr-9635-7m5w5ttgj2i4ddfl.kampusinfra.workers.dev) and head (4ffdaf91...) match this PR's own preview-deploy comment exactly, and the pull_request trigger means that preview was itself deployed by this PR's own deploy.yml, so it is the new stage-keyed path being exercised. Exit 0 on an :auth surface is itself the proof: the verb refuses on 11 unless the preview's own /api/auth/get-session comes back a user at the named tier.

Findings

1. resolveAuthSecret swallows a discoverRepoRoot failure and falls through to ambient. render-verb.ts:

const root = yield* Effect.result(discoverRepoRoot(options.cwd));
if (!Result.isFailure(root) && root.success !== undefined) { ... }
return classifyAuthSecret(options.env[AUTH_SECRET_ENV] ?? "", {_tag: "Ambient", ...});

discoverRepoRoot's own docblock is explicit that this collapse is the defect it was designed around: "undefined is a proven absence — every ancestor was probed. A probe that could not be performed leaves on the E channel instead, because an unreadable ancestor answered as 'no repo' would downgrade a pinned repo to the global without saying so." The new caller treats "I could not look" and "there is no repo" as the same branch. The blast radius is bounded — the verb's own session-proof fence means a wrong key produces an 11, never a wrong capture — so this cannot yield a false verdict. But it can hand a seat a confusing "the ambient $BETTER_AUTH_SECRET is empty" refusal when the real cause was an unreadable ancestor directory. Distinguishing the two costs one branch.

2. .gitleaks.toml's header now over-claims. The file describes its allowlist as "a narrow allowlist of triaged false positives" and "Triaged false positives (issue #2325 baseline scan)". The new fourth entry is a triaged true positive — a real signing key, deliberately public. The row's own comment says so, and the narrowness invariant (exact-value match, never a file or rule mute) is honored, so the control is not weakened. The header sentence is what needs a clause.

Deviations

review deviations returned found with 4 entries, matched against substance rather than class label:

Disclosed Matched what I found
render-verb.unit.test.ts route assertion changed Yes — expect(said).toContain("--auth-secret-from") removed, replaced with an assertion on the committed-key path plus not.toContain("ALCHEMY_PASSWORD"). The tier-m removed-assertion row the verb flagged is this one, and it is disclosed. The case still proves the same refusal.
auth.unit.test.ts verify helper gained an optional secret Yes — defaulted to the existing constant, every prior call site unchanged.
.gitleaks.toml entry and .patterns/alchemy-ci-cd.md correction added beyond the named files Yes, both present and both sound. The pattern lines were false after this change and had to move.
pr-cleanup.yml left passing secrets.BETTER_AUTH_SECRET into alchemy destroy Yes, verified as stated and the carve-out is sound. destroy loads alchemy.run.ts so the value only has to resolve for a config read; it deploys no serving worker, so no preview ever verifies a session against it. It does not leave the invariant half-built: neither "the committed key never reaches a non-preview stage" nor "production never verifies with the committed key" depends on it. The pull_request_target exposure is pre-existing and not widened here. Filed as #9636.

Nothing undisclosed that this gate could see.

CI

review ci --wait settled green at 4ffdaf9 — 49 check runs, 45 success, 3 skipped, 1 failure. The one failure is validate ADR files — no duplicate/mismatched ADR number, outside the branch ruleset's four required contexts and therefore non-blocking at the queue. It is the id collision above, and it is exactly why this round names it rather than letting a non-required red carry it to merge.

Verdict-written: 2026-09-21T06:11:34Z

@usirin

usirin commented Sep 21, 2026

Copy link
Copy Markdown
Member Author

review-doc: FAIL @ 4ffdaf9 content:3bda1fedf4fa — the decision record's id collides with a landed record

Graded against the amended acceptance-criteria block on #9533 (appended 2026-09-21, 8 rows, route 1 only). The route-2 criteria above it are superseded and were not graded.

Three doc-class files: .decisions/0405-preview-workers-sign-with-a-committed-public-key.md, infra/preview-auth-key/README.md, .patterns/alchemy-ci-cd.md. The prose is genuinely good — the blast-radius section does the work criterion 7 asked for, and I verified its claims against source rather than reading them. It fails on the record's number.

The finding — the record cannot land at 0405

.decisions/0405-fail-floor-subtracts-routed-namespaces.md is already on origin/main. This record's filename prefix and its frontmatter id: 0405 both claim that number. fabrika adr next computes 0406 over the union of the corpus, open pull requests and branch refs. The repo's own lock is red at this head:

duplicate ADR id 0405 in: 0405-fail-floor-subtracts-routed-namespaces.md, 0405-preview-workers-sign-with-a-committed-public-key.md
 — the ADR number lock requires the filename prefix and the frontmatter `id` to name one record,
 uniquely. Fix: renumber the newer record and rename its file to match.

That check sits outside the branch ruleset's four required contexts, so it does not block the queue on its own — which is the reason to name it here rather than assume the merge gate will.

The repair reaches this class in three places beyond the record itself:

  • .patterns/alchemy-ci-cd.md carries two resolvable links to ../.decisions/0405-preview-workers-sign-with-a-committed-public-key.md. After the rename they point at a file that does not exist, so both move with it.
  • infra/preview-auth-key/README.md cites "ADR 0405" three times and links the file once.
  • The record's own # 0405 — … heading and frontmatter.

Seven more citations sit in the code class; they are listed in the review-code verdict.

Per-criterion

1 — a fixed preview signing key is committed at a named path, with a note beside it saying it is preview-only and deliberately public. Discharged. infra/preview-auth-key/key.txt holds the value and infra/preview-auth-key/README.md sits beside it, opening with "It is preview-only, and it is public on purpose" and stating the consequence in the reader's face: "Anyone who can read this repository can forge a login on any preview deploy." It also carries a rotation section, which nothing asked for and which is the right thing to have written.

7 — a decision record cites the ruling comment and writes the blast radius down. Substantively discharged; only the number is wrong.

  • Cites the ruling. The ## Decision section links #9533 comment 5754589033 directly and quotes the founder's accepted cost in his own words rather than paraphrasing it.
  • A forged preview session reaches only that PR's own throwaway D1. Written under ## The blast radius, written down, naming phoenix-phoenix-db-pr-<n>-… and citing ADR 0349 as the fence that makes it true.
  • Preview logins are open to anyone who reads the repo. Written, under "Who can do it. Anyone.", and labelled the accepted tradeoff rather than buried.
  • The production worker rejects the committed key. Written, and correctly stated as two independent reasons rather than one.
  • The cross-stage question — does anything expect a session signed on one stage to verify on another? This is the part the criteria specifically asked for and the part most likely to have been asserted, so I checked all three of its claims against source. .github/app-roster.json contains web alone. No file under .github/workflows/ mentions depo. infra/depo/worker/index.ts reads BETTER_AUTH_SECRET off process.env at a hand-run deploy and infra/depo/README.md documents that it must match pasaport's — production-to-production, as the record says. And infra/depo/worker/verifier.ts resolves a presented pasaport apiKey against pasaport's own apiKey table, so a session cookie is not a credential it accepts in any form. All three hold. The record follows ADR 0349's precedent closely, which is what was asked.

The record also discloses its own carve-out under ## One thing this does not change — the pr-cleanup.yml teardown — rather than leaving it to the PR body. That is the right home for it.

Note, not a finding

.patterns/alchemy-ci-cd.md's two edits are accurate and were necessary: the old table row and the old bullet both said the one Actions secret is handed to every stage, which this change makes false. Correcting them was right, and it is disclosed as an out-of-scope change in the PR's Deviations.

Deviations

The disclosure's third entry covers the .patterns correction explicitly. Nothing undisclosed that this gate could see in this class.

Verdict-written: 2026-09-21T06:12:16Z

@usirin

usirin commented Sep 21, 2026

Copy link
Copy Markdown
Member Author

review-skill: FAIL @ 4ffdaf9 content:3bda1fedf4fa — review-ui's contract still documents the retired secret route

Two skill-class files: claude-plugins/fabrika/skills/review-ui/SKILL.md and claude-plugins/fabrika/skills/review-ui/contract.md. Both read whole at 4ffdaf9 out of the object database, per the rule that a touched skill file's unit is the document and not the hunk — the SKILL.md end to end (355 lines), the contract.md by heading around the sections the change reaches.

That read is what this FAIL rests on. The edited paragraphs are correct. The paragraphs the change left behind are not, and three of them still teach the retired route.

Findings

1. contract.md's mechanism section still documents the old resolution and the deleted route out. Unedited, in the review-ui render mechanism paragraph:

Resolve the tier signing secret — the file --auth-secret-from names, else the ambient variable — and refuse on 11 before a browser launches when it cannot be read, is empty, or carries the insecure_ placeholder. The refusal names the source it read and the route out, and the route differs by source: with no flag it is to pass one, and with a flag it is to re-export the repo-wide value from the ci-credentials stack's alchemy state, since the named file does not hold the deployed one.

Every clause there is now false. The committed key is the default source and does not appear at all. And the sentence describes, verbatim, the route this PR's own criterion 5 required removing — "re-export the repo-wide value from the ci-credentials stack's alchemy state" is the $ALCHEMY_PASSWORD route no agent holds. render-verb.ts now emits "run from a checkout carrying infra/preview-auth-key/key.txt" and "drop --auth-secret-from and the committed preview key … resolves on its own." The code moved; its contract did not.

2. contract.md's refusal-message table carries a string the verb no longer emits. The table row reads:

review-ui render: cannot read the exported repo-wide session-signing secret at <path>: <reason> — the named tier's render is UNKNOWN.

The head's render-verb.ts emits review-ui render: cannot read the session-signing secret at <path>: <reason> — …. That table is the registered description of the verb's refusals, so a reader matching an observed refusal against it finds nothing.

3. SKILL.md's forced-flag example still passes a flag whose source the same diff deleted. Section 3:

fabrika review-ui render --pr $pr_number --out forced --surface /welcome:auth --flag welcome-banner=on --auth-secret-from <file>

Fourteen lines above it, the rewritten paragraph now tells the reader they need no credential and that the verb resolves the key off the checkout with no flag. The example then hands them --auth-secret-from <file> — and <file> is now undefined anywhere in the document, because the paragraph that used to say where such a file comes from (the ci-credentials export) is the one this diff replaced. A reader following the example is sent after a file the skill no longer describes, to do a thing the skill just told them is unnecessary. Drop the operand from the example.

All three are the same defect with one cause: the diff edited the two places that describe the secret source and left the three places that use or enumerate it. I named all three in this one body deliberately rather than leaving the next round to find the second and third — a repair that fixes only what the hunks made obvious will come back.

What is correct

The edits that were made are accurate and well written. SKILL.md's tier-state paragraph and contract.md's tier-state bullet both now say the right thing, including the part that matters most — that the production worker refuses a preview-prefixed key at boot, so the two keys cannot be confused. The --auth-secret-from flag-table row correctly re-describes the default as the committed key with the ambient variable behind it, and correctly reframes the flag as an override rather than the route. command.ts's flag description and long description match that row.

fabrika guard portability-guard check is clean at this head — 1473 files, 337 references under a declared ceiling and none above it. The new infra/preview-auth-key/key.txt references in the shipped skill text are repo-relative paths rather than ticket numbers, decision-record numbers or hosted URLs, and no ceiling was raised to make room for anything.

governance guards reports the three anchored invariants in review-ui/SKILL.md as no-anchor-change: UNSEEN-NEVER-PLAUSIBLE, PAIRWISE-NEVER-ABSOLUTE and CAPABILITIES are all intact. Nothing here softens a guard.

Editorial

writing-for-agents applied to the changed prose: the new paragraphs carry their reason inline rather than asserting, keep one claim per sentence, and name the mechanism (preview_ prefix, boot refusal) instead of gesturing at it. No findings on craft. The defect is staleness, not writing.

Deviations

Nothing in this class was undisclosed. The PR's disclosure covers the test and pattern changes; it does not claim the contract was fully carried through, and it does not assert otherwise.

Verdict-written: 2026-09-21T06:13:04Z

@usirin

usirin commented Sep 21, 2026

Copy link
Copy Markdown
Member Author

Repair round 1 at c323974eb76a2750381dd231fc9e7595cca70510. Four FAIL verdicts at 4ffdaf9, two distinct findings, both addressed.

1 — the ADR id collision (governance, review-code, review-doc)

fabrika adr next computed 0406 in this tree (392 records scanned, 1 id in flight). The record was renamed to .decisions/0406-preview-workers-sign-with-a-committed-public-key.md, its frontmatter id and its # heading both say 0406, and I grepped the whole tree rather than working from a count. Fourteen citations carried through, in ten files:

  • .github/workflows/deploy.yml (2 comment blocks)
  • apps/web/worker/environment.ts, apps/web/worker/environment.unit.test.ts
  • apps/web/worker/preview-auth-key.ts (2 — the module docblock and the refusal message), apps/web/worker/preview-auth-key.unit.test.ts
  • apps/web/worker/features/pasaport/better-auth-live.ts
  • infra/ci-credentials/github.ts, infra/preview-auth-key/README.md (2)
  • .gitleaks.toml
  • .patterns/alchemy-ci-cd.md (2 resolvable links, both repointed at the new filename)

Plus the PR body's own two references. A grep -rn 0405 over the tree now returns nothing. The reviewer counted nine in code and config; the tree held one more than that in preview-auth-key.ts and one in environment.unit.test.ts, which is why the grep rather than the count.

2 — review-ui's contract still taught the retired route (review-skill)

Read both files whole, not just the three named spots. Fixed:

  • contract.md mechanism paragraph — rewritten to the three sources the code actually orders (named flag, else the committed key off the checkout, else ambient), and the two routes out restated from the strings render-verb.ts emits at head.
  • contract.md refusal table — the row now reads cannot read the session-signing secret at <path>: <reason>, matching the verb.
  • SKILL.md section 3 forced-flag example — --auth-secret-from <file> dropped; the operand had no defined <file> left in the document.

One more the whole-read turned up, not in the three: contract.md's tier-state bullet still opened "the preview worker's signing secret plus that tier's own session token, all unset by default". The secret is no longer unset by default — it resolves off the checkout — so that clause now scopes to the tokens alone.

The advisory — resolveAuthSecret swallowing a discoverRepoRoot failure

Fixed rather than deferred; it is one branch in a file this round already touches. A Result.isFailure(root) now returns Unreadable naming the ancestor that stopped the read, so "could not look" no longer arrives as "no repo here, fall through to ambient". The exit code is unchanged (11); only the message improves. One unit case covers it (refuses on 11 naming the unreadable ancestor when the repo root cannot be located), added by giving the test's run helper fakeFs's existing unreadable list. contract.md's exit-11 row gained the clause.

What I did not touch

Everything the reviewer graded sound: the two fences, the boot refusal's reachability, the tests reading the real committed key, the blast-radius artifact and its infra/depo answer, the live render evidence, the pr-cleanup.yml carve-out (#9636 already filed). The only code change in this round is the advisory branch above; every other edit is a number or a sentence.

Verification

build check green on three surfaces in this tree — code (typecheck, lint, 19 guards), prose (link + leak scan), workflows (actionlint). packages/fabrika-cli src/review-ui unit tests: 9 files, 142 passing. The validate ADR files check should go green at this head, since origin/main holds 0405 and this branch now holds 0406.

Look at the grep first — the finding was "carry the renumber through every citation", and the whole repair turns on there being none left.

— at c323974

@usirin

usirin commented Sep 21, 2026

Copy link
Copy Markdown
Member Author

governance: PASS @ c323974 content:d3d1f0435b3d — no contradiction, no weakening

Round 2, at head c323974e. The round-1 governance FAIL at 4ffdaf9 was an ADR id collision; that is what this round re-derived first.

Scope

governance scope derives required over 5 declared roots, hitting 3: claude-plugins/ (2 files), .decisions/ (1), .github/ (1). self false — this diff does not touch the governance skill or its contract, so §4's merge-base fence did not apply and the rules judged by are the head's.

Whether this diff needs a code-owner approval is a separate question CODEOWNERS answers, not this namespace's.

The corpus half

The id. The record in the diff is 0406, added at .decisions/0406-preview-workers-sign-with-a-committed-public-key.md. Derived rather than taken on trust:

  • origin/main carries 04000405 and no 0406. The 0405 slot on main is 0405-fail-floor-subtracts-routed-namespaces.md, which is exactly what the previous head collided with — at 4ffdaf9 this record was filed as 0405-preview-workers-sign-with-a-committed-public-key.md.
  • adr next computes 0407 over 392 records plus 1 id in flight across open pull requests and 394 claimed on branch refs. The one in-flight id is this PR's 0406, so nothing else claims it.
  • No reference to the retired 0405 id survives anywhere in the head tree: a tree-wide grep for 0405 at c323974e returns nothing at all.
  • The 14 citations the repair claims are present and counted: deploy.yml 2, .gitleaks.toml 1, .patterns/alchemy-ci-cd.md 2, environment.ts 1, environment.unit.test.ts 1, better-auth-live.ts 1, preview-auth-key.ts 2, preview-auth-key.unit.test.ts 1, infra/ci-credentials/github.ts 1, infra/preview-auth-key/README.md 2 — 14 across 10 files, plus the record's own 2.

The sweep. governance sweep --record 0406 ranked 331 uncited live-accepted records and returned shortlist. The eight ranked rows are mechanically adjacent by vocabulary and none of them legislates on session-signing keys: 0250 (hook fails open), 0322 (lane demotion), 0313 (queue dwell), 0210 (direction at intake), 0137 (e2e privileged D1 promote), 0179 (boot contract), 0306 (deleted packages), 0145 (dark-ship note). No contradiction on any.

The hand read, because the sweep is citation-independent and a disagreement about what a value means shares no vocabulary. Questions this change answers, and where the standing corpus sits on each:

  • May a deployed worker's session-signing key live in the repository in the clear? No standing record forbids it — a tree-wide search of .decisions/ for a no-committed-secrets rule returns nothing. The founder ruled it on Preview workers verify logins with the same signing secret as production, so no agent can be handed a safe copy #9533 and 0406 quotes the ruling's own words for the accepted cost.
  • Does this contradict ADR 0338 (the secret gate scans the merge result)? No. 0338's binding constraint reads "No finding is allowlisted to make the new scope work" — that is scoped to the scan-basis change it decides, and this entry is not one. The .gitleaks.toml entry added here keeps that file's own discipline: a narrow single-value regex under regexTarget = "match", never a file or rule mute, carrying its one-line reason and citing 0406. I will name the one honest stretch rather than leave it: the allowlist's header calls its entries "triaged false positives", and this value is a real key that is deliberately public, not a false positive. It is authorized by a founder ruling and a landed record, and the narrow match means a rotation re-reds the scan until the entry follows — so the friction points the right way. Not a weakening.
  • Does this contradict ADR 0088 (the preview deploy class), whose rite-audit: flag force-on seam — force phoenix-authorship-loop on for the dedicated audit stage (prod never) #1511 note says audit shares preview's auth topology? No. 0088's "auth topology" there is about served origin and trusted host, not about which value signs a session; 0406 addresses audit head-on, keeps it on the founder-held secret, cites rite-audit: flag force-on seam — force phoenix-authorship-loop on for the dedicated audit stage (prod never) #1511 for why, and judgeAuthSecret refuses a preview_ key on audit alongside production and development. Addressed, not contradicted.
  • Does the decision hold its own cited support? adr resolve 0349 0088 0137 returns all three live accepted. 0406's blast-radius section rests on 0349, which is live.

The gate half

governance guards --sha c323974e scanned 19 files, found 3 anchored invariants in reach and compared 14 files block-by-block against the base: no-anchor-change 3. The three anchors in reach all sit in claude-plugins/fabrika/skills/review-ui/SKILL.md; .github/workflows/deploy.yml carries none. No anchored invariant was removed or softened.

Read by hand, because an unanchored invariant is invisible to that scan and this diff is about a fence:

  • The deploy.yml selection does not soften a guard — it adds one. The stage-keyed step resolves on isPreviewStage (/^pr-\d+$/) and deliberately not on environmentForStage(stage) === "preview", which is fail-open on the stage axis. The founder-held secret stays the default; the public key is the exception. A hand-named stage lands on the secret.
  • The worker-boot refusal is a new guard, not a relaxed one. assertAuthSecretForEnvironment throws in BetterAuthLive, keyed on the preview_ prefix rather than the literal bytes, with preview-auth-key.unit.test.ts reading the real committed file so the prefix and the fence cannot drift.
  • infra/ci-credentials/github.ts mints and pushes exactly what it minted before. The change there is a docblock only; nothing about the founder-held secret's production path moves.
  • The one guard this diff leaves alone and says so: pr-cleanup.yml still hands secrets.BETTER_AUTH_SECRET to a preview-stage alchemy destroy. Disclosed in the PR's Deviations, written into 0406's own text, and filed as Preview teardown still hands the founder-held BETTER_AUTH_SECRET to a pr-<n> stage #9636. Nothing here weakens it; it is unchanged.

Verdict

PASS. No contradiction with standing law and no weakening of a guard. The round-1 FAIL's cause — an id already claimed on origin/main — is resolved at this head and derived independently here, not taken from the repair's account of itself.

@usirin

usirin commented Sep 21, 2026

Copy link
Copy Markdown
Member Author

review-code: PASS @ c323974 content:d3d1f0435b3d — all 8 criteria discharged at head, CI green

Round 2, at head c323974e, over the 14 code-class files. The round-1 FAIL at 4ffdaf9 named two causes; both were in the doc and skill classes, and this class is judged fresh against the amendment block's 8 rows.

The contract graded is the last acceptance-criteria block on #9533, under ## Amendment — 2026-09-21: 8 body rows plus 1 standing ruling, route 1 alone. review criteria reports 0 body rows superseded and 0 drifted markers.

Execution evidence

review ci --sha c323974e --wait returns settled green — 49 check runs, 46 success, 3 skipped, and 28 of 43 repo-authored workflows inspected this commit. Two required contexts that matter to the round-1 FAIL:

  • validate ADR files — no duplicate/mismatched ADR numbersuccess. This is the check that surfaced the id collision at 4ffdaf9.
  • governance floor at head — green after this round's governance verdict bound at c323974e.

No local re-run of the suite: CI is the execution evidence, and this reviewer's worktree does not stand at the head.

Per-criterion

1. A fixed preview signing key is committed at a named path, with a note beside it saying it is preview-only and deliberately public. Discharged. infra/preview-auth-key/key.txt holds one line, preview_915b87…, and the note is infra/preview-auth-key/README.md in the same directory: "It is preview-only, and it is public on purpose", with the accepted cost stated in the founder's terms and the ruling linked.

2. deploy.yml's needs-auth matrix leg is stage-keyed. Discharged. A new step resolves the source and the deploy step reads its answer:

BETTER_AUTH_SECRET: ${{ matrix.needs-auth && (env.PREVIEW_AUTH_KEY != '' && env.PREVIEW_AUTH_KEY || secrets.BETTER_AUTH_SECRET) || '' }}

PREVIEW_AUTH_KEY is set non-empty only on the yes arm. The non-empty test is the whole selector and there is no third value, so prod, audit and every hand-named stage take secrets.BETTER_AUTH_SECRET. The node --eval that calls isPreviewStage is the same shape the step directly above it already uses for environmentForStage, against the same module — so the TypeScript-import question is settled by a proven neighbour rather than by assumption, and set -euo pipefail with tr -d reading the key file fails the step if the file is missing rather than deploying an empty key.

3. The committed key can never reach a non-preview stage, and the deploy path shows which value each stage resolves to. Discharged, and I re-checked both fences rather than carrying round 1's word for it.

  • Selection keys on isPreviewStage/^pr-\d+$/ in apps/web/worker/environment.ts — not on environmentForStage(stage) === "preview", which is fail-open on the stage axis. environment.unit.test.ts pins that distinction directly: for pr-, pr-1-prod, xpr-1 and PR-1 it asserts isPreviewStage false and environmentForStage "preview", so the near-miss cases are the tested ones.
  • The boot refusal is real and reachable from worker init, not a per-request path: assertAuthSecretForEnvironment(environment, Redacted.value(secret)) runs inside BetterAuthLive's Layer.effect, at the one place holding both the secret and the environment, and throws. judgeAuthSecret keys on the preview_ prefix rather than the committed bytes, so a rotation cannot outrun it.
  • Each step echoes its answer, so a deploy log names the value the stage took.

4. capture/auth.ts accepts the committed key. Discharged, and the test is not a stub: auth.unit.test.ts reads the real infra/preview-auth-key/key.txt off disk with readFileSync, asserts classifyAuthSecret returns Usable, asserts it is not placeholder-prefixed, and signs a cookie with it that the test's own independent webcrypto verifier accepts. preview-auth-key.unit.test.ts reads the same real file for the prefix and the 32-character floor. A fixture would have kept passing through a rotation that broke either.

5. render-verb.ts resolves the preview key with no --auth-secret-from and no ci-credentials read, and its visitor-answer message stops pointing at the $ALCHEMY_PASSWORD route. Discharged, code half. resolveAuthSecret's order is flag, then committed key, then ambient. Both route-out strings now name infra/preview-auth-key/key.txt and neither sends a seat after a credential. A tree-wide search of claude-plugins/ and packages/fabrika-cli/src/ at this head finds exactly one remaining ALCHEMY_PASSWORD occurrence, and it is the negative assertion expect(said).not.toContain("ALCHEMY_PASSWORD") in render-verb.unit.test.ts. The prose half of this criterion is judged in the review-skill verdict.

6. infra/ci-credentials/github.ts still mints and pushes the production secret unchanged, and its roster docblock says the preview key is not one of its secrets. Discharged, derived rather than eyeballed: stripping comment lines from that file's base-to-head diff leaves nothing. The change is docblock-only, and the docblock carries a == WHAT IS DELIBERATELY NOT IN THE ROSTER == block saying the preview key never passes through the stack.

7. A decision record cites the ruling and writes the blast radius down. Discharged; graded in full in the governance and review-doc verdicts.

8. A review-ui render of an :auth surface on this PR's own preview returns a signed-in capture rather than exit 11. This is the one row carrying the outside-diff evidence marker, whose named source is "that render's own output on the PR's preview deploy, quoted in the PR body or in a comment on this issue". What I read: the PR body's hand-verification section, which quotes a review-ui render --pr 9635 --out auth-proof --surface /pano:auth run with $BETTER_AUTH_SECRET explicitly unset and no --auth-secret-from, coming back captured: 1280x800, 0 page error(s) and exit 0. Before recording, the verb asked the preview's own /api/auth/get-session from the same browser context and required a user back at the yazar tier, so the signature verified against what the worker deployed.

That render was taken at head 4ffdaf9, not at this head, so I did not take it on trust. I derived whether it still binds by reading the inter-head diff of render-verb.ts: the repair touched only resolveAuthSecret's failure arm, splitting if (!Result.isFailure(root) && root.success !== undefined) into an explicit failure return plus the same success test. The success path the render exercised — root found, committed key read, classifyAuthSecret with CommittedPreviewKey — is byte-identical at both heads. The evidence binds.

Fan-out

Type design. AuthSecretVerdict and AuthSecretSource are both tagged unions whose refusal arm carries the fact an operator needs — the environment that refused, the path that was read — rather than a bare boolean. describeAuthSecretSource switches exhaustively with no default, so a fourth source is a compile error rather than a silent fallthrough. This is the shape the repo asks for.

Silent failure. One finding, and it is out of scope: resolveAuthSecret refuses a failed discoverRepoRoot but still folds an unreadable key.txt into the ambient fallback, so "could not read the file" and "this checkout has no such file" share a branch — the same conflation the repair just refused one level up. Narrow reach, and the criteria name nothing about it. Routed to #9637 rather than appended as a criterion, so it does not bury a round that otherwise discharges the contract.

Test gaps. The pure judgeAuthSecret is well covered and the deploy predicate's near-misses are pinned. The one untested link is the wiring: nothing asserts that BetterAuthLive calls assertAuthSecretForEnvironment, so deleting that call would leave every test here green. Bounded rather than alarming — the deploy.yml selection is a fully independent first fence and is itself tested through isPreviewStage — so I am naming it in this verdict rather than routing it. render-verb.unit.test.ts covers the committed key, its precedence over a usable ambient secret, the flag override, and the new root-failure refusal.

The disclosed out-of-scope change

resolveAuthSecret no longer swallows a discoverRepoRoot failure. Disclosed as deviation entry 7 ("the criteria name no behavior in resolveAuthSecret's root-discovery branch"). Judged as one: I read delegate/root.ts and the docblock's claim is exactly right — discoverRepoRoot puts "there is no repo here" on undefined and "I could not look" on its E channel, and says so in its own text. So the old code reported the second as the first. The change makes the tool honest, is covered by a new test asserting the refusal names the unreadable ancestor and does not name $BETTER_AUTH_SECRET, and cannot reach an operator who passed --auth-secret-from, since that arm returns before root discovery. A correct and bounded deviation.

Deviations

review deviations returns found, with 5 entries and 1 tier-m removed-assertion. Matched against what I found, on substance:

Disclosed Matches what I saw
entry 6 — criteria name render-verb.ts's message, not its tests yes; the --auth-secret-from assertion at render-verb.unit.test.ts:219 was replaced with one on the new route text, which is the tier-m removed-assertion row
entry 6 — nothing about capture/auth.unit.test.ts's helpers yes; verify gained an optional secret defaulting to the existing constant, every call site unchanged
entry 7 — criteria name six source files, a record and the workflow yes; .gitleaks.toml, .patterns/alchemy-ci-cd.md and infra/preview-auth-key/README.md are beyond the named set
entry 3 — nothing about teardown yes; pr-cleanup.yml untouched, recorded in ADR 0406 and filed as #9636
entry 7 — criteria name no behavior in resolveAuthSecret's root branch yes; judged above

Nothing undisclosed that this gate could see.

Verdict

PASS. All 8 rows discharged at this head, CI green, one out-of-scope silent-failure finding routed to #9637 and one test gap named.

Verdict-written: 2026-09-21T06:38:24Z

@usirin

usirin commented Sep 21, 2026

Copy link
Copy Markdown
Member Author

review-doc: PASS @ c323974 content:d3d1f0435b3d — id free and derived, no stale reference, criterion 7 discharged

Round 2, at head c323974e, over the 3 doc-class files: .decisions/0406-preview-workers-sign-with-a-committed-public-key.md (new), .patterns/alchemy-ci-cd.md, infra/preview-auth-key/README.md (new).

The round-1 FAIL's cause, re-derived here

Round 1 failed this class on an ADR id already claimed on origin/main. Judged at this head, not taken from the repair's account:

  • The record is now 0406. origin/main carries 04000405, no 0406; the slot the previous head used, 0405, is 0405-fail-floor-subtracts-routed-namespaces.md on main.
  • adr next computes 0407 over 392 records plus 1 id in flight across open pull requests and 394 claimed on branch refs, remote-tracking ones included. That one in-flight id is this PR's, so nothing else claims 0406.
  • No stale reference to the old id survives. A tree-wide search for 0405 at c323974e returns nothing at all — not a citation, not a filename, not a link.
  • The 14 citations are present and counted across 10 files: deploy.yml 2, .gitleaks.toml 1, .patterns/alchemy-ci-cd.md 2, environment.ts 1, environment.unit.test.ts 1, better-auth-live.ts 1, preview-auth-key.ts 2, preview-auth-key.unit.test.ts 1, infra/ci-credentials/github.ts 1, infra/preview-auth-key/README.md 2.
  • The PR body's two references both read 0406.
  • validate ADR files — no duplicate/mismatched ADR number is success at this head. That is the check that caught the collision, and it is green.

Criterion 7, the doc-class row

"A decision record cites the ruling comment above and writes the blast radius down: a forged preview session reaches only that PR's own throwaway D1 (ADR 0349), preview logins are open to anyone who reads the repo, and the production worker rejects the committed key." Discharged, all four parts, and each is in the record rather than gestured at:

  • Cites the ruling. The ## Decision section links #9533#issuecomment-5754589033 by comment id and quotes the accepted cost in the founder's own words.
  • Reaches only that PR's D1. Named concretely — phoenix-phoenix-db-pr-<n>-…, holding the e2e suite's sign-ups and whatever preview-seed test-account seeded — citing ADR 0349, which adr resolve reports live accepted.
  • Open to anyone. Stated flatly under "Who can do it": "Anyone. Preview URLs are public and the key is in the repository. This is the accepted tradeoff, not an oversight."
  • Production rejects it. Two reasons given, not one: production deploys the founder-held secret so the signature does not verify, and it would refuse to boot if the committed key ever reached it.

The record also answers the cross-stage question the criteria block raised, with infra/depo named and checked three ways — absent from .github/app-roster.json and from every workflow, both stacks hand-deployed; its sharing is production-to-production; and its doorman authenticates a pasaport apiKey against pasaport's own table rather than verifying a session cookie. That is a real answer to a real question, not a hedge.

Portability

guard portability-guard check is clean at this head: 1473 files scanned, 337 references under a declared ceiling and none above it. No ceiling was raised to make room for a new reference — which matters here, because the record and the README both carry hosted issue and PR URLs, and both sit outside fabrika's two shipped trees where the guard bites.

Editorial craft — writing-for-agents

Applied to all three files.

Single source of truth. ADR 0406 owns the rationale and the blast radius; infra/preview-auth-key/README.md owns the operational facts beside the key and links out to 0406 for the full check rather than restating it ("See ADR 0406 for the full check, including the one hand-deployed worker that shares pasaport's secret"); .patterns/alchemy-ci-cd.md states the one line a reader of that pattern needs and links. That is the ladder used correctly — disclosed reference behind a pointer, not three copies.

Relevance and staleness. The .patterns table row for BETTER_AUTH_SECRET no longer reads as repo-wide; it now says "for production, audit and every named stage — not for previews" and names where a pr-<n> stage gets its value instead. The bullet further down that previously inlined the YAML expression matrix.needs-auth && secrets.BETTER_AUTH_SECRET || '' drops the stale literal rather than updating it, which is the right call: that expression is now a three-way selection and the environment is its source of truth.

Positive framing. The README's rotation section states the target act ("Replace the value in key.txt with a fresh preview_-prefixed high-entropy string and land it") rather than a prohibition, and closes on the plain consequence: "There is no secret store to update, because there is no secret."

No no-ops found, and no sprawl: 0406 runs 130 lines with a distinct job per heading, and the README is 39.

One observation, not a finding: the fact that a pr-<n> stage takes the committed key while everything else takes the Actions secret is now stated in 0406, the README, .patterns/alchemy-ci-cd.md, the deploy.yml comment, and infra/ci-credentials/github.ts's docblock. That spread predates this diff and is already filed as #9337 ("One fact about BETTER_AUTH_SECRET's source is copied into five surfaces that must agree by hand"), so I am citing it rather than re-raising it. This diff kept every copy in agreement.

Verdict

PASS. The id is genuinely free and derived so here, no stale reference to the retired id survives anywhere in the tree, the ADR-validation check is green at head, criterion 7 is discharged in full, and the portability guard is clean.

The evidence-marked criterion

One row of the contract carries the outside-diff evidence marker — "A review-ui render of an :auth surface on this PR's own preview returns a signed-in capture rather than exit 11", whose named source is that render's own output quoted in the PR body or in a comment on the issue. It is a code-class row and is graded in full in the review-code verdict, but naming what it rested on belongs in every namespace's body: I read the PR body's hand-verification section, which quotes a review-ui render --pr 9635 --out auth-proof --surface /pano:auth run with $BETTER_AUTH_SECRET unset and no --auth-secret-from, returning captured: 1280x800, 0 page error(s) at exit 0 with the verb's own session proof coming back at the yazar tier. That render was taken at head 4ffdaf9; I checked the inter-head diff of render-verb.ts and the success path it exercised is byte-identical at c323974e, so the evidence still binds.

The contract names that source as: that render's own output on the PR's preview deploy, quoted in the PR body or in a comment on this issue. That is where I read it.

Verdict-written: 2026-09-21T06:40:13Z

@usirin

usirin commented Sep 21, 2026

Copy link
Copy Markdown
Member Author

review-skill: PASS @ c323974 content:d3d1f0435b3d — both files read whole, no retired route survives, docs match the verb

Round 2, at head c323974e, over the 2 skill-class files: claude-plugins/fabrika/skills/review-ui/SKILL.md and claude-plugins/fabrika/skills/review-ui/contract.md.

Both files were read end to end at the scoped head, out of the object database, not as hunks. That is the whole point of this round: round 1 failed this class because a stale sentence sat outside the hunks it had graded, and the way to not repeat it is to finish both documents in one pass and name every contradiction in one body.

Criterion 5, the prose half

"…and its visitor-answer message stops pointing a seat at the $ALCHEMY_PASSWORD route no agent holds." The code half is in the review-code verdict. Here is what the two documents now teach, checked against what render-verb.ts actually does at this head.

No trace of the retired route survives in either file. Searching both whole documents for ALCHEMY_PASSWORD, ci-credentials, repo-wide, readable copy, export, credential, per-stage, stands in, not yours and been handed turns up only the new, correct text. Widening to the whole of claude-plugins/ and packages/fabrika-cli/src/ at this head, the single remaining ALCHEMY_PASSWORD occurrence anywhere is expect(said).not.toContain("ALCHEMY_PASSWORD") — a test asserting its absence.

Four spots, all four fixed. Round 1 named three and the repair says it found a fourth by reading both files whole. Reading them whole myself, I find the same four and no fifth:

  1. SKILL.md lines 140–152 — the tier-values paragraph. Now: "you need no credential to hold it", the key resolves off the checkout with no flag and no environment variable, production keeps a separate founder-held secret, and a preview-prefixed key is refused at boot. The old closing sentence — "If you hold no export of that value and your ambient value is the placeholder, you have not been handed the secret, and review-ui note is the honest route" — is gone, correctly, because that route no longer exists for the secret.
  2. contract.md lines 204–214 — the required-environment bullet. Now opens "The tokens are unset by default; the secret is not, since it resolves off the checkout", which is the precise statement of the change.
  3. contract.md line 268 — the --auth-secret-from flag row. Default column now reads "the committed preview key at infra/preview-auth-key/key.txt, else the ambient $BETTER_AUTH_SECRET", and the description calls the flag "rarely needed".
  4. The fourth, found by the repair: SKILL.md line 205, the worked --flag example, which still carried a trailing --auth-secret-from <file> on its command line. It is dropped. That one matters more than its size — a worked example is what a reader copies, so the example would have kept teaching the retired route after every paragraph around it stopped.

The documented behaviour matches the verb, including the refusal strings.

  • contract.md lines 363–368 state the resolution order as flag, then committed key in the checkout the verb runs in, then the ambient variable. resolveAuthSecret takes exactly that order.
  • The route-out text differs by source in the contract — "with no flag it is to run from a checkout that carries that committed key… with a flag it is to drop the flag, since the named file does not hold the value this preview verifies against" — and render-verb.ts's two route strings say the same two things. Neither sends a seat after a credential.
  • The refusal-strings table row changed from "cannot read the exported repo-wide session-signing secret at <path>" to "cannot read the session-signing secret at <path>: <reason>", which is now the literal string the verb emits.
  • The new root-discovery refusal is covered rather than undocumented: the exit-11 row at line 399 gained "or while the repo root could not be located at all so the committed preview key was never looked for", and the message itself rides the existing cannot read the session-signing secret row because the verb returns the same Unreadable shape. So there is no undocumented refusal at this head.

One sentence I checked and am not calling stale. SKILL.md line 214 reads "The credentials are the operator's, not yours (see below)". Read in isolation that could be taken to include the signing secret. Read where it sits — immediately after the tier-token paragraph and immediately before "and one more grant rides with them: platform admin on that throwaway preview D1" — "them" is the tier session tokens, which are still the operator's. The sentence is accurate and teaches nothing retired. Naming it here so a later round does not have to re-derive that it is fine.

Portability

guard portability-guard check is clean: 1473 files scanned, 337 references under a declared ceiling and none above it. No ceiling was raised. Both files sit inside claude-plugins/fabrika/, where the guard bites hardest, and neither gained a ticket number, a decision-record number, a decision-corpus path or a hosted URL — the new text names infra/preview-auth-key/key.txt as a repo-relative path, which is a shape the guard admits, and pr-<n> as a stage-name pattern rather than a reference.

Editorial craft — writing-for-agents

Pruning, and the right direction. The rewrite is a net cut: the SKILL.md paragraph went from thirteen lines describing a credential chain to eight stating a fact, and the sentence that no longer bears on anything was deleted rather than trimmed. That is the no-op test applied correctly.

Positive framing over prohibition. The old text steered by what you could not have ("If you hold no export… you have not been handed the secret"). The new text states the target ("you need no credential to hold it… the verb resolves it off the checkout you are standing in"). The banned behaviour is simply never spoken, which is the lever's own prescription.

Co-location. Each document keeps the whole concept under one heading: the secret's source, the override, the fallback and the refusal all sit together in SKILL.md's tier paragraph and again in the contract's required-environment bullet, rather than scattering across the file.

Single source of truth — the one place to be honest about. The same fact about which stage gets which key now appears in SKILL.md, the contract's environment bullet, the contract's flag table, the contract's behaviour section, and two long --help strings in command.ts. That is six copies of one meaning. I am not raising it as a finding, for two reasons: the split across contract, skill text and CLI help is this repo's established layering rather than something this diff invented, and the previous version carried the identical spread with the ci-credentials story in every slot. The diff kept all six in agreement, which is the most a round can ask. The underlying duplication is already filed as #9337.

No sprawl introduced, and no [N/A] reference left dangling — the two wire doc-section pointers at SKILL.md lines 189–191 still name headings that exist in the contract at this head.

Deviations

review deviations returns found, five entries plus one tier-m removed-assertion. The entry bearing on this class is entry 7 — "the criteria name six source files, a decision record and the deploy workflow" — which discloses that .gitleaks.toml, .patterns/alchemy-ci-cd.md and infra/preview-auth-key/README.md are beyond the named set. The two skill files are within it, through criterion 5. Nothing about the skill-class changes is undisclosed.

The evidence-marked criterion

One row of the contract carries the outside-diff evidence marker: "A review-ui render of an :auth surface on this PR's own preview returns a signed-in capture rather than exit 11", and the contract names its source as — that render's own output on the PR's preview deploy, quoted in the PR body or in a comment on this issue. That is where I read it: the PR body's hand-verification section quotes a review-ui render --pr 9635 --out auth-proof --surface /pano:auth run with $BETTER_AUTH_SECRET unset and no --auth-secret-from, returning captured: 1280x800, 0 page error(s) at exit 0, with the verb's own session proof coming back at the yazar tier. The row is a code-class one and is graded in full in the review-code verdict; the render was taken at head 4ffdaf9, and the inter-head diff of render-verb.ts shows the success path it exercised is byte-identical at c323974e, so the evidence still binds. It also bears on this class directly: that run is the live proof that what these two documents now teach — no flag, no credential, no environment variable — is what the verb does.

Verdict

PASS. Both files read whole at head, all four retired-route spots fixed with no fifth found, the documented behaviour and the emitted refusal strings match the verb at this head, and the portability guard is clean.

Verdict-written: 2026-09-21T06:41:16Z

@usirin

usirin commented Sep 21, 2026

Copy link
Copy Markdown
Member Author

ship: awaiting control-plane approval — PR #9635 @ c323974 → human

This PR is control-plane (4 owners resolved from CODEOWNERS at the base ref). Every machine gate at
this head is clear:

  • review-code PASS, review-doc PASS, review-skill PASS, governance PASS — all four bound to
    c323974eb76a2750381dd231fc9e7595cca70510.
  • No code-owner approval exists (0 reviews on the PR), so ship cp-approval answers stop.

The head is also 3 commits behind main. cp-approval reports that drift as a notice, not a second
outcome: the rebase is owed before an approval is solicited, because a rebase after approval
destroys it. This run did not rebase and moved no branch — the head stays where it is until a human
decides whether to move it first.

No enqueue, no merge, no deploy. Merge intent disarmed at site refuse (nothing was armed).
Terminal: AWAITING-CP-APPROVAL, cause head-behind-base.

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.

Preview workers verify logins with the same signing secret as production, so no agent can be handed a safe copy

1 participant