Files
jason.woltje c771a36f4b row 234: machine grant row a, host enrollment, store records and bus schema 3i to 3j (#1740, Vikunja #436)
Lands Gizmoduck's round 3 candidate, manifest 4782c8b5ecb7 (c783d5e39 on gizmoduck/row234-r3, one commit
on 57f59e0c3), approved by Duckworth: content on round 1 (861182fa6, 29919), the round 2 rebase check
(29928, rev 2670) and round 3 (REVIEW-r3.md 7c8c38b69, 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 the c783d5e39 tree: 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.
2026-10-21 01:15:43 -05:00

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", blob 64451be5 (queue rev 2617).
  • Design: agents/gizmoduck/work/machine-grant/DESIGN.md r6, f9039682e on gizmoduck/machine-grant-design, approved by Duckworth (duckworth-0064, REVIEW-r6.md at 8ea792256). Not reopened here. Cites "r6:" are lines of that file.
  • Grant event rule: #436b section 7, fa5658941 on megavolt/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. Branch gizmoduck/row234. Code cites are path:line at 8fede5fc6 unless 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:

  1. Schema 3i to 3j. migrations/3i-3j.sql adds hosts and host_grants (r6 S1, primary keys (business, host) and (business, agent, host)) and the guard trigger: an action.allowed whose body.action or body.operation GLOBs 'grant.*' aborts unless the actor is (NULL, 'grant'). SCHEMA_VERSION 3j, a migration test with a step that fails partway.
  2. Grant event writer. A private session-less broker writer for guarded events. #event doesn't change. recordEvent refuses reserved-event for the guarded namespace.
  3. The grant raise frame. decision.raise takes grant only with credential.mint, only from the chief (grant-chief-only), and in row a only with method: 'host-enroll'. Any other method refuses invalid-request until row b adds host. E1's checks, the ssh-keyscan set compare before the raise transaction, and the rendered block.
  4. 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: the registry step undo, and a resolved-yes unspent enrollment ending grant.failed interrupted (X3).
  5. hosts.check and hosts.remove (E4), with mosaic hosts status|check|remove.
  6. tools/host/: mosaic-keys, mosaic-keys-write, mosaic-keys.sudoers, mosaic-keys.sshd.conf (E3).
  7. host.run on the store (X4 first and third bullets): it reads the hosts and host_grants records and the Approve record check, and at start the runtime gives Credentials a reference per host_grants record. The loader refuses hosts at the top level and in a role (S1). Row 210's tests move to store fixtures.
  8. Reader filter (duckworth-0067 N1): every grant-evidence reader in this row (status, the start reconcile, openGrantDecisions) filters kind = 'action.allowed' AND actor_role IS NULL AND actor_run = 'grant', with a test that an action.refused look-alike and a session-written one are ignored. hosts.remove's hostLineLeft reader 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-enroll block (r6:240-242): a question can't forge the block, and the 4096-character limit holds with the block appended. G4 for the host block 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. The sweep half stays in row b.
  • M3. A store-fixture test that host.run refuses host-not-granted for no record, an unresolved decision, a decision resolved by an arbiter, the tester or an agent or not via cli, a consume record with operation grant.host-enroll in place of grant.host, and an unspent decision (X4 first bullet, r6:593-596). A restart test that a host_grants fixture gives Credentials a 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.remove reads body.hostLineLeft from any guarded body, not only grant.failed.
  • F3. Decided: a guarded body that carries hostLineLeft also carries host. Row b adds host to grant.revoke.closed, so its body is {tag, host, hostLineLeft} (r6:716 has {tag, hostLineLeft}). The reader matches on body.host, and doesn't map tags back to hosts through grant.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 sweep cleared it" (r6:403) needs grant.sweep, which only row b writes. Row b adds the sweep clear to hosts.remove with a G21 case, and its G8 or G13 shows hosts.remove refusing after a real hostLineLeft write, not only after the fixture. Until then row a's check is "any guarded hostLineLeft for 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 object host-grant.mjs builds.
  • recordEvent (old :1243-1251, new :1606): one more refusal. An action.allowed whose body.action or body.operation is a grant.* name refuses reserved-event.
  • request() (old :1362, new :1726): two routes ahead of the sync path, a decision.raise with grant to #raiseGrant and the hosts.* verbs to #requestHosts. Both are async.
  • requestHost (old :1712-1795, new :2079): the business-file lookups (old :1730-1736) become one #hostAccess call, in the same transaction as #agent. commands and entry come from it.
  • #dispatch (old :1796, new :2160) gets a fifth parameter, enroll, which only #raiseGrant passes.
    • decision.raise (new :2244): grant joins the allowed keys, and grant without enroll (or the reverse) refuses invalid-request. The host.sudo block reads #hostAccess in place of b.hosts and the role's hosts, and an undefined target still refuses host-unknown. validArgv and validStdin now run after the access check, so a malformed argv for an ungranted host refuses host-not-granted first. The enrollment block is appended to the question before string(question), so the 4096 cap counts it (M1). The decision's raise body carries grant: enroll.
    • decision.resolve (new :2409): when op is resolved, #approvedGrant reads the decision, and a host-enroll grant starts #runEnrollment from setImmediate, after the transaction commits. The result adds grant: 'started'.

New, all private but two:

  • #grantEvent (:657), the writer of guarded events. It runs the secret check, then writeGrantEvent.
  • #approvedGrant (:667), #consumeGrant (:678). #consumeGrant sits after #approvedGrant, not next to #consumeAuthority as planned.
  • #hostRecord (:685), #hostAccess (:692), #grantNotice (:708), #enrollFailed (:736), #runEnrollment (:750), #openGrantDecisions (:823).
  • runEnrollment (:745), public, a trusted entry to #runEnrollment for G10. No verb reaches it.
  • reconcileGrants (:833), public, which runtime.mjs calls 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, and hosts.check's list.
  • bus/src/runtime.mjs: builds hostGrant, calls reconcileGrants before the server starts, and adds the host_grants references before new Credentials (X4 third bullet).
  • bus/src/host-run.mjs: runHost takes command, sent as is in place of the sudo argv, for the installer's list.
  • bus/src/server.mjs: the hosts.* verbs and a raise with grant go down the async path.
  • bus/src/store.mjs: the 3i-3j step, SCHEMA_VERSION 3j. bus/src/credentials.mjs exports privateDir.
  • business/src/business.mjs and business/src/hosts.mjs: the loader refuses hosts (S1). checkHosts and checkHostGrants are gone.
  • cli/src/hosts.mjs, new, routed from cli.mjs and scripts/mosaic.

Decisions taken in the build

  • #hostAccess binds the agent and the host. X4 says the Approve record check passes when the decision is an approved host grant with a grant.host consume. The check also requires the grant's agent to equal the session's role and its host to equal the requested host. Without that, an approved grant for one agent and host would vouch for any host_grants row 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.mjs inserts the decision rows by SQL, but every guarded event goes through writeGrantEvent, so the trigger sees it (F1). An enrollment fixture writes grant.host.enrolled, or the start reconcile reads it as interrupted.
  • The resolver test for host kinds is gone. business/tests/hosts.test.mjs lost its D5 case, because the loader now refuses host.<id> credential refs. The isHostKind filter in business/src/resolve.mjs can'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. The host_grants references depend on that order, and the comment there says so.
  • CLI. mosaic hosts prints tab lines, and --json prints the bus rows. Usage errors exit 4 and bus refusals exit 2, as mosaic credentials does. There is no enroll command: enrollment is the chief's raise.
  • Host tools tests live in the bus suite. bus/tests/host-tools.test.mjs loads the writer by path through a Python harness under python3 -B -I. The harness passes its own key dir, config dir, owner uid and user, group and sudo lookups, and runs mosaic-keys from a copy with stub sudo and logger. The gate runs the bus suite, so G16 and G25 run there. The real pwd, grp and sudo -l paths are checked on the #433 VM, not here. The test also runs visudo -c on the sudoers file and sshd -T on the drop-in.
  • Writer details the design left open. The from file must hold exactly one value matching [0-9A-Za-z.:/*?!_-]+(,[0-9A-Za-z.:/*?!_-]+)*, so it can't close the quote. users, privileged and from must 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. add of a tag that is already there exits 0 and writes nothing.
  • runEnrollment is 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 refuses decision-closed (broker.mjs:2416) before the executor runs. So #runEnrollment returns its code (enrolled, host-enrolled or the refusal), and a public runEnrollment calls it, as reconcileGrants is public. The test checks a spent decision gives decision-consumed, and an open or agent-resolved one gives not-authorized, with nothing written. That second case also reaches the via and by checks in #approvedGrant, so A3 is no longer vacuous (N1). Who can call it (sage-1692): only code holding the Broker object, which is the bus process and the tests. The one new Broker in src is runtime.mjs:126, and no src file calls runEnrollment; the resolve path calls the private #runEnrollment from setImmediate (broker.mjs:2445). A seat holds no Broker. It reaches the bus only over broker.sock, where server.mjs calls a fixed set of methods by name (request at :134 and :140, identity, disconnect). request() routes on the verb through the #dispatch switch, and neither file looks a method up by name (no this[ in either), so no verb string can reach runEnrollment.
  • A sudo -l that times out refuses user-sudo (N2). _sudo returns an empty answer on TimeoutExpired, which isn't the expected line, so main logs 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 refuses host-key-mismatch. The comment in host-grant.mjs now says so.
  • No tag on in-run grant.failed (N4). r6:578 tags only the interrupted one, so the code follows it.
  • Run sheet (N5). The writer refuses config-privileged when 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 hosts primary key leaves host_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.