Lands Gizmoduck's round 3 candidate, manifest 4782c8b5ecb7 (c783d5e39on gizmoduck/row234-r3, one commit on57f59e0c3), approved by Duckworth: content on round 1 (861182fa6, 29919), the round 2 rebase check (29928, rev 2670) and round 3 (REVIEW-r3.md7c8c38b69, posted 29935, rev 2683). 41 paths. Round 3 fixes the two failures in Sage's combined gate with row 233: bus schema.test.mjs now counts 18 tables and 71 triggers for the new hosts and host_grants tables, and stack-release.test.mjs T46 puts both tables in the untimed restore-guard list, since every write shares a transaction with a grant.* event. Round 3 rebuilt on the row 233 landing as one commit. 38 of the 39 round 2 blobs are unchanged and BUILD.md is append-only. Gizmoduck's singles on round 3: stack-release 196/0, schema 1/0. Evidence: Gizmoduck's slot runs, 25 of 25 mutants killed (A22b and A23 included, A9 structural), singles on r2 387/0 over 47 files. Sage's landing gate on thec783d5e39tree: 06:02:12Z to 06:15:26Z, every package except queue 2890 pass, 0 fail (15 packages; bus 539, cli 622), and all ten scripts/test-*.sh 585 pass, 0 fail. This is code only. The live stack runs from a pinned copy at schema 3i. The 3i to 3j migration runs in the 10-24 window (mos-1145), agent-run under Q173, with a backup first. Duckworth's N10 (hosts.mjs Object.hasOwn) and N12 (doc-only) go to row 235's carry list.
22 KiB
Row 234 (#436a row a, Gitea #1740): host enrollment, store records and bus schema 3i to 3j
Gizmoduck, 2026-10-21. Reviewer Duckworth, gate owner Sage. Lands by 10-25, before the .53 and .54 installs (mos-1134).
- Brief:
docs/plans/2026-10-20_host-credentials.md, section "Machine grant row a: enrollment, store records and migration 3i-3j", blob64451be5(queue rev 2617). - Design:
agents/gizmoduck/work/machine-grant/DESIGN.mdr6,f9039682eongizmoduck/machine-grant-design, approved by Duckworth (duckworth-0064, REVIEW-r6.md at8ea792256). Not reopened here. Cites "r6:" are lines of that file. - Grant event rule: #436b section 7,
fa5658941onmegavolt/row199,agents/megavolt/work/436b/DESIGN.md:431-490, file sha256 96510b83778e79a71b33d0d5f6fd0c806dfe74f6de315ff4a9f1fbe5620f6c69, approved by Duckworth (duckworth-0067). - Base: origin/refactor
8fede5fc6, with rows 199 (cb5fdb064) and 210 (f5753c0e8) landed. Branchgizmoduck/row234. Code cites arepath:lineat8fede5fc6unless they say otherwise.
Names: r6 against section 7
r6's grant.* and host.enroll.* names are body.action values on action.allowed events, not event
kinds. The events kind CHECK isn't rebuilt. Four r6 names move under grant.:
| r6 | This row |
|---|---|
host.enroll.step |
grant.host.enroll.step |
host.enrolled |
grant.host.enrolled |
host.checked |
grant.host.checked |
host.removed |
grant.host.removed |
Only the consume record carries decision: {operation: 'grant.host-enroll', decision}. Every other
guarded body names the decision grantDecision, so r6's decision field in step, enrolled and failed bodies
is grantDecision here. The actor of every guarded event is actor_role NULL with actor_run 'grant',
and body.by names the caller the broker checked.
Split (r6 section 12 Q-S4)
Row a builds:
- Schema 3i to 3j.
migrations/3i-3j.sqladdshostsandhost_grants(r6 S1, primary keys (business, host) and (business, agent, host)) and the guard trigger: anaction.allowedwhosebody.actionorbody.operationGLOBs'grant.*'aborts unless the actor is (NULL, 'grant').SCHEMA_VERSION3j, a migration test with a step that fails partway. - Grant event writer. A private session-less broker writer for guarded events.
#eventdoesn't change.recordEventrefusesreserved-eventfor the guarded namespace. - The
grantraise frame.decision.raisetakesgrantonly withcredential.mint, only from the chief (grant-chief-only), and in row a only withmethod: 'host-enroll'. Any other method refusesinvalid-requestuntil row b addshost. E1's checks, thessh-keyscanset compare before the raise transaction, and the rendered block. - Enrollment executor.
#consumeGrant, the start on the human's cli yes (X1), E2 steps 1 to 4, the installer key at<configDir>/secrets/hosts/<business>/<hostId>/installer, the failure cleanup, and the start reconcile for enrollments: theregistrystep undo, and a resolved-yes unspent enrollment endinggrant.failed interrupted(X3). hosts.checkandhosts.remove(E4), withmosaic hosts status|check|remove.tools/host/:mosaic-keys,mosaic-keys-write,mosaic-keys.sudoers,mosaic-keys.sshd.conf(E3).host.runon the store (X4 first and third bullets): it reads thehostsandhost_grantsrecords and the Approve record check, and at start the runtime givesCredentialsa reference perhost_grantsrecord. The loader refuseshostsat the top level and in a role (S1). Row 210's tests move to store fixtures.- Reader filter (duckworth-0067 N1): every grant-evidence reader in this row (status, the start
reconcile,
openGrantDecisions) filterskind = 'action.allowed' AND actor_role IS NULL AND actor_run = 'grant', with a test that anaction.refusedlook-alike and a session-written one are ignored.hosts.remove'shostLineLeftreader is one of them (duckworth-0079 M4).
Row b keeps the host method's raise binding (R4), the grant executor (X2), the grant rows of X3, X5,
Credentials.register/claim/abortClaim, revoke (section 6) and sweep. So row a has no way to make a
host_grants record outside a test fixture, and from its landing to row b's no agent holds a host grant
(r6 N9, the run sheet strips row 210's keys).
Tests in row a (r6 section 10): G1 and G5 for the host-enroll raise, G6 and G10 for the enrollment
executor, G11's enrollment cases, G14, G15, G16, G17, G18, G20, G21 (its host-line-left case reads an
event fixture until row b writes hostLineLeft), G25, the migration test, the trigger cases, the reader
filter test, and row 210's T-tests on store fixtures. Row b: G2 to G4, G7 to G9, G11's grant cases, G12,
G13, G19, G22 to G24, G26 to G28.
Early check items (duckworth-0079, REVIEW-build-split.md 10672772e on duckworth/reviews)
Moves into row a:
- M1. G4 for the
host-enrollblock (r6:240-242): a question can't forge the block, and the 4096-character limit holds with the block appended. G4 for thehostblock stays in row b. - M2. The tag function (r6:265-266), because E2 step 2 tags the installer line. G24's first sentence moves
here: names of 64 characters with
.and_give distinct tags of the fixed length. Thesweephalf stays in row b. - M3. A store-fixture test that
host.runrefuseshost-not-grantedfor no record, an unresolved decision, a decision resolved by an arbiter, the tester or an agent or not via cli, a consume record with operationgrant.host-enrollin place ofgrant.host, and an unspent decision (X4 first bullet, r6:593-596). A restart test that ahost_grantsfixture givesCredentialsa reference (X4 third bullet).
G21's host-line-left case:
- F1. The fixture goes through this row's private grant writer in the real shape:
action.allowed, actor (NULL, 'grant'),body.action'grant.failed',body.host,body.hostLineLeft. No raw SQL around the trigger. - F2.
hosts.removereadsbody.hostLineLeftfrom any guarded body, not onlygrant.failed. - F3. Decided: a guarded body that carries
hostLineLeftalso carrieshost. Row b addshosttogrant.revoke.closed, so its body is{tag, host, hostLineLeft}(r6:716 has{tag, hostLineLeft}). The reader matches onbody.host, and doesn't map tags back to hosts throughgrant.step. A direct field is one equality in SQL, and a tag lookup would add a second reader that the N1 filter also has to cover. - F4. Row b carry: "no later
sweepcleared it" (r6:403) needsgrant.sweep, which only row b writes. Row b adds the sweep clear tohosts.removewith a G21 case, and its G8 or G13 showshosts.removerefusing after a realhostLineLeftwrite, not only after the fixture. Until then row a's check is "any guardedhostLineLeftfor this host refuses".
Mutants (r6:838-883, numbered here)
Row a, killed on this row's tree:
| # | Mutant |
|---|---|
| A1 | drop the chief check |
| A2 | start the executor on a credential.mint with no grant |
| A3 | start it on an arbiter resolve |
| A3b | start it on a cli yes by someone other than the human (duckworth-0083 N8) |
| A4 | start it on no |
| A5 | skip the keyscan compare |
| A6 | compare only one key |
| A7 | write the private key into the event body |
| A8 | drop the "not yet enrolled" check in E2 step 1 |
| A9 | drop the hosts primary key |
| A10 | skip the start check for a resolved, unspent enrollment decision |
| A11 | host.run reads the business file instead of the grant record |
| A12 | build the tag without the business |
| A13 | drop from= in the writer |
| A14 | let the writer touch a line outside the tag pattern |
| A15 | drop O_NOFOLLOW on the user file |
| A16 | drop one group from the writer's refused list |
| A17 | drop the user pattern check before the path use |
| A18 | treat a privileged entry as enough without users |
| A19 | let the writer accept a sudo user not in privileged |
| A20 | trigger: drop the body.operation arm, so the consume record passes from a session |
| A21 | trigger: accept actor_role NULL with any actor_run |
| A22 | a grant reader drops the kind = 'action.allowed' or actor filter. The runner splits it: A22a drops the kind, and G21's action.refused look-alikes kill it. A22b drops the actor, which the trigger can't stand in for, because 3i-3j adds the trigger without checking old rows. Its killer is the grant-migration test "A22b, sage-1703": a session-written grant.host.removed with hostLineLeft and a grant.host-enroll row from 3i, migrated to 3j, where hosts.remove still removes the host and reconcileGrants ends nothing |
| A23 | host.run skips the Approve record check (any host_grants record is enough) |
Row b keeps the rest of r6's list: the ready check, the three hash compares, the definition check, the
install and record order, remove on a verify failure, both start sweeps, the reconcile's remote remove,
the grant.step before install, revoke leaving the record, register before issue, the abort check and
the await before issue, revoke and an unreferenced claim, sweep and any mosaic- line, revoke's host
step order, claim before consume, the resolved unspent grant decision, an async commit, hosts.revoke
through credential-verbs.mjs, the revoke marker reconcile, the upsert, the undo past grant.revoked, the
abort after step 5 and a missing bus.tester as dev.
Broker functions this row touches (sage-1677)
For row 236 (Megavolt), which rebuilds on this landing. Old lines are packages/bus/src/broker.mjs at
8fede5fc6, new lines are at the last commit that changed this file (they moved by 10 after runEnrollment
went in). The planned list is kept in this file's history (827b89ea6).
Changed:
constructor(:279): one more option,hostGrant, the objecthost-grant.mjsbuilds.recordEvent(old :1243-1251, new :1606): one more refusal. Anaction.allowedwhosebody.actionorbody.operationis agrant.*name refusesreserved-event.request()(old :1362, new :1726): two routes ahead of the sync path, adecision.raisewithgrantto#raiseGrantand thehosts.*verbs to#requestHosts. Both are async.requestHost(old :1712-1795, new :2079): the business-file lookups (old :1730-1736) become one#hostAccesscall, in the same transaction as#agent.commandsandentrycome from it.#dispatch(old :1796, new :2160) gets a fifth parameter,enroll, which only#raiseGrantpasses.decision.raise(new :2244):grantjoins the allowed keys, andgrantwithoutenroll(or the reverse) refusesinvalid-request. Thehost.sudoblock reads#hostAccessin place ofb.hostsand the role'shosts, and an undefinedtargetstill refuseshost-unknown.validArgvandvalidStdinnow run after the access check, so a malformed argv for an ungranted host refuseshost-not-grantedfirst. The enrollment block is appended to the question beforestring(question), so the 4096 cap counts it (M1). The decision's raise body carriesgrant: enroll.decision.resolve(new :2409): whenopisresolved,#approvedGrantreads the decision, and ahost-enrollgrant starts#runEnrollmentfromsetImmediate, after the transaction commits. The result addsgrant: 'started'.
New, all private but two:
#grantEvent(:657), the writer of guarded events. It runs the secret check, thenwriteGrantEvent.#approvedGrant(:667),#consumeGrant(:678).#consumeGrantsits after#approvedGrant, not next to#consumeAuthorityas planned.#hostRecord(:685),#hostAccess(:692),#grantNotice(:708),#enrollFailed(:736),#runEnrollment(:750),#openGrantDecisions(:823).runEnrollment(:745), public, a trusted entry to#runEnrollmentfor G10. No verb reaches it.reconcileGrants(:833), public, whichruntime.mjscalls once at start.#raiseGrant(:866),#hostsCaller(:886),#requestHosts(:900),#hostLinesLeft(:964).
Module level, new (:184-230): GRANT_RUN, grantName, GRANT_EVIDENCE (the N1 filter as SQL),
registrySha256, writeGrantEvent (exported, the one writer of guarded events, which test fixtures use
for F1), HOSTS_VERBS, fingerprintOf, INSTALLER_COMMENT.
Unchanged: #event, #spent, #consumeAuthority.
Other src files:
bus/src/host-grant.mjs, new: the keyscan, the installer key pair, andhosts.check'slist.bus/src/runtime.mjs: buildshostGrant, callsreconcileGrantsbefore the server starts, and adds thehost_grantsreferences beforenew Credentials(X4 third bullet).bus/src/host-run.mjs:runHosttakescommand, sent as is in place of the sudo argv, for the installer'slist.bus/src/server.mjs: thehosts.*verbs and a raise withgrantgo down the async path.bus/src/store.mjs: the 3i-3j step,SCHEMA_VERSION3j.bus/src/credentials.mjsexportsprivateDir.business/src/business.mjsandbusiness/src/hosts.mjs: the loader refuseshosts(S1).checkHostsandcheckHostGrantsare gone.cli/src/hosts.mjs, new, routed fromcli.mjsandscripts/mosaic.
Decisions taken in the build
#hostAccessbinds the agent and the host. X4 says the Approve record check passes when the decision is an approvedhostgrant with agrant.hostconsume. The check also requires the grant'sagentto equal the session's role and itshostto equal the requested host. Without that, an approved grant for one agent and host would vouch for anyhost_grantsrow that names its decision. Cost: none for row b, whose executor writes the row from the same grant.- Store fixtures are written the real way.
bus/tests/host-fixtures.mjsinserts the decision rows by SQL, but every guarded event goes throughwriteGrantEvent, so the trigger sees it (F1). An enrollment fixture writesgrant.host.enrolled, or the start reconcile reads it as interrupted. - The resolver test for host kinds is gone.
business/tests/hosts.test.mjslost its D5 case, because the loader now refuseshost.<id>credential refs. TheisHostKindfilter inbusiness/src/resolve.mjscan't be reached now. I left it in, since row b may want it back, and it's one line. - Store before Credentials. The runtime already opened the store before
Credentials. Thehost_grantsreferences depend on that order, and the comment there says so. - CLI.
mosaic hostsprints tab lines, and--jsonprints the bus rows. Usage errors exit 4 and bus refusals exit 2, asmosaic credentialsdoes. There is no enroll command: enrollment is the chief's raise. - Host tools tests live in the bus suite.
bus/tests/host-tools.test.mjsloads the writer by path through a Python harness underpython3 -B -I. The harness passes its own key dir, config dir, owner uid and user, group and sudo lookups, and runsmosaic-keysfrom a copy with stubsudoandlogger. The gate runs the bus suite, so G16 and G25 run there. The realpwd,grpandsudo -lpaths are checked on the #433 VM, not here. The test also runsvisudo -con the sudoers file andsshd -Ton the drop-in. - Writer details the design left open. The
fromfile must hold exactly one value matching[0-9A-Za-z.:/*?!_-]+(,[0-9A-Za-z.:/*?!_-]+)*, so it can't close the quote.users,privilegedandfrommust be regular, owned by the owner uid and not group or world writable, and are opened without following a symlink. Lines starting with#are comments. A refusal exits 1 with its code on stderr and in syslog.addof a tag that is already there exits 0 and writes nothing. runEnrollmentis public for G10 (duckworth-0081 B1). G10 needs a second start for a spent decision, and the resolve path can't give one: a second resolve refusesdecision-closed(broker.mjs:2416) before the executor runs. So#runEnrollmentreturns its code (enrolled,host-enrolledor the refusal), and a publicrunEnrollmentcalls it, asreconcileGrantsis public. The test checks a spent decision givesdecision-consumed, and an open or agent-resolved one givesnot-authorized, with nothing written. That second case also reaches theviaandbychecks in#approvedGrant, so A3 is no longer vacuous (N1). Who can call it (sage-1692): only code holding theBrokerobject, which is the bus process and the tests. The onenew Brokerinsrcisruntime.mjs:126, and nosrcfile callsrunEnrollment; the resolve path calls the private#runEnrollmentfromsetImmediate(broker.mjs:2445). A seat holds noBroker. It reaches the bus only overbroker.sock, whereserver.mjscalls a fixed set of methods by name (requestat :134 and :140,identity,disconnect).request()routes on the verb through the#dispatchswitch, and neither file looks a method up by name (nothis[in either), so no verb string can reachrunEnrollment.- A
sudo -lthat times out refusesuser-sudo(N2)._sudoreturns an empty answer onTimeoutExpired, which isn't the expected line, somainlogs the refusal and exits 1. - Keyscan comment (N3). A scan that can't start refuses
host-failed. One that runs and finds no key returns no lines, and the raise refuseshost-key-mismatch. The comment inhost-grant.mjsnow says so. - No tag on in-run
grant.failed(N4). r6:578 tags only theinterruptedone, so the code follows it. - Run sheet (N5). The writer refuses
config-privilegedwhen the file is missing, so each host needs an empty, root-owned/etc/mosaic-keys/privileged(0644) before the first enrollment.
Mutant results (Sage's slot, 2026-10-21)
All 25 killed: A1 to A23, with A3b added for duckworth-0083 N8 and A22 split into A22a and A22b. The
runner is mutants.py outside the repo. Each mutant gets its own session, a 420 s limit with a group kill,
and a byte restore checked against git status. None hung. Run 1 (04:24Z, on 95f966b1c) killed A1 to A22a
and stopped at A22b, which survived. Run 2 (04:43Z, on 097892741) killed A22b and A23.
- Each kill comes from a named assertion in the mutant's own test files, except A9. Dropping the
hostsprimary key leaveshost_grants' foreign key with no parent key, so the store refuses to open ("foreign key mismatch") and 28 of 29 tests fail at setup. That's a structural kill, not the S1 test. The schema can't exist without the key (sage-1709). - A22b drops the actor filter from
GRANT_EVIDENCE. The trigger can't stand in for it, because a 3i store can hold a session-written grant row that the 3i-3j step keeps. The grant-migration test "A22b, sage-1703" kills it (see the A22 row above).
Follow-up, not in row a: G18's control calls runHost directly, so nothing runs hg.check end to end
with a good pin (duckworth-0083 N9). The #433 VM run covers it.
Status at 097892741
Done: every Split item, 1 to 8, duckworth-0081 B1 to B3, duckworth-0083 N8, and all 25 mutants killed.
Single files in the slot, all exit 0, 178 pass and 0 fail by an awk sum over 23 logs: bus grant-enroll 24, host-run 24, grant-migration 6, host-tools 6, account-migration 3, chief-of-staff 18, comment-migration 5, hard-waits 13, name-migration 1, project-runtime 3, spec-migration 1, thread-migration 3, usage-migration 1, project-files 8, spec-socket 6, spec-review 12, land 17, spec 4; harness spec-tools 2, raise-domain 2; business hosts 4; cli hosts 5; seat sandbox 10.
Left: Sage runs the whole bus, business and cli suites and every scripts/test-*.sh at the landing gate,
after Duckworth approves.
Round 3 (sage-1742): two tests that count the schema
The landing gate failed packages/bus/tests/schema.test.mjs:522. It counts the tables and triggers that
schema.sql creates, and row a adds two tables and a trigger, so the counts go from 16 to 18 tables and
from 70 to 71 triggers. My singles didn't run it because I took it for a test of other rows' tables.
I grepped packages/*/tests/* for sqlite_master and schema.sql, with schema.test.mjs:517 as the
positive control. That turned up one more test that would fail: T46 in
packages/cli/tests/stack-release.test.mjs:910. It puts every schema table in exactly one of four lists, and
hosts and host_grants were in none. I'd left the stack-release tests out of the r2 singles on the claim that
they don't load the bus, but T46 reads schema.sql. Both tables go in its untimed list (Sage's ruling,
sage-1745: the message_threads pattern). Neither has an at column. Row a writes hosts at five lines,
and each runs in one store transaction with a grant.* events row, which the restore guard counts.
#grantEvent (broker.mjs:657) writes that row through writeGrantEvent (:201, INSERT INTO events at
:212).
- :737 DELETE, in
#enrollFailed, with grant.failed at :738. Both callers wrap it in a transaction, :816 and :849. - :763 INSERT, in the step 1 transaction (:754), with grant.host.enroll.step at :761.
- :783 UPDATE installer_fingerprint, in the step 4 transaction (:780), with grant.host.enrolled at :784.
- :932 DELETE, in the hosts.remove transaction (:927), with grant.host.removed at :933.
- :950 UPDATE state ready, in the hosts.check transaction (:944), with grant.host.checked at :951.
Row a writes no host_grants rows. It only reads them (broker.mjs:695, :930, runtime.mjs:103). Row b adds
the writes, and each must share a transaction with a grant.* event the same way. That goes on row
235's carry list. The other hits pin an older schema
(account, comment, name, spec, thread and usage migrations, chief-of-staff 3c) or count only their own
tables (state.test.mjs:106 reads every table but pins no list, threads.test.mjs:129).
hosts and host_grants are mutable by design, so the refused-labels loop above schema.test.mjs:517 gets no
entry for them. hosts.check updates a host to ready. hosts.remove and a failed enrollment delete the
record. Row b updates and deletes grant records. Neither table has append-only triggers. The one new
trigger, events_grant_actor, guards events. grant-migration.test.mjs tests it, and it killed mutants
A20 and A21.