0075 — A production change goes through one write tool, and only that tool holds the write credential

Why

"A production change needs a recorded human go" was written only in SMITH_POLICY.md. Smith broke it five times in two weeks (smith-review 2026-10-25, cluster B C5), for example a Lambda event-source mapping changed in prod, a merge-queue jump at 53 entries, and a GitHub variable changed before the decision about it was raised.

The first fix, agent-harness #1587, checked the command text before the shell ran it. Replayed over 30 days of Smith's real Bash history (32,357 unique commands), it refused 11, all real production changes, and nothing legitimate. But each of its five review rounds found a new way to spell a write so the check missed it: a wrapper with flags between the verb and its object, a \ line continuation, awk 'BEGIN{system(…)}', find -exec, a script written and then run, quotes that hid a (. Shell text can always be re-spelled. A credential cannot. So the gate moves from the text to the credential.

What was measured (2026-10-27, names and scopes only)

What Smith's claude child can use today. child_env drops every PropFlow secret from the child's environment, but it passes HOME and PROPFLOW_ROOT, and the child runs as the same macOS user as everything else on the mini. So it can open any credential file under HOME:

Credential Where Who / scope
AWS [default] ~/.aws/credentials IAM user gera-admin, AdministratorAccess. The child's aws commands use this one.
AWS keys in .env.local propflowai/.env.local IAM user gera-dev. The worker process loads these; the child can read the file. Not read-only: it can PutItem/UpdateItem on the prod table, has IAM write on one role's policies, and has KMS decrypt.
gh login ~/.config/gh/hosts.yml user gera-propflow, scopes repo, workflow, gist, read:org; admin on the org's repos (so variables, secrets, merge-queue jumps). The child's gh shim sends org reads and allowed writes to the App, and falls back to this user for any write outside the App's grant.
GitHub App ~/.config/github-apps/propflow-agent-smith/ (private key) installation on all org repos: contents, issues, pull_requests, statuses, workflows write; actions, administration, checks, deployments read. No variables or secrets. The private key is readable by the child, so it can mint its own token.
Vercel CLI login ~/Library/Application Support/com.vercel.cli/auth.json user g-era. Vercel has no read-only token. Also VERCEL_API_TOKEN in .env.local.
Cloudflare token ~/.config/cloudflare/token active. Its permissions could not be read with itself. blocked uses it to read the decision answer store. Whether it can WRITE that store is unverified.

The worker's LaunchAgent sets no credential. The login keychain holds Claude Code and Slack entries only.

What Smith actually runs (the same 30-day history). 1,731 aws invocations, of which about 1,697 are reads (mostly logs filter-log-events). The writes that were really executed: the 11 #1587 found (2 merge-queue jumps, 2 lambda update-event-source-mapping, 2 dynamodb update-item on the prod table, 1 gh variable set, and lambda put-function-concurrency, update-function-configuration, publish-version, update-alias), plus three its verb list did not name: ecs execute-command into a prod container, sqs start-message-move-task (a dead-letter redrive, twice) and budgets update-budget. No Vercel production deploy or promote was run in the window; Vercel use was reads (logs, inspect, ls, env ls, about 100 calls). The other ecs update-service and cloudformation deploy hits were text inside grep patterns, not runs.

The decision ledger. blocked check <id> reads the canonical answer store (Cloudflare KV state:<slug>), whose one write door is the decisions page behind Cloudflare Access, which records who clicked. But blocked resolve --answered --choice … writes a ledger row with no store read, and any session can run it (--by is free text). So a resolved row is not evidence a person said yes.

The decision

  1. [Superseded by the 2026-10-28 amendment.] (no cutover: the child keeps its credentials) Smith's claude child gets no credential that can change production. AWS: a read-only principal (not gera-dev, which can write the prod table). gh: the App's token only, and no user login the shim can fall back to. The App's private key must be out of the child's reach first: a child that can read the key mints its own token (with workflows: write) and is no longer routed through the shim, so the shim is not a boundary until the key has moved. Vercel: no CLI login and no token. The worker process keeps only what its own bookkeeping writes need, narrowed (see Open).
  2. Every production change goes through one tool, harness-prod-write. It takes an argv, never shell text, and runs it with no shell and stdin closed, so what runs is exactly what was approved. There is nothing to re-spell.
  3. [Superseded by the 2026-10-28 amendment.] The go names the exact change. harness-prod-write plan -- <argv> prints a fingerprint (a hash of the argv, the cwd, the env keys that pick a target such as AWS_REGION and GH_REPO, and the bytes of every file the command reads). The decision is raised with blocked raise offering exactly Go: run change <fingerprint> and No: do not run it. A go for one change cannot run another. run recomputes that fingerprint from the argv, cwd, env and file bytes at run time, immediately before the exec, and refuses a mismatch, so a file edited between the go and the run is a different change.
  4. [Superseded by the 2026-10-28 amendment.] (the approver is both seats) Only a go read from the answer store counts. harness-prod-write run --decision <id> -- <argv> asks blocked check through the existing reader (decision_block_check_activity), and runs only on ANSWERED with the exact go option and no qualifying note. RESOLVED is refused: it is session-writable, and it is also how a used go looks. UNREADABLE is reported as "could not check" (exit 12), never as "no". "No qualifying note" means any non-empty note: the tool cannot tell a condition from a thank-you, so it refuses both and a person re-answers (prod_write.go_verdict). ⚠️ Today a go clicked on the /agents Decisions tab cannot pass this check. That path (propflowai api/agents/decision → relay POST /api/blocks/resolve) writes the choice straight into the ledger row as RESOLVED, and only the NOTE reaches the answer store (state:agentflow). So an /agents go reads RESOLVED and is refused, safely but uselessly; only a go clicked on the published docs decision page reads ANSWERED. Before the tool is usable on /agents: the /agents decision route also writes the choice into state:agentflow under the Access-authenticated <id>::<email> key (the same door the docs page uses), and the tool reads the store even when the row is resolved (a store-only read in blocked check). The ledger row stays a session-writable index and is never the go.
  5. [Superseded by the 2026-10-28 amendment.] (see the amendment's receipts note) One go, one run. The tool writes a receipt before the command starts (so a crash still uses up the go), a second receipt with the exit code and the sha256 and size of the output (never the output, which may hold a secret), then resolves the row with the go option. A spent go is enforced by the receipts, so after the cutover the receipts (the spent-go record) live with the privileged half in the root-owned place, not under the child's HOME. Before the cutover they sit in ~/.claude/smith-state/prod-write/, which the child can delete, so "one go, one run" holds only against an honest caller until then.
  6. What the tool covers is the measured list, not a guess: every AWS op that is not a read (the account holds stage and prod together, so every AWS write counts as production), gh variable|secret set|delete and their REST writes, a GraphQL merge-queue jump, and Vercel production deploys, promote, rollback, alias set|rm, env add|rm|update and rm. Anything else is refused by the tool (exit 10), so the tool does not become a general door to the write credential. A secret is never typed inline: gh secret set --body, ssm put-parameter --value and secretsmanager --secret-string must come from a file, because the go card and the receipt show the argv.
  7. [Superseded by the 2026-10-28 amendment.] (no cutover) The write credential lives where the child cannot read it. A file under ~miniclaw does not qualify, since the child runs as that user. The cutover puts the write credentials in a root-owned place, and the tool's privileged half runs through one sudo rule for that one binary. The privileged half does the store check itself, with its own read of the store, so the child cannot skip it.
  8. The command gate is not the boundary. #1587's plain-command parser (rounds 1 to 3) may come back as a hint ("this is a production write; use harness-prod-write"), but nothing depends on it catching every spelling.

How systems relate after this

⚠️ Superseded by the 2026-10-28 amendment, whose own How systems relate section replaces these edges.

The cutover, in stages (the exact step list goes to Gera with the go request)

⚠️ Dropped by the 2026-10-28 amendment. Do not carry out any of these stages. Kept as the record of what was decided and then withdrawn.

  1. Create a read-only AWS principal for the child, and a separate write principal for the tool. Prove both with simulate-principal-policy before using them.
  2. Point the child at the read-only principal through build_child_env (a set AWS_SHARED_CREDENTIALS_FILE/AWS_PROFILE), never by editing [default], which is Gera's own shell's. This changes the child's DEFAULT route only: the child can still read ~/.aws/credentials or pass --profile until stage 4.
  3. Install the tool's privileged half, its root-owned credentials and spent-go record, and the one door the child may use to reach it. The privileged half holds its own read-only store token, root-side, and reads the store with it; it never uses the token the child can read. Precondition: the Cloudflare token the child can read is proven read-only for the answer store, or replaced in the child's reach by a read-only one. If the child can write the store, it can write its own go and decision 4 is void.
  4. The wall. Spawn the claude child under a macOS sandbox profile (sandbox-exec) that denies reading ~/.aws, the user gh login, the GitHub App key and token cache, the Vercel login, and the write-keys half of a split .env.local (proven on this host 2026-10-27: under such a profile cat ~/.aws/config is denied and aws still runs on the read-only profile). Keep the App shim for org repos and give the child a scoped token for the two personal repos the App cannot see. This is the stage that makes the tool a wall.
  5. Narrow gera-dev (or give the worker its own principal) to the prod-table bookkeeping writes it needs.

Each stage has its own check and its own roll-back, and the tool is a door, not a wall, until stage 4.

Open

Amendment — 2026-10-28: two seats approve, no founder go, no cutover

Why this amendment exists. The 2026-10-27 "yes" that started this ADR was not Gera's. A webhook wake typed Enter into his pane at the moment the question's picker was open (the picker-Enter bug), and that keystroke was recorded as his approval. On 2026-10-28 the question was put to him again, and he said no to removing Smith's production access. He verified that no wake reached his pane, then gave the rule below. Recorded at decision time (ADR-0004).

Gera's rule (2026-10-28). Smith MAY change live data, but only when it is very certain AND both Smith and Trinity approve. No founder go.

What changes

  1. The approver is both seats, not a person. A production change runs only with two approvals of that exact change:
    • Smith's approval is its certainty check, written down. It states the exact change (the named operation, its arguments and the fingerprint harness-prod-write prints), how it was dry-run or will be read back before the write, and how Smith will verify the result afterwards. A change with any of the three missing is not approved.

    • Trinity's approval is an independent yes on that exact fingerprint. It arrives through the existing bridge protocol (agent_harness.bridge_protocol, the #engineering-bridge rail ADR-0071 uses for its critique cross-review). It is a parsed, fixed-shape bridge message naming the fingerprint and the decision it is given on, from Trinity's bot user as Slack authenticated it.

    • What the tool verifies about Trinity's message, from the Slack event, never from its text:

      • the author is Trinity's bot user id: the slack_bot_uid of her row in agent-harness config/agents.toml;
      • it was posted in the bridge channel (SLACK_BRIDGE_CHANNEL), as a reply in the thread of Smith's request message for this change, so the approval and the request it answers stay together for audit;
      • it parses as the fixed approval shape;
      • it names this change's fingerprint and decision.

      A message in that shape is not an approval if it comes from any other author (a human typing the format, or Smith itself), or if it sits anywhere other than the request's thread.

    • A free-text claim never counts. That covers a reply saying "Trinity approves", a Smith summary of what Trinity said, or a verdict in a cross-review thread (which is advisory by design). The ADR-0071 cross-review is the model for asking Trinity; the approval is its own record.

  2. The founder go is removed. GO_WRITERS (Gera's address, widened to the three founders by agent-harness #1688) no longer approves a production change, and a click on the docs decisions page or /agents is not a go. The answer store and its readers stay (local-bin #686 blocked answer, propflow-docs #378, propflowai #22911), because they are useful for other decisions, but harness-prod-write stops reading them for its go.
  3. Until Trinity can approve, nothing runs. Trinity's seat needs an update before it can receive the request and send a bridge approval; Fede does that. Until then, every production change Smith wants is reported instead: what it wanted to change, its certainty check, and that it is waiting for Trinity. Nothing is run, and no other path stands in.
  4. The credential cutover is dropped. Stages 1–5 above (the read-only principal, the privileged runner under a separate user, the sandbox wall, narrowing gera-dev) are not going to be carried out.
    • Closed PRs: agent-harness #1669 (the runner's checks) and #1696 (the ssh part of the wall) are closed.
    • Code to delete: the dark wall code already on main (child_sandbox, agent-harness #1674 and #1689) is deleted in one cleanup PR.
    • What that leaves: the write credential is NOT moved out of the child's reach. So the two-seat approval is a rule the tool enforces for changes made through the tool. It is not a wall against a child that runs a write command directly. The text above that says "a write fails with AccessDenied" no longer describes the plan.
  5. What stays from the original decision.
    • Named operations: every production change is a named operation of harness-prod-write, an SDK call or a literal argv, never shell text.
    • Fingerprint: the fingerprint covers exactly what is called.
    • One approval, one run: a run is spent by an fsynced receipt written before the change starts.
    • The block id is in the approval text. The approval names the decision it was given on, so a spent approval cannot be re-spent under another spelling of the same decision id. This is the alias fix found in #1669's review. #1669 closed unmerged, so this code is NOT on main: part 2 re-implements it, and its tests must include the "B00000001" and "b00000001\n" aliases of a spent approval.
    • The boot self-check (agent-harness #1663, merged and live: the worker writes its stamp at every boot) and its catalog signal.
  6. The once-only receipts stay where the child can delete them. With no cutover, they stay in ~/.claude/smith-state/prod-write/, under the same macOS user as Smith's child. So "one approval, one run" holds only against an honest caller. A run whose receipts were deleted could be approved again, but it still needs both seats' approval of that exact change a second time.

How systems relate after this amendment

Open

Amendment — 2026-10-29: either Gera's go, or both seats

Why this amendment exists. On 2026-10-29 (about 00:40Z), with the two-seat approval merged (agent-harness #1711), Gera widened the rule. His words: "it should be either my go or if Trinity and Smith both agree … like i can do it, or they both agree can do it." Recorded at decision time (ADR-0004). Gera is the decider.

The rule. A production change made through harness-prod-write runs on EITHER of two approvals of that exact change. Smith's certainty record is required on BOTH, because Gera's rule is that Smith must be very certain.

  1. Path A: Gera's go.

    • What counts: Gera's own answer of the exact go option for that fingerprint (Go: run change <fingerprint>) on that decision, read from the decision answer store, where Cloudflare Access recorded who answered (the docs decision page, blocked answer). The writer must be Gera's address, and only his.
    • What never counts:
      • another founder's answer (Fede, Sean);
      • an agent's or a service token's answer;
      • a ledger row merely RESOLVED (any session can write one);
      • free text anywhere, including a Slack message saying "Gera said go".
    • Qualified answers: an answer carrying a note is refused, because the tool cannot tell a condition from a thank-you. A go older than 24 hours is refused.
    • This narrows the 2026-10-27 go. That go was widened to the three founders by agent-harness #1688. Path A is Gera alone.
  2. Path B: both seats. Unchanged from the 2026-10-28 amendment:

    • Smith's certainty record;
    • Trinity's independent BRIDGE APPROVAL v1 from her bot user, in the request's bridge thread, naming that fingerprint and decision.
  3. Smith's certainty record, on both paths. harness-prod-write file requires it before anything is filed:

    • the exact change and its fingerprint;
    • how it was dry-run or will be read back;
    • how Smith will verify the result afterwards.

    On path A, Gera answers a decision whose card carries that record. On path B, Trinity is asked with it.

  4. Same binding and spending on both paths.

    • Either approval names the fingerprint AND the block id, compared exactly, so an aliased id ("B00000001", "b00000001\n") cannot re-spend it.
    • A run is spent by the same fsynced receipt, and by the ledger's execution record. One approval, one run, whichever path gave it.
    • The 2026-10-28 amendment's stated limit applies to both: the records sit where Smith's child can write them, so this holds against an honest caller only.
  5. How a person stops a change. Withdrawing the decision stops it on both paths: run refuses a withdrawn decision under its lock. Gera did not ask for a separate "No" veto, so none is added. An answer other than the exact go is simply not a go.

  6. "Until Trinity can approve, nothing runs" no longer holds. Path A went live with agent-harness #1716, merged as b134ae4e: Smith's worker booted on that sha at 2026-10-29T02:31:00Z (~/.claude/smith-state/worker-boot.json) and the tool's boot self-check passed one second later. Until Trinity's seat is updated, path A is the only one that can run a change.

  7. Where these rules are enforced. In agent-harness:

    • prod_write_approval.go_verdict (path A) and prod_write_approval.verdict (path B), combined by prod_write_approval.either;
    • pinned in tests/test_prod_write_approval.py, tests/test_prod_write_cli.py and tests/test_prod_write_executor.py, each check with a revert probe (#1711, #1716).
    • Trinity's decline fails path B only. It never ends the owner or withdraws the decision, so Gera's go stays open whatever order the readings come in (#1716 round 1).
  8. Withdrawal beats an approval that already landed, on either path. harness-prod-write run reads the ledger row under its lock and refuses a withdrawn decision before it reads any approval (#1711 round 1). An approval that landed earlier does not override a later withdrawal. A row that exists but cannot be read is "could not check", never a send.

How systems relate after this amendment