agents: Gizmoduck machine-grant design draft r1 (Vikunja #436, Q188, mos-1115)

This commit is contained in:
jason.woltje committed 2026-10-20 17:14:34 -05:00
1 parent f41729178e
commit f6b6a04b60
1 file changed
+443
@@ -0,0 +1,443 @@
# Machine grant path, design draft r1 (Vikunja #436, row 210 follow-up)
Owner: Gizmoduck. Reviewer: Duckworth. Sizing and row assignment: Sage. Design only, no code until Sage
confirms the row and owner (mos-1115).
Sources: Q188 (DECISIONS.md:1642, jason-master-restart 8ecf252), mos-1115, row 210 DESIGN r3 (e021dfa26) and
its approved candidate 455964f23. Code cites are `path:line` at origin/refactor f41729178 unless they say
"210:", which means the row 210 candidate 455964f23 (the same code as the branch head a2fd30390).
## 0. What Q188 asks for, and what this draft builds
Q188 says no agent creates a grant. A request goes up the chain, only the chief raises it to Jason's inbox,
Jason clicks Approve or Deny, and on Approve protected stack code creates the grant. Revoke stays instant, by
Jason, Mos or the tester. mos-1115 puts machines first: issue the key, install it on the host with
`from="<stack host>",restrict`, pin the host fingerprint, and on revoke drop the key and cut the connection.
This replaces row 210 section 4, where Jason hand-edits three layers, runs `issue`, and ops-01 pastes the
`authorized_keys` line.
This draft covers:
- host enrollment, once per host (section 3)
- the grant request and its decision (section 4)
- the executor that runs on Approve (section 5)
- revoke (section 6)
- how row 210's per-call decision becomes manual mode (section 7)
Gitea and GitHub grants follow the same executor shape and are a later draft (section 11).
One thing up front, because it changes what Jason sees on 10-27. A grant made this way still doesn't let an
agent run a command without a click. Under PRD 1.7 `host.sudo` is a hard wait, and every `host.run` needs its
own decision (210 D4). "Runs without a click" needs REQ-CRED-4 in PRD 1.8 (Vikunja #434, Honker through Sage)
and the reviewed commit that routes `host.sudo`. This path and that routing are separate pieces, and the
first is useless for Q188's goal without the second.
## 1. Facts from the code
- F1. **No chain of command exists in the data.** A role definition has only `roleVersion, name, title,
contract, tools, network, authority, credentials, messaging, skills` (`business/src/role.mjs:19-20`). There
is no `reportsTo` or manager field. The one fixed rank is the chief, the definition named
`chief-of-staff` (`bus/src/broker.mjs:157`), and the broker already checks an instance's definition against
it (`broker.mjs:593`, `:1990`). Who may message whom is the only structure, `#checkMessaging`
(`broker.mjs:617-627`).
- F2. **Any agent may raise any action.** `decision.raise` stores `raised_by_role` and `raised_by_run`
(`broker.mjs:1703-1708`). No rule limits which role raises which action, and no raise names another agent
as its beneficiary.
- F3. **A hard wait always routes to the human.** Any action outside `ROUTED` is in `GATED_ONLY`
(`business/src/vocabulary.mjs:88`), classifies `gated` (`broker.mjs:597-609`) and routes to `human`
(`broker.mjs:1677-1680`). A tester thread can't route to the human (`broker.mjs:1683`).
- F4. **Resolving a decision runs nothing.** `decision.resolve` records a `decision_events` row and messages
the raiser (`broker.mjs:1752-1828`). An effect happens later, when a verb consumes the decision through
`#consumeAuthority`, which refuses `decision-consumed` if an `action.allowed` event other than the raise
already cites it (`broker.mjs:808-831`). The landing path is the only place where stack code reacts to a
resolution, and even there the agent's own `land_work` call does the work (`cli/src/launcher.mjs:555-600`).
- F5. **The human proof.** `mosaic decide` resolves through `human-cli.mjs` (`cli/src/cli.mjs:142-175`,
`cli/src/transport.mjs:55-75`). `verifyHuman` is a same-UID cooperative check (`bus/src/human.mjs:50-52`),
and `#human` needs `via === 'cli'` and `outsideAgent` (`broker.mjs:375-378`).
- F6. **The business file has one writer discipline.** `organizationWriter.write`
(`business/src/organization.mjs:177-219`): a lock dir, a revision that is the sha256 of the current bytes,
`previous.json`, tmp, fsync, rename, and receipts in `businesses/.history/<id>/changes.jsonl`. The bus child
does the write (`cli/src/host.mjs:215`, `bus/src/process.mjs:1-3`). Agent-reachable keys are
`ORGANIZATION_KEYS` and `INSTANCE_KEYS` (`broker.mjs:146-148`). Row 210's `hosts` keys sit outside both.
- F7. **Row 210's pieces this path reuses:**
- the agent key store at `<configDir>/secrets/agents/<business>/<agent>/host.<id>` (210:
`bus/src/credentials.mjs:153-157`)
- `issue`, which writes tmp, fsync, rename and then calls `commit` (210: `credentials.mjs:243-280`)
- `revoke`, which drops memory, aborts the call in flight and unlinks the file (210:
`credentials.mjs:284-298`)
- pinned `known_hosts` built from the registry's `hostKeys` with `StrictHostKeyChecking=yes` (210:
`bus/src/host-run.mjs:36`, `:170`)
- F8. **Nothing on refactor generates keys or contacts a host.** No `ssh-keygen` or ssh spawn exists in
`packages/*/src`. Row 210 adds the ssh spawn, and row 210's tests already require `ssh-keygen` on the
machine (210 test list, first test).
- F9. **The Console can't approve today.** It shows decisions and copies the `mosaic decide` command
(`webui/src/public/bus.js:3-5`). `decision.resolve` is not in `BUS_VERBS` (`webui/src/serve.mjs:62`).
Vikunja #435 adds Approve after Q175 sign-in (#412).
- F10. **`credential.mint` is already a hard wait with no executor** (`vocabulary.mjs:38`).
## 2. Shape
```
agent --message--> chief --decision.raise host.grant--> Jason's inbox
|
mosaic decide (later: Console Approve, #435)
|
bus child grant executor (on resolve, approve only)
keygen -> install via host installer -> verify -> business file -> key store
```
Revoke runs the other way: memory, then the in-flight call, then the key file, then the host line, then the
business file.
## 3. Host enrollment (once per host, human only)
Installing a key on a host needs something on the host that already trusts the stack. Nothing on refactor
holds that trust, so each host gets one enrollment. This is the step that remains for ops-01, once per
host. Per-agent installs no longer need ops-01.
### E1. What Jason enters
`mosaic hosts enroll <hostId> --address A --port P --user U --install-user I`, with the host's public keys on
stdin, one `ssh-ed25519`, `ecdsa-sha2-nistp256` or `ssh-rsa` line each. ops-01 reads them on the host
(`/etc/ssh/ssh_host_*_key.pub`) and hands them to Jason out of band. The pin comes from the host side, never
from a first connection (210 section 4). The checks are 210 D3 layer 2's: the address rules, refusing
loopback and this machine's names, the port range, the user pattern, and the key types.
The Console form for this (Q188: "Jason enters ... in the WebUI") waits on #412 like Approve does. Until
then the CLI is the only way in.
### E2. What the stack does
The bus child, on a human-only `hosts.enroll` verb:
1. writes the `hosts.<hostId>` registry entry through the grant writer (section 5, W1), with `hostKeys` set
to the stdin lines
2. generates an installer key pair with `ssh-keygen -t ed25519 -N '' -C mosaic-<business>-installer-<hostId>`
in a 0700 dir under `<configDir>/secrets/run`, the dir row 210 already sweeps at start
3. moves the private half to `<configDir>/secrets/hosts/<business>/<hostId>/installer`, mode 0600 in 0700
dirs, by the same tmp, fsync, rename steps as 210 `issue`
4. prints the installer's `authorized_keys` line for ops-01:
```
from="<stack host address>",restrict,command="/usr/local/sbin/mosaic-keys" ssh-ed25519 AAAA... mosaic-<business>-installer-<hostId>
```
No private material goes out. The secrets dir layout is new. Sage or Mos may prefer
`secrets/mosaic-stack/...`, which is the Console's (`webui/src/sso.mjs:35`). That is question Q-S2.
### E3. The host side: `mosaic-keys`
One small script in this repo, under `tools/host/mosaic-keys`, reviewed like any code. ops-01 installs it
once per host as root, mode 0755, owned by root. It is the installer key's forced command, and it reads one
request from `SSH_ORIGINAL_COMMAND`:
| Request | Effect |
|---|---|
| `list <user>` | prints each `authorized_keys` line of `<user>` whose comment starts `mosaic-` |
| `add <user> <type> <base64> <tag>` | appends `from="<stack>",restrict <type> <base64> <tag>` unless the tag exists, through tmp and rename, keeping owner and mode |
| `remove <user> <tag>` | drops the line with that exact tag, through tmp and rename |
Rules the script enforces on the host, so a stolen installer key can do no more than they allow:
- `<user>` must be in a host-side allowlist file, `/etc/mosaic-keys/users`, written by ops-01. The install
user itself is never on it.
- The script writes the `from=` value from `/etc/mosaic-keys/from`, also ops-01's. The stack can't choose it.
- Each tag matches `^mosaic-[a-z0-9-]{1,96}$`, and each type and base64 matches the same patterns as E1.
- The script never touches a line without the `mosaic-` prefix.
- The script logs each request to syslog.
The install user `I` needs write access to `<user>`'s `authorized_keys` only. A `sudoers` line for exactly
`/usr/local/sbin/mosaic-keys-write` (a root helper the script calls) is ops-01's choice. The design needs the
effect and not a particular mechanism, and Duckworth's #423 condition (duckworth-0035) applies to whatever
ops-01 runs.
This still doesn't make the installer key safe to lose. A key that can add a line for a NOPASSWD sudo user
is root on that host. Section 9 says so.
### E4. Check
`mosaic hosts check <hostId>` connects with the installer key, the pinned `known_hosts` and 210's ssh
options, and runs `list <user>`. A wrong pin exits 255 and refuses `host-key-mismatch`, as 210 does. Enrollment
is done when the check passes. `status` shows each host's pin fingerprints, the installer fingerprint and the
last check time.
`mosaic hosts remove <hostId>` is human only. It refuses while any grant names the host, then deletes the
installer key and the registry entry. ops-01 removes the installer line on the host.
## 4. The request and the decision
### R1. Up the chain
An agent that needs host access sends the chief a message, as any request to the chief goes today
(`#checkMessaging`, `broker.mjs:617-627`). There is no structured request verb in r1. F1 gives the reason:
nothing records who reports to whom, so the bus can't check a chain beyond "the chief raises it".
"Not every agent may request" (Q188) is enforced at the raise. The target agent's definition must declare
`{"service": "host"}` in `credentials` (210 D3 layer 1, a reviewed commit). An agent whose definition lacks
it can ask, but the chief's raise for it refuses `host-not-declared`. Which definitions declare `host` is
the reviewed list of agents that may hold a host grant.
A structured `grant.ask` verb (the agent files the request, the chief raises it citing the request id, and
the broker checks the pair) is the stronger form. It is question Q-S1.
### R2. The action
`host.grant` is a new action in `ACTIONS` and in neither `ROUTED` list, so it is a hard wait for every role
(F3, `vocabulary.mjs:88`). PRD 1.7 has no requirement for it. Like `host.sudo` in 210 section 5, it needs a
PRD line (section 8). It is never routed, because Q188 keeps grants with Jason.
I chose a new action over `credential.mint` (F10). `credential.mint` names no host or argv, and a test pins
the hard-wait list, so either choice changes vocabulary. The narrower name keeps a future Gitea mint
separate.
### R3. Who may raise it
The broker refuses a `host.grant` raise unless the raiser's definition is `chief-of-staff`
(`b.roles[s.role].definition === STAFF_TO`, the check at `broker.mjs:593`). Refusal: `grant-chief-only`. A tester
thread can't raise it, because it routes to the human (`broker.mjs:1683`).
### R4. What the raise binds
New raise argument `grant`, required when `action === 'host.grant'` and refused otherwise:
```
grant: { "agent": "<instance>", "host": "<hostId>", "commands": [["systemctl", "status"], ...],
"rotateBy": "YYYY-MM-DD" }
```
Checks at raise, all inside the raise transaction:
- `agent` is a role instance of this business, and its definition declares `host` (R1)
- `host` is enrolled (section 3)
- `commands` passes 210 D6's argv rules: no wildcard, no shell, no empty prefix, and the existing
refusals for `bash` and the like
- `rotateBy` is a date inside 210's rotation window
The broker appends a rendered block to the question, as 210 D4 does for `host.sudo`: agent, host id,
address, user, each argv prefix JSON-quoted, the rotate date, and whether this is a new key or a change to
an existing grant's commands. The chief can't shape that block. The whole question keeps the 4096-character
cap (`broker.mjs:126`).
The raise event body gets `hostGrant = {agent, host, commandsSha256, rotateBy, registrySha256}`.
`registrySha256` hashes the host's registry entry at raise time. If ops-01 re-pins the host between raise and
Approve, the executor refuses `decision-mismatch`, and the chief raises again.
### R5. Jason decides
`mosaic decide <id> yes|no` as today (F5). After #435, the Console's Approve calls the same
`decision.resolve` through `human-cli.mjs` behind Q175 sign-in. The executor keys on the resolution, not on
the client, so #435 needs no executor change.
## 5. The executor
### X1. When it runs
At resolve. Today a resolution runs nothing (F4), and the raiser comes back later to consume the decision.
Here the raiser is the chief, and Q188 says no agent creates the grant, so the chief must not be the one
whose call does it. The bus child starts the executor itself after the human's `decision.resolve` commits,
when all of these hold: the decision's action is `host.grant`, the route is `human`, the choice equals
`approvalChoice`, and `via === 'cli'`.
The resolve call returns at once with `{grant: "started"}`. It does not wait on ssh. Jason sees the outcome
in `mosaic hosts status` and as a message in his inbox from `system` when the executor ends.
The executor consumes the decision through the same `action.allowed` event `#consumeAuthority` writes
(`broker.mjs:808-831`), with `operation: 'host.grant'`, in a transaction before any remote step. A second
start finds it spent and refuses `decision-consumed`. One executor runs at a time per (agent, host),
through 210's busy map (210: `credentials.mjs:118`).
### X2. Steps
1. **Recheck** inside one transaction: the decision is resolved yes by the human via cli and unspent, the
agent and its definition still declare `host`, the registry entry hashes to `registrySha256`, and no
revoke of this (agent, host) is newer than the raise. Then write the consume event. Any failure is
`grant.failed` with its code, and nothing remote has happened.
2. **Key.** For a new grant, `ssh-keygen -t ed25519 -N '' -C <tag>` in a fresh 0700 dir under
`<configDir>/secrets/run`, where tag is `mosaic-<business>-<agent>-<hostId>`. For a commands change
with a live key, skip to step 5.
3. **Install.** Connect as `I` with the installer key, pinned (210's options), and send
`add <user> <type> <base64> <tag>`. If the tag exists on the host, `remove` it first, because an old line
under the same tag belongs to a key the stack no longer holds.
4. **Verify.** Connect as `<user>` with the new key through a short-lived ssh-agent (210 D7) and run `true`.
The connection proves the line, the `from=` and the pin together. A failure here runs `remove <user> <tag>`
and ends `grant.failed`.
5. **Business file.** One write through the grant writer (W1): `roles.<agent>.hosts.<hostId>.commands` set to
the decided list, and for a new key `roles.<agent>.credentials["host.<hostId>"] = {"store": true,
"rotateBy": D}`.
6. **Store.** 210 `issue(agent, "host.<hostId>", privateKey, rotateBy)` writes the key file and fills
memory. The tmp dir from step 2 is removed in a `finally`.
7. **Event and message.** `grant.applied {decision, agent, host, fingerprint, commandsSha256}` and a message
to the human and the chief. The private key appears in no event, message or log (210 D8's
`assertCleanKey` runs on every event body).
### X3. Failure and restart
The steps are ordered so a failure leaves nothing an agent can use:
| Fails at | State left | Cleanup |
|---|---|---|
| 1 | nothing | none |
| 2 | tmp dir | `finally`, and the start sweep |
| 3 | maybe a line with a key nobody holds | `remove <user> <tag>`, best effort |
| 4 | a line, no grant | `remove`, best effort |
| 5 | a line, no grant | `remove`, best effort |
| 6 | a line, a grant, no key file: calls refuse `credential-unavailable` (199 D3a) | `remove`, then a writer pass that drops layers 3 and 4 |
A best-effort `remove` that fails adds `hostLineLeft: <tag>` to `grant.failed`, and `status` shows it until
`mosaic hosts sweep <hostId>` (human or tester) succeeds. `sweep` runs `list <user>` and removes every
`mosaic-` line whose tag has no live grant.
A bus child that dies mid-executor leaves a spent decision with no `grant.applied` or `grant.failed`. At start,
the bus records `grant.failed {code: 'interrupted'}` for each such decision and runs no remote step. Jason
gets a message. The chief raises again, and Jason approves again. I chose a new decision over an automatic
retry because Jason approved one attempt at one moment, and a retry after a restart might be hours later.
### W1. The grant writer
A second writer on the same lock, revision, tmp, rename and receipt steps as `organizationWriter.write`
(F6), with its own key set:
- `hosts.<hostId>`, written by `hosts.enroll` and `hosts.remove` only
- `roles.<agent>.hosts.<hostId>` and `roles.<agent>.credentials["host.<hostId>"]`, written by the executor
and by revoke only
The receipt's actor is `grant:<decisionId>`, `revoke:<by>` or `enroll:human`. No broker verb takes these
keys from a request body. `organization.set`, `limits.set` and `instances.set` keep refusing `hosts`, which
210 T3 already tests.
This replaces 210 section 4's hand edits for layers 2 to 4. A hand edit of the business file still works,
because the file is Jason's, and the loader checks it as 210 D3 does.
## 6. Revoke
Q188: instant, by Jason, Mos or the tester. `mosaic hosts revoke <agent> <hostId> --reason R`, with
`--tester` for the tester (210 D9 already gives the tester `revoke`). Mos revokes as the tester or through
Jason's terminal. Q-M1 asks Mos which.
Order, local first so the part that matters can't wait on the network:
1. 210 `revoke`: memory, abort the `host.run` in flight (SIGKILL to the ssh process group), unlink the key
file. The call answers `credential-unavailable`. This is the "instant" part, and it doesn't touch the
network.
2. The grant writer drops `roles.<agent>.hosts.<hostId>`. The credential reference stays as a store
reference with no file, which reads as revoked (199). A new grant needs a new decision.
3. `remove <user> <tag>` on the host through the installer. If the host can't be reached, the event is
`grant.revoked {hostLineLeft: <tag>}`, and `sweep` clears it later.
Step 3 is defense in depth. After step 1 the stack holds no copy of the key. The line only matters if
something copied the key file before the revoke, which section 9's same-user limit allows.
Remote processes that the killed ssh started may keep running (210 section 6). Revoke cuts the stack's
connection, not what already runs on the host.
`hosts remove` (E4) refuses while grants exist. Revoking the installer itself is `hosts remove` and ops-01
deleting the line.
## 7. Row 210's per-call decision becomes manual mode
Row 210 lands as reviewed (mos-1115). Its D4 stays the code path: a `host.run` whose class is `gated` needs a
decision bound to host, argv hash and stdin hash, spent once.
What changes is when the class is `gated`:
- Under PRD 1.7, `host.sudo` is a hard wait, so every call is gated. That is today.
- Under PRD 1.8 with REQ-CRED-4, `host.sudo` is routed, and a definition that lists it within-role runs
granted commands with no decision (210 section 5).
- Manual mode (Q188) puts the per-call decision back. I propose a business-file key, human-only and written
through the grant writer, `"manual": ["host.sudo"]`, a list of routed actions that classify `gated` for
every role while listed. `#classification` checks it first (`broker.mjs:597-609`). A list rather than a
flag, so Jason can make host commands manual without making every routed action manual.
The manual mode key is PRD 1.8 territory. Honker drafts REQ-CRED-4 through Sage. I bring this proposal to that
draft and don't build it in the grant rows. Q-S3 asks Sage where it belongs.
## 8. PRD lines (proposal for 1.8, beside REQ-CRED-4, through Sage)
> **REQ-CRED-5.** Only the chief raises a host grant. Jason approves it, and stack code then creates the key,
> installs it on the host, and records the grant. No agent writes a grant or holds a host key's private half
> outside the store. A grant names an agent, a host and exact argv prefixes. Revoke takes effect at once on
> the stack's side, and the host line is removed when the host can be reached.
I don't edit the PRD. Sage takes this to Mos with Honker's REQ-CRED-4.
## 9. Limits, said plainly
- **The installer key is root on its host.** It can add a line for a NOPASSWD sudo user. It sits in
`<configDir>/secrets`, readable by the stack's OS user like every other key (199 D6). `mosaic-keys` limits
which users it can touch, not what those users can do.
- **The seat network isn't confined** (`seat/src/sandbox.mjs:29-31`, 210 section 6). An agent granted a
command that writes a file as root can plant its own key and ssh around the bus. `from=` doesn't help,
because the sandbox shares the stack's address. A network-confined sandbox closes it (Sage's follow-up).
- **The human proof is cooperative same-UID** (`bus/src/human.mjs:50-52`). Approve is as strong as
`verifyHuman`, no stronger. This path adds nothing weaker, but grants raise the stakes of that check.
- **Approve is not "runs without a click"** until PRD 1.8 routes `host.sudo` (section 0).
- **Enrollment still needs ops-01 once per host.** I don't see a way around it without a trust anchor on the
host, and I'd rather say so than claim the flow is hands-free.
- **The pin trusts what Jason pastes.** Whoever writes the stdin of `hosts enroll` chooses the trusted key.
## 10. Tests and mutants (for the rows, not built here)
Tests use a scratch sshd on 127.0.0.1 with throwaway keys, as row 210 does, with `mosaic-keys` running as the
scratch sshd's forced command against a scratch `authorized_keys`. No real host is contacted.
| Test | Claim |
|---|---|
| G1 | a `host.grant` raise from a role that isn't the chief refuses `grant-chief-only`, and nothing is stored |
| G2 | a raise for an agent whose definition lacks `host` refuses `host-not-declared` |
| G3 | a raise with a wildcard, shell or empty argv prefix refuses, the same cases as 210 D6 |
| G4 | the rendered block is appended by the broker, the chief's question text can't forge it, and the 4096 cap holds |
| G5 | `host.grant` classifies gated for every role and routes to `human`, and a tester thread can't raise it |
| G6 | a yes from the human via cli starts the executor, while a no, a withdraw, or an arbiter resolve start nothing |
| G7 | executor end to end on scratch sshd: line installed with the `from=` and tag, verify login works, layers 3 and 4 written with a receipt, key file 0600, `grant.applied` |
| G8 | each failure row of X3 leaves the stated state, and a failed `remove` leaves `hostLineLeft` |
| G9 | a registry change between raise and Approve refuses `decision-mismatch` before any remote step |
| G10 | a second start for the same decision refuses `decision-consumed` |
| G11 | a bus restart mid-executor gives `grant.failed interrupted` and runs no remote step |
| G12 | revoke: the call in flight dies, the key file is gone, the grant entry is gone, the host line is gone, in that order |
| G13 | revoke with the host down still does steps 1 and 2 and reports `hostLineLeft` |
| G14 | `organization.set`, `limits.set` and `instances.set` still refuse `hosts` and `manual` |
| G15 | no event, message, log line or receipt holds private key material (210 D8 check on every body) |
| G16 | `mosaic-keys`: users outside its list, lines without the `mosaic-` prefix, bad tags and bad key text all refuse, and `from=` comes from the host file |
| G17 | `hosts enroll` refuses loopback and this machine's names and bad key lines, and prints no private material |
| G18 | `hosts check` with a wrong pin exits 255, refuses `host-key-mismatch` and runs nothing |
Mutants, a first list. The row's BUILD.md numbers them and adds more:
- drop the chief check
- drop the definition check
- skip the commands hash compare
- skip the registry hash compare
- swap the order of install and the business write
- skip `remove` on a verify failure
- skip the start sweep
- drop `from=` in `mosaic-keys`
- let `mosaic-keys` touch an unprefixed line
- run revoke's host step before the local steps
- start the executor on an arbiter resolve
- start it on `no`
- write the private key into the event body
## 11. Not in this draft
- Gitea and GitHub grants. The same flow: chief raise, Jason approves, the executor mints a token through
an admin credential Jason enters, writes the reference, and revoke deletes the token at the service.
The admin scope is refused today (`vocabulary.mjs:105-106`), so that is a design of its own.
- The Console Approve (#435) and the Console token entry. Both wait on Q175 sign-in (#412).
- The network-confined sandbox (Sage's follow-up from 210 section 6).
## 12. Questions
- **Q-S1 (Sage).** Is "message the chief, and the definition gates the raise" enough for "up the chain"
in the first row? Or do you want the `grant.ask` verb (R1) now? It adds one verb and one table. I'd
start without it.
- **Q-S2 (Sage, Mos).** Where should installer keys live: `secrets/hosts/<business>/<hostId>/installer`
(my proposal) or under `secrets/mosaic-stack/`?
- **Q-S3 (Sage).** Does manual mode (section 7) go into Honker's PRD 1.8 draft beside REQ-CRED-4, and is
REQ-CRED-5 (section 8) one line Sage carries with it?
- **Q-S4 (Sage).** My suggested split, yours to size. Row a: enrollment, `mosaic-keys`, the grant writer,
and `hosts status`. Row b: the `host.grant` action, the raise binding, the executor and revoke. Row b
waits on row a and on 210's landing. Both need the PRD line before landing, as 210 did.
- **Q-M1 (Mos).** How does Mos revoke: as the tester (`--tester`) or from Jason's terminal?
- **Q-O1 (ops-01, through Mos).** Is a root-owned forced-command script with a host-side user list and
`from=` file acceptable on the hosts? If ops-01 would rather run something else on the host, the
stack-side design stays the same as long as the three requests in E3 behave as stated.