0075 — A production change goes through one write tool, and only that tool holds the write credential
- Status: Accepted 2026-10-27; amended 2026-10-28 (two seats approve, no cutover) and again 2026-10-29 (either Gera's go or both seats). Read the amendments last-first: the 2026-10-29 one wins over the 2026-10-28 one, and both win over the text above. In particular, the 2026-10-28 amendment's "no founder go" (its item 2) is superseded by the 2026-10-29 path A: Gera's own go approves again, and only his.
- Date: 2026-10-27
- Deciders: recorded on 2026-10-27 as "Gera, who approved removing Smith's production-changing access". That approval was not his: the "yes" was typed into his pane by a webhook wake at the same instant (the picker-Enter bug). Re-asked on 2026-10-28, he said no, and gave the rule in the amendment. Gera is the decider of the amendment; nobody decided the original removal.
- Systems:
- prod-write (
agent-harnessharness-prod-write,src/agent_harness/prod_write.py+prod_write_cli.py): the write tool. New entry inSYSTEMS.md. - decision-blocks (
local-binblocked,agent-harnessdecision_block): where the go is recorded and read. [Superseded by the 2026-10-28 amendment.] The go no longer comes from here; both seats approve. - agent-smith (
agent-harnesschild_env.build_child_env, theco.propflow.smith-workerLaunchAgent): what Smith'sclaudechild can reach. - command gate (
agent-harnesshooks/command_gate.py): no longer asked to stop production writes.
- prod-write (
- Supersedes: agent-harness #1587 (a pre-command check on the command text), parked after five review rounds.
- Name: the tool ships as
harness-prod-write(a new tool takes aharness-*name only, agent-harness R1); review round 1 of #1619 renamed it fromsmith-prod-write. - Implemented by: agent-harness #1646 (the tool, as named operations), #1654 (the executor,
ProdWriteWorkflowinsrc/agent_harness/workflows/prod_write.py), #1663 (the boot self-check). The cutover is dropped by the 2026-10-28 amendment.
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
- [Superseded by the 2026-10-28 amendment.] (no cutover: the child keeps its credentials) Smith's
claudechild gets no credential that can change production. AWS: a read-only principal (notgera-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 (withworkflows: 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). - 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. - [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 asAWS_REGIONandGH_REPO, and the bytes of every file the command reads). The decision is raised withblocked raiseoffering exactlyGo: run change <fingerprint>andNo: do not run it. A go for one change cannot run another.runrecomputes 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. - [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>asksblocked checkthrough the existing reader (decision_block_check_activity), and runs only onANSWEREDwith the exact go option and no qualifying note.RESOLVEDis refused: it is session-writable, and it is also how a used go looks.UNREADABLEis 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 (propflowaiapi/agents/decision→ relayPOST /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 intostate:agentflowunder 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 inblocked check). The ledger row stays a session-writable index and is never the go. - [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. - 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|deleteand their REST writes, a GraphQL merge-queue jump, and Vercel production deploys,promote,rollback,alias set|rm,env add|rm|updateandrm. 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 --valueandsecretsmanager --secret-stringmust come from a file, because the go card and the receipt show the argv. - [Superseded by the 2026-10-28 amendment.] (no cutover) The write credential lives where the child cannot read it. A file under
~miniclawdoes 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 onesudorule 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. - 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 child → AWS / GitHub / Vercel edge becomes read-only for production. A write is not refused by a parser; it fails with AccessDenied, because the credential cannot do it.
- A new prod-write → decision-blocks edge: the tool reads the answer store through
blocked check, the one reader (decision-blocks), and resolves the row after the run. No third store: the receipts file records runs, not answers. - The decision driver (
DecisionWorkflow) is unchanged. It may close a dead raiser's row after its nags; for a go that means the go is used up, which is the safe direction. - Smith → person: a production change becomes a
blocked raisethat waits for a click. Ops recovery that used to happen at once (a DLQ redrive, a Lambda alias roll-back) now waits for a person. That is the point of the rule.
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.
- Create a read-only AWS principal for the child, and a separate write principal for the tool. Prove both with
simulate-principal-policybefore using them. - Point the child at the read-only principal through
build_child_env(a setAWS_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/credentialsor pass--profileuntil stage 4. - 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.
- The wall. Spawn the
claudechild under a macOS sandbox profile (sandbox-exec) that denies reading~/.aws, the userghlogin, 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 profilecat ~/.aws/configis denied andawsstill 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. - 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
- [Moot after the amendment: no cutover.] The same-user problem is the whole cutover. Until stage 4, the child can read every credential file under
HOME, so the tool is a door, not yet a wall. - [Moot after the amendment: the answer store is no longer the go.] The /agents answer path does not reach the answer store (decision 4). Until the /agents decision route writes the choice into
state:agentflowand the tool reads the store for a resolved row, every go must be clicked on the docs decision page. - [Moot after the amendment.] Cloudflare token scope is unverified. If it can write the answer store, a child could write its own go. This is a precondition of cutover stage 3, not a follow-up.
- AWS
ReadOnlyAccessis broad. It reads every DynamoDB item (customer data) and includessqs:ReceiveMessage, which hides a message from consumers for its visibility timeout. The read-only policy should be a custom one: logs, describe/list/get on the services Smith investigates, and nosqs:ReceiveMessage. - Vercel has no read-only token. The child loses about 100 Vercel reads a month (
logs,inspect,ls). Either the tool grows a fixed list of read verbs that need no go, or those reads move to the GitHub deployments API. Decide at part 2. - [Moot after the amendment: no cutover.] The App's merge-queue rights. Whether the App's
pull_requests: writecan enqueue withjump: truewas not tested. Prove it at cutover, read-only first. - The worker's own writes.
automation_runandbake_registrywrite rows to the prod table from the worker withgera-dev's keys. These are Smith's bookkeeping, not production changes in this ADR's sense, and stay outside the tool. Their key must not be one the child can read.
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
- 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-writeprints), 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-bridgerail 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_uidof her row in agent-harnessconfig/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.
- the author is Trinity's bot user id: the
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.
- 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/agentsis not a go. The answer store and its readers stay (local-bin #686blocked answer, propflow-docs #378, propflowai #22911), because they are useful for other decisions, butharness-prod-writestops reading them for its go. - 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.
- 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.
- 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.
- Named operations: every production change is a named operation of
- 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
- prod-write → agent-smith (Trinity seat): new edge. The tool asks Trinity through the bridge and reads her structured approval. It no longer depends on decision-blocks for the go.
- Smith → people: a production change is announced, not asked of a founder. People can read the request and both approvals in
#engineering-bridge. Nobody's click is required, and nobody's click is enough. - The child's credentials are unchanged from the original "What was measured" table.
Open
- Trinity's side, for Fede: what her seat must run to receive a request, check the fingerprint independently, and post the bridge approval. This is written up separately as an ask for Fede.
- "Very certain" is Smith's own judgment, made checkable. The three-part certainty record can be read and challenged, and Trinity's independent yes is the second check. Neither seat can approve alone.
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.
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.
- What counts: Gera's own answer of the exact go option for that fingerprint (
Path B: both seats. Unchanged from the 2026-10-28 amendment:
- Smith's certainty record;
- Trinity's independent
BRIDGE APPROVAL v1from her bot user, in the request's bridge thread, naming that fingerprint and decision.
Smith's certainty record, on both paths.
harness-prod-write filerequires 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.
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.
How a person stops a change. Withdrawing the decision stops it on both paths:
runrefuses 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."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.Where these rules are enforced. In agent-harness:
prod_write_approval.go_verdict(path A) andprod_write_approval.verdict(path B), combined byprod_write_approval.either;- pinned in
tests/test_prod_write_approval.py,tests/test_prod_write_cli.pyandtests/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).
Withdrawal beats an approval that already landed, on either path.
harness-prod-write runreads 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
- prod-write → decision-blocks: restored, for path A only. The tool reads Gera's answer through
blocked answer, the store reader. - Where Gera can click the go: both pages count (Gera's ruling, 2026-10-29, from a measurement):
The docs decision page: writes the answer store with Gera's own Access identity as the writer.
The
/agentsDecisions tab: propflowai #22911 writes Gera's click into the answer store through the propflowai SERVER's Access service token. Measured on his real Go on the synthetic test decisionb93240038(read once withblocked answer):bywasservice-token:e97018896224507d19b4ac12a36469c4.access;- his address appeared only as the
raw_keysuffix::gera@propflowai.co, which the server writes only for his signed-in click (STORE_CHOICE_WRITERS).
#455 said this click counted, reasoning from the route's code; the reading showed it did NOT under the first implementation (it failed closed). Asked plainly with the risk stated, Gera ruled that it counts. Path A accepts exactly this shape:
byequal to that one token id AND araw_keynaming Gera, with every other binding unchanged (prod_write_approval.is_geras_writer,AGENTS_PAGE_TOKEN).- Merged as agent-harness #1731 (
b84d5565, 2026-10-29). Itsraw_keycheck is a::gera@propflowai.cosuffix match. Agent-harness #1734 (open at the time of writing) tightens it to EXACTLY<decision>::gera@propflowai.co. - Live only once Smith's worker boots on it. The executor's approval check runs inside the worker process. At 19:30Z on 2026-10-29 the worker's boot record still showed
c000e21d, the commit before #1731, so an/agentsGo was not yet read as approved there. Check~/.claude/smith-state/worker-boot.jsonfor a sha at or afterb84d5565before relying on it.
The accepted risk: anything holding the propflowai server's Access token can write a key ending
::gera@propflowai.co, and path A reads it as Gera's Go. Gera accepted this, and the harness states it as a known limit.Another admin's
/agentsclick still only resolves the ledger row, which is never a go.
- prod-write → agent-smith (Trinity seat): unchanged, path B.
- Smith → Gera: a production change is raised as a decision Gera can answer, and posted to Trinity at the same time. Whichever approval lands first runs it, once.