# Row 199 (#1697): per-agent credential store, each agent's own git identity, publish to its fork Builder: Megavolt. Brief: `docs/plans/2026-10-20_agent-credentials.md`. Design: `DESIGN.md` r6 at 4d3007ec9, approved by Duckworth in duckworth-0027 (REVIEW-r6.md at 640011518). Authority model ruled by Mos in mos-1027. Branch: megavolt/row199. Reviewer: Duckworth. Status: candidate on megavolt/row199-on-190, rebased on origin/refactor 00d4cc1f6 (sage-1555). The full gate and the section 11 mutants ran in Sage's slot; results under "Runs". ## Commits so far - 286bf598c business: agent identity, the publish grant, the project upstream with the D9c parse, store credential references; git.push.remote as a hard wait. - a2c387dba bus: broker publish authority (checkPublish, recordPublish, publishRefused, pushCommit on the raise); the D9c parse moves to `business/src/vocabulary.mjs`; pm.md and chief-of-staff.md list git.push.remote among the hard waits. - 609dc9364 bus: the per-agent credential store in `credentials.mjs`. - 504336797 bus: runtime wiring, the store root and the four publish entries in the op table. - a18fc6cf8 tasks: request calls use `useFor` keyed from the session; the start probe keeps `use`. The D5 test in `credential-store.test.mjs` pins the `use(` call sites to `credentials.mjs` (useFor's own call) and `tasks/src/adapter.mjs` (the host's @sync and start probe). - aa740cc71 bus: the lifecycle verbs `credentials.issue`, `credentials.revoke` and `credentials.delete` in `credential-verbs.mjs`, the broker branch, the runtime wiring; `organizationWriter` gets a `detail` on `write` and a receipt-only `record`. - 0cdace415 cli: `mosaic credentials status|issue|rotate|revoke|delete`, the scripts/mosaic route, docs. ## Deviations from DESIGN.md r6 1. **Identity is optional on an agent.** D8 reads as identity on every agent. Today's business files carry none, so identity stays optional and `publish` requires it (N10's load refusal). An agent without identity commits as E7 (T6). 2. **Revoke of a `{file}` or `{env}` reference.** Section 6 describes revoke for store references. For a reference outside the store, revoke forgets the token in memory and never unlinks the file; the lifecycle verb then turns the business file's reference into `store`, so a restart finds nothing. The test: `credential-store.test.mjs`, "revoke of a {file} reference". 3. **Issue order.** Issue writes the token file (tmp, fsync, rename, dir fsync), then runs the caller's commit (the business file's reference becomes `store`), then sets memory. A failed commit leaves the file on disk and the token out of memory, so the agent stays `not-issued` until the issue is run again. The test: "a failed commit leaves the token on disk but not in memory". 4. **The D9c parse lives in `business/src/vocabulary.mjs`, not `business.mjs`.** The broker reads `forkProblem` and `forkUrl`, and `project-runtime.test.mjs` pins the broker's import closure to files that import nothing. `business.mjs` wraps `parseGiteaUrl` with the loader's refusal messages. The pin test names the two new imports. 5. **`withGitea` takes the publish ticket, not a cap (D9b).** The key is the session checkPublish checked with `#agent` (a run holding its claim; never a tester, reader or human), read through `broker.publishSession(ticket)`. The token is usable only between checkPublish and recordPublish or publishRefused. Its refusals are filed once, by publishRefused on the task's trail, and not also by `refusing()`. The test: `publish.test.mjs`, "runtime: withGitea keys from the open ticket only". 6. **Role contracts.** `workflow-roles.test.mjs` requires the hard waits listed in `roles/pm.md` and `roles/chief-of-staff.md` to equal GATED_ONLY. Adding git.push.remote to the vocabulary made it fail, so both lists now name git.push.remote. Prose only; no hash pin reads either file. Duckworth should review this hunk because `roles/` is reviewed authority. 7. **Delete takes a reason.** Section 6 lists `delete AGENT` with no reason. The broker requires `reason` on delete as on revoke, so every receipt that ends a credential says why. 8. **Rotate is `credentials.issue`.** There are three bus verbs, not four. An issue over an entry the bus holds is recorded as `rotate` in the receipt and the answer. The CLI's `rotate` reads the status first and refuses before reading stdin when nothing is issued. 9. **Issue covers entries the bus loaded.** The business file must already name the kind for the agent, and the bus must have loaded that reference at start; otherwise `credential-unknown`. An agent added after the bus started needs the restart row 165 already asks for. Row 194's human `create` is where a new agent's `store` references come from. 10. **No Console page yet.** Section 6 puts the verbs in the Console's agent page too. This branch has the bus verbs and the CLI. The Console page is a separate piece (packages/webui, its `BUS_VERBS` and `STATUS` pins); I raise the split with Sage before the manifest. Ruled in sage-1421: the Console page is out of row 199 and becomes a follow-up row with Webby as reviewer. The manifest holds bus, business, tasks, cli and seat. 11. **The push runs in the bus child, and the git socket polls for it (D9b).** The git socket runs in the host process. The broker, the runtime and the token are in the bus child that `packages/cli/src/host.mjs` forks, and host ops cross that IPC one at a time with a 10 s answer limit. ls-remote and the push can run longer than that, so `withGitea(ticket, fn)` can't wrap a host-side git. The split: the socket runs D9 steps 3 and 4 (tip and identity under the lock, `working.publish`), then op `publishPush`, which starts `publishRemote` in the bus child under `withGitea` and answers `{state: 'running'}`. The socket polls op `publishResult` every 200 ms until it reads `pushed` or `refused`. Fork, user, source and destination come from the ticket (`broker.publishTarget`), never from the host's message. `recordPublish` also refuses `ref-moved` when the pushed ref isn't the ticket's destination. A second `publishPush` on a running ticket refuses `publish-busy`. The token reaches git on fd 3 only (`runGit` option `pass`), read once by a one-shot credential helper that answers `get` for https on the fork's exact host and port. On close, a running publish's git isn't awaited; git's own time limit ends it, and the ticket goes with the process. The 10 s IPC limit stays as it is (sage-1440). The poll has a bound (sage-1440): `publishDeadlineMs`, by default four times git's limit and grace (publishRemote runs at most four git commands) plus 30 s. Past it the socket refuses `publish-timeout`, filed once by publishRefused, and logs that the bus process's push may still reach the fork. A failure that is not a refusal is filed as `git-failed` and logged on the bus's stderr by error name and code only. Tests: `seat/tests/publish.test.mjs` (11), `bus/tests/publish.test.mjs` (the process ops), `cli/tests/publish-socket.test.mjs` (7, end to end through the socket, the in-process runtime, the store and a TLS fake Gitea; one is the publish-timeout outcome). 12. **No harness tool for publish.** `packages/harness/src/git-tools.mjs` has push, fetch and land clients. A runner tool that calls the socket's `publish` op is in packages/harness, outside the manifest scope sage-1421 set. Today a publish needs a socket client; the tool is a follow-up. 13. **The bind rule's `git.push.remote` clause has no test of its own.** D11 adds it and the launcher has it. A role can publish only a push its own role made (`work-not-yours`), and `recordPush` needs `git.push.working`, which already binds the socket. No role reaches the clause alone, so dropping it changes no outcome today. It matters once a role can publish work it didn't push. Ruled in sage-1440: keep it; its mutant goes in the section 11 list as equivalent, with this reasoning, and Duckworth judges it. 14. **A push between checkPublish and the git push refuses; it never lands a different commit.** T3 says such a push "doesn't change the commit that lands". Under the hard wait it refuses: `working.publish` checks the work ref's tip against the ticket's source under the lock (`ref-moved`, `seat/tests/publish.test.mjs`), and `recordPublish` checks `pushCommit` again (`decision-mismatch`, `bus/tests/publish.test.mjs` "r4 B4"). The fork gets only the ticket's oid either way. N9's routing-commit test still applies. Ruled in sage-1440: T3's wording in DESIGN.md now reads "refuses with `ref-moved` or `decision-mismatch`", and an end-to-end test asserts each code mid-flight in `cli/tests/publish-socket.test.mjs`: a push after checkPublish refuses `ref-moved` with no request to the fork; a push recorded while the bus process pushes refuses `decision-mismatch`, filed once, and the fork holds only the ticket's oid. 15. **Fix: rotate on a due gitea key (gizmoduck-0050, sage-1494).** The CLI's `ISSUED` set (`packages/cli/src/credentials.mjs`) listed valid, expiring and expired, but the bus's `status()` gives a gitea key past its rotate-by date `rotation-due`. So `credentials rotate` refused the one case it exists for. `ISSUED` now names every state status() gives a held token. The test: `cli/tests/credentials.test.mjs`, "rotate goes ahead on each held state". Its mutant (the old set) fails that test and goes in the section 11 list. 16. **The bus process refuses a boot config carrying `publishTls` (M26, sage-1563).** process.mjs used to drop `publishTls` from the boot config before `startBroker`, and no test could see the drop. The host's boot config (`packages/cli/src/config.mjs`) never carries it; it is the tests' scratch CA for an in-process runtime only. A boot that carries it now refuses `invalid-host-request` and the process exits 2, rather than trusting it or dropping it quietly. The test: `bus/tests/publish.test.mjs`, "the bus process answers the publish ops from their own lines". Every test file that reads process.mjs ran singly, green (see "Runs"). 17. **M17 is equivalent: `#authorityFor`'s pushCommit comparison is also made by `#publishFacts`.** A git.push.remote raise with no recorded push refuses `no-push` (`broker.mjs` raise, `pushCommit = ... ?? fail('no-push')`), so every publish decision carries a pushCommit. `#publishFacts` takes `source = result.pushCommit` and refuses `decision-mismatch` when it isn't `#latestPush(task_ref).commit`, the same two values `#authorityFor` compares. The check stays as defense in depth, for a decision whose context lacks pushCommit, which no code path writes today. Duckworth judges it, as with M27. ## N9, for the routing commit (sage-1388) Today git.push.remote is a hard wait, so every publish carries a decision, and the decision's `pushCommit` fixes the commit. If a later commit routes git.push.remote within role, a publish has no decision and no `pushCommit`. `#publishFacts` then takes the commit from `#latestPush`, and the git socket's tip check under the lock is the only thing that holds it. That commit needs a test that a push between checkPublish and the git push doesn't change what lands. ## R1, the backup set Checked at 504336797. Row 185's `backup_store` (`scripts/stack-release.sh:372-392`) copies only `/bus/bus.sqlite`, plus its sums, modes, listing and stamps, into `/backups/stack//`. Nothing under `` is copied, so `/secrets/agents` isn't in the backup set. `rehearse` copies the live business file through `R_BUSINESS_JS` (`:1268-1288`). Its `swap` replaces every credential reference, a `store` one included, with a placeholder file in the rehearsal's own dir. The guard then refuses `credential-ref-live` unless every reference is such a placeholder (`:1329-1337`), so a rehearsal never reads a store token. A backup tool outside this repository that copies `` would copy tokens. That is R1's open half, and it is Jason's to check on each VM. ## T1 limit A wrong owner on a store dir or file can't be set up without root, so T1 covers modes, symlinks and dirs. The owner check (`st.uid !== process.getuid()`) is in `privateDir` and `#read`, and is untested. ## Runs Single files, each green on its commit: bus broker, decision-close, decision-route, decisions, hard-waits, land, project-runtime, launch-notice, review, publish, credential-store, credentials, credential-status, limits, tester, plugins-verb, routes-verb, limits-runtime, instances-verb, web, usage-runtime, limit-checks, daily-quota-runtime, chief-of-staff, tasks, tester-writes; every business test file. Since: credential-verbs (2/2), credential-store (11/11, with D5), each tasks file, tester, credential-status, plugins-verb, routes-verb, instances-verb; business launch-settings, plugin-settings, instance-settings, token-limit-settings; cli credentials (3/3), cli, app, tester-reads, tester, other-tree, transport, instances, limits, verbs. Hash pins: none on a changed file (`thread-migration.test.mjs:46` pins DDL only). Publish, single files, each green before its commit: seat checkout (77), working (26), publish (11), landing (8); bus process (8), land (17), publish (12), credential-verbs (2); cli publish-socket (4), git-socket (24), launcher (29); harness tools (22). Hash pins: none on a changed file. sage-1440 changes (the poll bound, the mid-flight tests), single files, green before the commit: cli publish-socket (7), git-socket (24), launcher (29). Hash pins: none on a changed file. The rotation-due fix, single files: cli credentials (4), green; the old-set mutant fails it 3/1. Hash pins: none on a changed file. Slip, 2026-10-20 about 19:51Z: I ran `node --test packages/tasks/tests/*.test.mjs` in one command, which is a package suite, without asking Sage for the slot. It passed, 97 pass and 0 fail, in under a minute. Reported to Sage in megavolt-0017. Rebase (sage-1555): the 21 commits from megavolt/row199 sit on origin/refactor 00d4cc1f6 as the new branch megavolt/row199-on-190 (no force-push). `git range-diff` shows all 21 patches as `=`; the merge of 12c85ada1 flattened with an empty remerge-diff. Full gate, Sage's slot, on c6868ba41 (base 00d4cc1f6), logs started 2026-10-20T22:55:44Z: bus 476 pass 0 fail; business 191/0; tasks 97/0; cli 522/0 (472 s); seat 163/0; scripts/test-queue.sh 156/0; test-auth, test-conductor, test-config, test-discord, test-extension-package, test-foundation, test-land-work, test-release and test-task each exit 0. The test-task compose run left the network megavolt-row199_default with 0 containers; I removed it. Section 11 mutants, batch 1, on c6868ba41, 2026-10-20T23:09:03Z to 23:09:56Z, restores verified and packages/ clean: 22 of 28 killed; M27 survived, expected equivalent (deviation 13). Five expected kills survived, each a test gap: M05 (the token in the push URL), M06 (revoke unlinks before it drops memory), M12 (http.followRedirects=false dropped), M17 (#authorityFor's pushCommit check dropped) and M26 (publishTls reaches startBroker). Launchpad's stack-release single file ran alongside from 23:09:28Z, so batch 2 reruns all 28 (sage-1563). Fixes, each checked by its mutant alone: 5fa7b2553 seat publish, a test reads git's argv through a PATH shim and finds no token and no userinfo (M05 killed); the 302 test's target shares the CA git trusts, so only http.followRedirects=false keeps git off it (M12 killed). 116eeec48 bus, credential-store: revoke's unlink fails (dir 0500) and the token is already out of memory (M06 killed); process.mjs refuses publishTls, deviation 16 (M26 killed, its anchor is now the refusal line). M17 is equivalent, deviation 17. Single files after the fixes, each green: seat publish (12), bus credential-store (12), bus publish (12), bus process (8), and every other file that reads process.mjs: bus credential-status (4), limit-checks (16), land (17), tasks (9), end-launch (7); cli trackers-boot (1), thread-launch (11), publish-socket (7). Hash pins: none on a changed file. Section 11 mutants, batch 2, all 28, on 7c3cdd940 in Sage's slot (sage-1565), 2026-10-20T23:17:59Z to 23:18:48Z, restores verified and packages/ clean: 26 killed; M17 and M27 survived, both expected equivalent (deviations 17 and 13). Each of the five batch 1 survivors but M17 now fails its test. Then pkg-bus 477 pass 0 fail, pkg-seat 164/0, pkg-cli 522/0 (461 s), ending 23:26:40Z. No other seat's run was active: the busy check said CLEAR before the batch started. Logs: /mnt/storage/scratch/megavolt-r199/batch2-20261020T231650Z/.