Squash of Megavolt's 627979a17..86963d281 on origin/megavolt/row199-on-190 (base00d4cc1f6, row 190's landing), the 37 manifest paths, build.patch 05383075, manifest 35f900fd (Duckworth approve round 1, comment 29779, rev 2540). Mutant batches 1 and 2 in agents/megavolt/work/queue-199/BUILD.md: batch 2 26 of 28 killed, M17 and M27 equivalent. docs/TOOLS.md is a 3-way merge with row 200 (9e1085816): row 199's hunk applies unchanged (patch-id d88379111e2e) and no test reads the file. Gate on9e1085816plus the patch: 15 package suites 2735 pass 0 fail, 10 scripts/test-*.sh suites 429 pass 0 fail (plus 156 node tests in the queue suite). Refactor moved to76134dfdesince, a brief touching no manifest path. No live change: the stack runs from copy4429a104funtil the 10-24 re-pin window.
16 KiB
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
286bf598cbusiness: agent identity, the publish grant, the project upstream with the D9c parse, store credential references; git.push.remote as a hard wait.a2c387dbabus: broker publish authority (checkPublish, recordPublish, publishRefused, pushCommit on the raise); the D9c parse moves tobusiness/src/vocabulary.mjs; pm.md and chief-of-staff.md list git.push.remote among the hard waits.609dc9364bus: the per-agent credential store incredentials.mjs.504336797bus: runtime wiring, the store root and the four publish entries in the op table.a18fc6cf8tasks: request calls useuseForkeyed from the session; the start probe keepsuse. The D5 test incredential-store.test.mjspins theuse(call sites tocredentials.mjs(useFor's own call) andtasks/src/adapter.mjs(the host's @sync and start probe).aa740cc71bus: the lifecycle verbscredentials.issue,credentials.revokeandcredentials.deleteincredential-verbs.mjs, the broker branch, the runtime wiring;organizationWritergets adetailonwriteand a receipt-onlyrecord.0cdace415cli:mosaic credentials status|issue|rotate|revoke|delete, the scripts/mosaic route, docs.
Deviations from DESIGN.md r6
-
Identity is optional on an agent. D8 reads as identity on every agent. Today's business files carry none, so identity stays optional and
publishrequires it (N10's load refusal). An agent without identity commits as E7 (T6). -
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 intostore, so a restart finds nothing. The test:credential-store.test.mjs, "revoke of a {file} reference". -
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 staysnot-issueduntil the issue is run again. The test: "a failed commit leaves the token on disk but not in memory". -
The D9c parse lives in
business/src/vocabulary.mjs, notbusiness.mjs. The broker readsforkProblemandforkUrl, andproject-runtime.test.mjspins the broker's import closure to files that import nothing.business.mjswrapsparseGiteaUrlwith the loader's refusal messages. The pin test names the two new imports. -
withGiteatakes 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 throughbroker.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 byrefusing(). The test:publish.test.mjs, "runtime: withGitea keys from the open ticket only". -
Role contracts.
workflow-roles.test.mjsrequires the hard waits listed inroles/pm.mdandroles/chief-of-staff.mdto 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 becauseroles/is reviewed authority. -
Delete takes a reason. Section 6 lists
delete AGENTwith no reason. The broker requiresreasonon delete as on revoke, so every receipt that ends a credential says why. -
Rotate is
credentials.issue. There are three bus verbs, not four. An issue over an entry the bus holds is recorded asrotatein the receipt and the answer. The CLI'srotatereads the status first and refuses before reading stdin when nothing is issued. -
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 humancreateis where a new agent'sstorereferences come from. -
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_VERBSandSTATUSpins); 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. -
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.mjsforks, 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, sowithGitea(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 oppublishPush, which startspublishRemotein the bus child underwithGiteaand answers{state: 'running'}. The socket polls oppublishResultevery 200 ms until it readspushedorrefused. Fork, user, source and destination come from the ticket (broker.publishTarget), never from the host's message.recordPublishalso refusesref-movedwhen the pushed ref isn't the ticket's destination. A secondpublishPushon a running ticket refusespublish-busy. The token reaches git on fd 3 only (runGitoptionpass), read once by a one-shot credential helper that answersgetfor 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 refusespublish-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 asgit-failedand 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). -
No harness tool for publish.
packages/harness/src/git-tools.mjshas push, fetch and land clients. A runner tool that calls the socket'spublishop is in packages/harness, outside the manifest scope sage-1421 set. Today a publish needs a socket client; the tool is a follow-up. -
The bind rule's
git.push.remoteclause 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), andrecordPushneedsgit.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. -
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.publishchecks the work ref's tip against the ticket's source under the lock (ref-moved,seat/tests/publish.test.mjs), andrecordPublishcheckspushCommitagain (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 withref-movedordecision-mismatch", and an end-to-end test asserts each code mid-flight incli/tests/publish-socket.test.mjs: a push after checkPublish refusesref-movedwith no request to the fork; a push recorded while the bus process pushes refusesdecision-mismatch, filed once, and the fork holds only the ticket's oid. -
Fix: rotate on a due gitea key (gizmoduck-0050, sage-1494). The CLI's
ISSUEDset (packages/cli/src/credentials.mjs) listed valid, expiring and expired, but the bus'sstatus()gives a gitea key past its rotate-by daterotation-due. Socredentials rotaterefused the one case it exists for.ISSUEDnow 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. -
The bus process refuses a boot config carrying
publishTls(M26, sage-1563). process.mjs used to droppublishTlsfrom the boot config beforestartBroker, 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 refusesinvalid-host-requestand 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"). -
M17 is equivalent:
#authorityFor's pushCommit comparison is also made by#publishFacts. A git.push.remote raise with no recorded push refusesno-push(broker.mjsraise,pushCommit = ... ?? fail('no-push')), so every publish decision carries a pushCommit.#publishFactstakessource = result.pushCommitand refusesdecision-mismatchwhen it isn't#latestPush(task_ref).commit, the same two values#authorityForcompares. 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
<dataRoot>/bus/bus.sqlite, plus its sums, modes, listing and stamps, into
<dataRoot>/backups/stack/<activation>/. Nothing under <configDir> is copied, so
<configDir>/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 <configDir> 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/.