gizmoduck: T3 federation design r2 for #414: user proof bound to the Console tree outside agents (B1), named ownership changes (B2), one collaborator level with an exact verb list (B3), three revocation triggers and launcher refusal (B4), separate uid as R8's entry gate (B5)

This commit is contained in:
jason.woltje committed 2026-10-20 13:56:54 -05:00
1 parent 6925f78f0e
commit 60b9b112a1
2 files changed
+223 -101

No files matched your search

+222 -101
View File
@@ -1,24 +1,44 @@
# T3 federation design: factory and hub (Vikunja #414, G3, G4, C1.1) # T3 federation design: factory and hub (Vikunja #414, G3, G4, C1.1)
Author Gizmoduck (Opus 5.5), 2026-10-20, r1. Design only, no product code. Needs an independent review Author Gizmoduck (Opus 5.5), 2026-10-20, r2. Design only, no product code. Reviewer Duckworth (Opus 5.5,
(Filbert or Dewey) before any row goes to Sage's queue. Code lines are at origin/refactor 9ae8c73ac. The mos-0978), r1 verdict changes B1-B5 (duckworth-0001, REVIEW.md at b402197ff). R1, R2 and R5 go to Sage's
Q175 SSO design is cited at Sage's local commit 817e8f51b (agents/sage/work/q175-sso/DESIGN.md), which queue once Duckworth approves, and Sage assigns owners. Code lines are at origin/refactor 9ae8c73ac. The
was not on origin when this was written. Q175 SSO design is cited at Sage's local commit 817e8f51b (agents/sage/work/q175-sso/DESIGN.md), which was
not on origin when this was written.
Binding inputs: TARGET.md addendum 2026-10-20, DECISIONS Q175 to Q178, projects/mosaic-mvp/GAP-MAP.md and Binding inputs: TARGET.md addendum 2026-10-20, DECISIONS Q175 to Q178, projects/mosaic-mvp/GAP-MAP.md and
PLAN.md T3 (all in jason-master-restart). PLAN.md T3 (all in jason-master-restart).
## Changes from r1
- B1. A user principal now comes only from the Console's verified OIDC session. The user CLI takes the
human proof's walk, which refuses agent markers and binaries, and its direct parent must be the
Console from the bus's own tree. This needs the path-bound human proof (P19 333). Residual risk in 3.6.
- B2. 3.6 names the ownership changes: `#owner`, `#threadFor`, the `#human` gates and `thread.send`'s
sender fields. `message.send` is on no list, and `#threadFor` refuses users as a second wall.
- B3. One level for 10-27, `collaborator`, with an exact verb list and a filter per read (3.5). Viewer and
every board read wait.
- B4. Revocation has three triggers: the next user request, a 30 s broker timer, and a reload verb that
R5 calls. The launcher refuses to start a revoked thread, because the broker reports it on every
`threadsState`. It also SIGTERMs a running one (3.7). R4 now depends on R3 and R5.
- B5. A separate uid per instance is a hard entry gate of R8, with checks (mos-0984 point 2).
- N1 to N9 are handled where they apply: v1 count (2), `configDir` at business.mjs:25-28 (5), R1 takes
providers and peers as arguments (7), "human-only" (3.5), file checks (3.5), `instances` as the real limit
(3.5), provider removal needs a Console restart (3.7), Q1 is with Jason (9), client ids in R8 (7).
- Mos's rulings in mos-0978 and mos-0984 replace the r1 questions (section 9). The push credential note is
in section 10.
## 1. Summary ## 1. Summary
By 10-27, federation means a person from one instance can sign in to the other instance's Console and use By 10-27, federation means a person from one instance can sign in to the other instance's Console and use
exactly what its owner granted: a business, some projects, some agent instances, at a viewer or exactly what its owner granted: one business, some projects and its thread instance, at the collaborator
collaborator level. The receiving instance verifies that person itself through OIDC (Q175) and its broker level. The receiving instance verifies that person itself through OIDC (Q175) and its broker enforces the
enforces the grant on every request. Revoking a grant refuses the next request and stops that user's grant on every request. Revoking a grant refuses the next request and stops that user's thread runs within
thread runs. about 30 s, or at once when the grant CLI makes the change.
Instance-to-instance traffic, where an agent on the factory talks to a role on the hub, waits until after Instance-to-instance traffic, where an agent on the factory talks to a role on the hub, waits until after
10-27. Section 6 sketches it (Ed25519 instance keys and signed HTTP requests) so the 10-27 slice doesn't 10-27 unless Jason rules otherwise (mos-0984 point 3). Section 6 sketches it (Ed25519 instance keys and
block it. signed HTTP requests) so the 10-27 slice doesn't block it.
## 2. What exists today (measured) ## 2. What exists today (measured)
@@ -28,26 +48,36 @@ block it.
- F2. A business has exactly one human: `human` at packages/business/src/business.mjs:160. - F2. A business has exactly one human: `human` at packages/business/src/business.mjs:160.
`bindHuman` refuses any other name (packages/bus/src/broker.mjs:532-538). `bindHuman` refuses any other name (packages/bus/src/broker.mjs:532-538).
- F3. The human proof is a cooperative same-uid process check, not a wall. `verifyHuman` compares the - F3. The human proof is a cooperative same-uid process check, not a wall. `verifyHuman` compares the
caller's uid, start time, nonce and CLI path (packages/bus/src/human.mjs:52-71). The tester proof works caller's uid, start time, nonce and CLI path (packages/bus/src/human.mjs:52-72), then walks the parents
the same way (packages/bus/src/tester.mjs:5-9). The server takes exactly one of `cap`, `human` or and refuses agent markers, launch records and agent binaries (:73-88). The tester proof refuses only
`tester` per request (packages/bus/src/server.mjs:52-70). managed runs and allows agents by design (packages/bus/src/tester.mjs:5-9, :56-60). The server takes
exactly one of `cap`, `human` or `tester` per request (packages/bus/src/server.mjs:52-70).
- F4. Authority for agents is checked in the broker: `#checkAuthority` and `#authorityFor` - F4. Authority for agents is checked in the broker: `#checkAuthority` and `#authorityFor`
(broker.mjs:763-799), with classification at :596-609. Every one of these is about a role run. Humans (broker.mjs:763-799), with classification at :596-609. Every one of these is about a role run. Humans
get no roles. `#human` (broker.mjs:375-378) is the only human check. get no roles. `#human` (broker.mjs:375-378) is the only human check.
- F5. Threads have a single `owner` column (packages/bus/schema.sql:247). `#thread` refuses a thread whose - F5. Threads have a single `owner` column (packages/bus/schema.sql:247). `#thread` refuses a thread whose
owner isn't the caller (broker.mjs:356-362). `#owner` returns `tester` or the human (broker.mjs:364-366). owner isn't the caller (broker.mjs:356-362). `#owner` returns `tester` or the human (broker.mjs:364-366).
`#threadHolders(business, instance, owner)` already finds the held runs of one owner's threads `#threadFor` special-cases the human (:326) and checks reply chains for the tester only (`#testerOwns`,
(broker.mjs:298-306). :352-355, called at :337 and :342). `#threadHolders(business, instance, owner)` finds the held runs of
one owner's threads (broker.mjs:298-306).
- F6. The Console reaches the bus as the one human by spawning `human-cli.mjs` per call - F6. The Console reaches the bus as the one human by spawning `human-cli.mjs` per call
(packages/webui/src/bus.mjs:1-9, 27-31). Its sign-in today trusts a proxy header (Sage's design, section (packages/webui/src/bus.mjs:1-9, 27-31). Its entry is `packages/webui/src/cli.mjs serve`
"What exists today"). Q175 replaces that with an OIDC relying party that takes a `subjects` list from a (scripts/webui-service.sh:3). Its sign-in today trusts a proxy header (Sage's design, section "What exists
`console.sso` key in config.json (Sage's design sections 1 and 4). today"). Q175 replaces that with an OIDC relying party that takes a `subjects` list from a `console.sso`
key in config.json (Sage's design sections 1 and 4).
- F7. config.json is read by one loader that refuses unknown top-level keys - F7. config.json is read by one loader that refuses unknown top-level keys
(scripts/mosaic-config.mjs:112-116). The business file is writable by agents through (scripts/mosaic-config.mjs:112-116). The business file is writable by agents through
`organizationWriter` (packages/business/src/organization.mjs:83), which is why Sage put SSO config in `organizationWriter` (packages/business/src/organization.mjs:83), which is why Sage put SSO config in
config.json and not the business file. config.json and not the business file.
- F8. Bus, Discord and Console run as `systemctl --user` units (scripts/bus-service.sh:200), so every - F8. Bus, Discord and Console run as `systemctl --user` units (scripts/bus-service.sh:200), so every
instance on a host runs under Jason's uid today. instance on a host runs under Jason's uid today.
- F9. Board reads are per business, not per project or instance: `inbox`, `agents`, `tasks`, `trail` at
broker.mjs:2143-2146, `decisions` :2174, `credentials` :2195, `messages` :2217 (the Console's set is
views.mjs:3-13). `project.list` returns every project in the business (:2318-2322).
- F10. The launcher restarts a thread that has pending messages and no holder (packages/cli/src/launcher.mjs:533),
launched as the business human (:542), every 2 s (:133). Its `startedFor` guard is in memory. A claim end
stops a run only at its next bus call (broker.mjs:2273-2275). The idle stop is the one place that
SIGTERMs a thread runner (launcher.mjs:527).
### The archived v1 federation (v1/apps/gateway/src/federation) ### The archived v1 federation (v1/apps/gateway/src/federation)
@@ -59,8 +89,8 @@ and re-checks the grant row on every request. Scopes are zod-validated resource
Verdict: none of the code is reusable here. Verdict: none of the code is reusable here.
1. It is about 4,100 lines of NestJS, Drizzle and Postgres code (`wc -l`), plus a step-ca dependency. The 1. It is about 4,470 lines of NestJS, Drizzle and Postgres code without tests (9,198 with them), plus a
new stack has none of those. step-ca dependency. The new stack has none of those.
2. Inbound federation never worked. The guard depends on `requestCert: true`, but the server is built as 2. Inbound federation never worked. The guard depends on `requestCert: true`, but the server is built as
`new FastifyAdapter({ bodyLimit })` with no TLS options (v1/apps/gateway/src/main.ts:50-53). Every `new FastifyAdapter({ bodyLimit })` with no TLS options (v1/apps/gateway/src/main.ts:50-53). Every
inbound call would get 401. inbound call would get 401.
@@ -77,8 +107,8 @@ enrollment short-lived and single-use. Tag every federated result with its sourc
1. D1. Each instance names itself and its peers in config.json, so the Console can show "you are on the 1. D1. Each instance names itself and its peers in config.json, so the Console can show "you are on the
hub" and link to the factory. Peer entries carry no trust in the 10-27 slice. They are labels and URLs. hub" and link to the factory. Peer entries carry no trust in the 10-27 slice. They are labels and URLs.
2. D2. The owner of an instance grants a person access to one business, with a project list, an agent 2. D2. The owner of an instance grants a person access to one business, with a project list and an
instance list and a level. Grants live in a human-authored access file on the receiving instance. instance list. Grants live in a human-authored access file on the receiving instance.
3. D3. That person signs in to the receiving instance's Console with OIDC. The Console verifies the token 3. D3. That person signs in to the receiving instance's Console with OIDC. The Console verifies the token
itself (Q175) and matches `(provider, sub)` to an access-file user with a live grant. itself (Q175) and matches `(provider, sub)` to an access-file user with a live grant.
4. D4. The broker, not the Console, enforces the grant on every request: which verbs, which projects, 4. D4. The broker, not the Console, enforces the grant on every request: which verbs, which projects,
@@ -89,20 +119,22 @@ enrollment short-lived and single-use. Tag every federated result with its sourc
### 3.2 What waits ### 3.2 What waits
- W1. Agent-to-agent traffic between instances (section 6). - W1. Agent-to-agent traffic between instances (section 6).
- W2. Shared threads between several users (C1.4). That is T1.3's. A granted user gets private threads - W2. Shared threads between several users (C1.4), and a viewer level. Both are T1.3's. A granted user gets
only. private threads only.
- W3. Remote owners. A grant never gives owner verbs: organization, instances, routes, plugins, limits, - W3. Remote owners. A grant never gives owner verbs: organization, instances, routes, plugins, limits,
decision resolution, launch revoke and the kill switch stay with the local human. decision resolution, launch revoke and the kill switch stay with the local human.
- W4. A registry, `mosaic comms`, enrollment tokens and key rotation tooling. With two instances, - W4. A registry, `mosaic comms`, enrollment tokens and key rotation tooling. With two instances,
out-of-band setup by Jason is cheaper. out-of-band setup by Jason is cheaper.
- W5. Federated reads that merge results from both instances into one view. - W5. Federated reads that merge results from both instances into one view, and any board read for a
granted user (F9).
### 3.3 Instance identity in the slice ### 3.3 Instance identity in the slice
An instance is its public HTTPS origin plus its own OIDC client at the provider. The factory and the hub An instance is its public HTTPS origin plus its own OIDC client at the provider. The factory and the hub
are two clients, so their ID tokens carry different `aud` values. The Q175 audience check refuses a are two clients, so their ID tokens carry different `aud` values. The Q175 audience check refuses a
factory token presented at the hub, which makes tokens instance-bound by signature, not by a header. No factory token presented at the hub, which makes tokens instance-bound by signature, not by a header. This
instance key is needed until instances talk to each other (W1). holds only if the two client ids differ, which R8 checks. No instance key is needed until instances talk
to each other (W1).
### 3.4 Remote user identity ### 3.4 Remote user identity
@@ -115,10 +147,10 @@ home instance's word for who the user is.
The owner path stays as Sage designed it: `console.sso` subjects sign in as the business human. Everyone The owner path stays as Sage designed it: `console.sso` subjects sign in as the business human. Everyone
else comes from the access file. The two lists are disjoint, and the loader refuses a subject in both. else comes from the access file. The two lists are disjoint, and the loader refuses a subject in both.
### 3.5 The access file ### 3.5 The access file and the collaborator level
`<configDir>/access.json`, next to config.json, so it is per instance (business.mjs:25-27 sets configDir `<configDir>/access.json`, next to config.json, so it is per instance (`configDir` at business.mjs:25-28
from `MOSAIC_CONFIG`). It is not in config.json for two reasons. Revocation has to take effect without a reads `MOSAIC_CONFIG`). It is not in config.json for two reasons. Revocation has to take effect without a
restart, and a typo in config.json stops every tool on the instance (Sage's design, section 4). It is not restart, and a typo in config.json stops every tool on the instance (Sage's design, section 4). It is not
in the business file because agents can write that (F7). in the business file because agents can write that (F7).
@@ -135,7 +167,7 @@ in the business file because agents can write that (F7).
"user": "jason", "user": "jason",
"business": "creative-hub", "business": "creative-hub",
"projects": ["*"], "projects": ["*"],
"instances": ["researcher", "fact-check"], "instances": ["researcher"],
"level": "collaborator", "level": "collaborator",
"expires": "2026-12-31T00:00:00Z" "expires": "2026-12-31T00:00:00Z"
} }
@@ -143,57 +175,132 @@ in the business file because agents can write that (F7).
} }
``` ```
Rules the loader enforces, refusing the whole file on any failure (fail closed): Rules the loader enforces, refusing the whole file on any failure (fail closed). The loader takes the
provider ids, peer ids and declared businesses as arguments, so it stays pure:
- The file is a regular file, not a symlink, owned by the instance's uid, with no group or other write.
These are the checks Sage's loader runs on the secret slot.
- Unknown keys refuse. `accessVersion` is 1. `revision` is a positive integer. - Unknown keys refuse. `accessVersion` is 1. `revision` is a positive integer.
- A user id matches the business id rule and is never `human`, `tester`, or any business's `human`. That - A user id matches the business id rule and is never `human`, `tester`, or any business's `human`.
keeps a granted user from owning threads as the owner or the tester (F5). - `subjects[].provider` names a given provider. A `(provider, sub)` pair appears once in the file and
- `subjects[].provider` names a provider in `console.sso`. A `(provider, sub)` pair appears once in the never in `console.sso.subjects`.
file and never in `console.sso.subjects`. - `home` names a given peer, or is `local` for a non-federated extra user.
- `home` names a peer in `federation.peers`, or is `local` for a non-federated extra user.
- A grant names a declared business. `projects` is `["*"]` or declared project ids. `instances` is - A grant names a declared business. `projects` is `["*"]` or declared project ids. `instances` is
`["*"]` or declared instances. `level` is `viewer` or `collaborator`. `expires` is optional ISO time. `["*"]` or declared instances. `level` is `collaborator`, the only level in 10-27. `expires` is optional
ISO time.
- A grant past `expires` is treated as absent. - A grant past `expires` is treated as absent.
- A file that fails any rule leaves every user with no grant, which revokes everything. That is the safe
direction.
Levels, as templated human roles. A grant can only narrow them through `projects` and `instances`: The collaborator level is an exact verb list. Every other verb fails `access-refused` before the verb's own
checks. That covers all owner verbs (W3), every board read (F9), `message.*`, `decision.*`, project files,
`spec.read`, `assistant.stop`, `launch.*`, `name.set` and `usage.*`.
| Level | Allowed | | Verb | Today | For a user |
|---|---| |---|---|---|
| viewer | Read the board, projects and instances it is granted. Read its own threads. No sends. | | `project.list` | every project in the business (:2318-2322) | granted projects only |
| collaborator | Viewer, plus create its own threads in granted projects on granted instances, send in them, read replies. | | `thread.create` | the business's one thread instance (:2331-2334) | project granted, and that instance in the grant's `instances`, else `access-refused` |
| `thread.list` | owner filter (:2351-2362) | owner `user:<id>`, and only threads whose project and instance are still granted |
| `thread.open` | `#thread` owner check (:2365-2371) | same, plus the thread's project and instance still granted |
| `thread.send` | `#thread`, sender `human`, writes `human.input` (:2374-2398) | same grant check. Sender `from_role` `user`, `from_run` `user:<id>`, no `human.input` |
| `thread.messages` | `#thread` (:2401-2420) | same grant check |
| `names` | business-wide (:2288-2295) | granted instances only |
Writes go through a human-only CLI verb (row R5), atomically with a revision bump and one receipt line `human.input` events are the owner's authority evidence (the `input` view, broker.mjs:1203-1207). A user's
in `<dataRoot>/state/access-log.jsonl` (append-only, as `activation-log.jsonl` is). Jason may also edit the message must never write one, the same rule the tester follows at :2398.
file by hand. The next read refuses a bad edit, which revokes everything, the safe direction.
### 3.6 Enforcement in the broker `instances` is the real limit of a grant. The thread instance runs with its full role authority and is
launched as the business human (F10), so a collaborator's message drives that instance with the owner's
role. A grant gives a user exactly what the granted instance can do. The grant docs and the R5 help text
say so.
A new bus principal, the user. It binds through a new path-bound CLI, `packages/bus/src/user-cli.mjs`, Writes go through `mosaic access grant` and `revoke` (row R5), atomically with a revision bump and one
proven the way `tester-cli.mjs` is (F3), carrying `{ business, user }`. The broker: receipt line in `<dataRoot>/state/access-log.jsonl` (append-only, as `activation-log.jsonl` is). The
CLI checks its caller with the human proof, but a direct file write by the instance's uid skips both the
CLI and the receipt. Only the separate uid (R8 gate) keeps other instances' agents from making that
write. Jason may also edit the file by hand.
1. Reads the access file through a reader that re-reads on change, the way `settings.current(id)` does ### 3.6 The user principal in the broker
for instance settings (packages/bus/src/runtime.mjs:140).
2. On bind and on every request, finds the user's live grant for the business, or fails `access-revoked`.
3. Allows only the verbs on the level's list. Every other verb fails `access-refused`, including all owner
verbs (W3).
4. Checks project and instance arguments against the grant before the verb's own checks.
5. Uses `user:<id>` as the thread owner, so `#thread` (broker.mjs:356-362) keeps users apart from each
other, from the human and from the tester with no new ownership code.
6. Records `action.refused` for each refusal, as `refuse()` does today (broker.mjs:526-530).
The Console only authenticates. It picks the transport by session: the owner session keeps The bar (B1, mos-0984 point 1): a bus user principal comes only from something the bus can verify. Here
`human-cli.mjs`, a granted user's session uses `user-cli.mjs`. It re-reads the access file per request that is the Console's verified OIDC session, reached through a proof the bus checks itself. The calling
and drops a session whose user has no live grant left. The bus check stays authoritative, so a Console process's claim alone isn't enough, and neither is a proof that admits agent ancestry.
bug can't widen a grant.
The proof. A new path-bound CLI, `packages/bus/src/user-cli.mjs`, with its own nonce variable
(`MOSAIC_BUS_USER_NONCE`), so neither of the other CLIs' nonces proves it. The bus checks it with a
`verifyUser` that:
1. Runs `verifyHuman`'s checks with `cliPath` set to the bus's own `user-cli.mjs`: same uid as the bus,
start time, nonce, CLI path, and the parent walk that refuses agent markers, launch records and agent
binaries (human.mjs:64-96).
2. Also requires the user CLI's direct parent to be the Console from the bus's own tree: `argv[1]` equals
the bus's resolved `packages/webui/src/cli.mjs`, and `argv[2]` is `serve`. This binds the user CLI to
the Console's tree, as #225 binds `human-cli` to its literal path.
3. Takes `user` from the proof and binds it only if the access file gives that user a live grant in the
proof's business. Otherwise `access-revoked`.
So the claim "I am user jason" comes from a process the bus has verified as its own Console, outside any
agent. The Console makes that claim only after Q175 verified the ID token's signature, issuer, audience,
expiry and nonce and mapped `(provider, sub)` to the user.
This needs the path-bound human proof, P19 333. Residual risk, stated as Sage's design section 8 states it
for the owner. The proof is a cooperative same-uid check. A non-agent process on the instance's uid, a
shell Jason opens for example, can start its own copy of the Console's tree from the bus's path and claim
any granted user. So can anything that can ptrace or write files as that uid. Agents can't, because the
walk refuses them. Other instances can't, because the uid check refuses them (R8 gate). Federation adds no
weaker path than the owner's.
The broker changes, each with an R3 test:
1. `bindUser({ business, user })` makes a session with `s.user` set and `s.human` and `s.tester` unset.
2. `#owner(s)` (:364-366) returns `user:<id>` for a user session. Today it would return undefined.
3. `#threadFor` (:318-345) refuses a user session with `thread-required-use-thread-send`, as it refuses
the human at :326. `message.send` is also off the verb list. Two walls, so a reply-chain send into
another user's thread fails even if one is removed (R3 mutant).
4. The `if (!s.tester) this.#human(s)` gates on the seven verbs above become a check that admits the human,
the tester, or a user with a live grant.
5. Every other `s.human` and `s.tester` branch (48 and 41 in broker.mjs) gets an audit line in R3's
packet: user refused, user treated as human, or not reachable for a user. Since every non-list verb
fails first, most are "not reachable", and the audit shows it.
6. `refuse()` records `action.refused` for every refusal (broker.mjs:526-530), with `by` = `user:<id>`.
The Console only authenticates. The owner session keeps `human-cli.mjs`, a granted user's session uses
`user-cli.mjs` with the user id. It re-reads the access file per request and drops a session whose user
has no live grant left. The bus check stays authoritative, so a Console bug can't widen a grant.
### 3.7 Revocation ### 3.7 Revocation
- Removing or expiring a grant: the next Console request drops the session, and the next bus request A grant is lost when it is removed, when it expires, or when the file fails validation. Three triggers
fails `access-revoked`. Both re-read the file, so there is no cache to flush. find the loss:
- On a revision where a user loses a grant, the broker ends that user's held thread claims in that
business using `#threadHolders(business, instance, 'user:<id>')` (F5) and the existing end path. 1. The next user request. The broker re-reads the file when its mtime, size or inode changes, so any write
- Removing a provider from `console.sso` refuses every user of that provider at sign-in. Live sessions counts.
hold the provider id and are dropped on the next request when it no longer resolves. 2. A broker timer every 30 s re-reads and re-evaluates expiry. This catches hand edits and expiry with
no request at all.
3. `access.reload`, a new human-only verb. `mosaic access grant` and `revoke` (R5) call it after the
write, so CLI changes act at once.
On a loss, in one transaction, the broker ends the claims of every `user:<id>` thread that is no longer
covered (via `#threadHolders(business, instance, 'user:<id>')`, F5, and `#claimEnd`). The run's next bus
call is then refused (F10).
The launcher side, so a pending message can't start a new run:
- `threadsState` (broker.mjs:390-393) adds `revoked: true` to any thread owned by `user:<id>` that no live
grant covers. The broker computes this from the file on each call, so it survives a launcher restart,
unlike `startedFor`.
- The launcher's thread pass (launcher.mjs:510-553) never starts a revoked thread.
- For a revoked thread that is still running, the launcher SIGTERMs the runner at once, as the idle stop
does at :527, but without waiting for `threadIdle`'s release, since the claim is already ended. The
launch check also refuses a revoked thread, which covers a start racing the revocation.
Bound: the bus refuses the user and the run from the next request on. The runner gets SIGTERM within one
launcher tick (2 s, launcher.mjs:133) of the broker seeing the loss. A model turn already in flight ends
when its runner exits. Its last bus call is refused, so it can't deliver a reply into the thread.
Removing a provider from `console.sso` is different. config.json is read at start, so that takes effect
only after a Console restart. Removing the users from access.json acts at once, so the R5 help text says to
do that first.
## 4. Fit with the current code ## 4. Fit with the current code
@@ -201,6 +308,8 @@ bug can't widen a grant.
is the one network-facing piece. No row opens a bus port. is the one network-facing piece. No row opens a bus port.
- The broker's agent authority path (broker.mjs:763-799) is untouched. User checks are a separate path, - The broker's agent authority path (broker.mjs:763-799) is untouched. User checks are a separate path,
next to `#human`, because humans have no role claim and `#agent` would refuse them (broker.mjs:367-374). next to `#human`, because humans have no role claim and `#agent` would refuse them (broker.mjs:367-374).
- New ownership code is small but real: `#owner`, `#threadFor`, the seven verb gates, `thread.send`'s
sender fields and `threadsState` (3.6, 3.7).
- The business file keeps one `human`. Granted users sit outside it, which keeps every existing - The business file keeps one `human`. Granted users sit outside it, which keeps every existing
business-file test and the organization writer unchanged. business-file test and the organization writer unchanged.
- The config loader gains one optional key, `federation`, in the same file Sage's phase 1 changes - The config loader gains one optional key, `federation`, in the same file Sage's phase 1 changes
@@ -216,24 +325,28 @@ bug can't widen a grant.
## 5. Dependencies on T1 ## 5. Dependencies on T1
- T1.1, several instances per host. Needed for the live two-instance acceptance, not for building the - T1.1, several instances per host, with a separate uid per instance (row 190, Darkwing). Rows R1 to R7
rows. The rows and the scratch acceptance (R7) use separate `MOSAIC_CONFIG` files and data roots, which build and test without it: they use separate `MOSAIC_CONFIG` files and data roots, which the code already
the code already honours (business.mjs:24). Live needs two Consoles, two public origins, two Traefik honours (business.mjs:25-28). R8 refuses to start without it (mos-0984 point 2). With per-uid instances,
routes and two provider clients, which are Jason-gated infra. a factory-uid process can't read or write the hub's access.json or data root, and it can't pass the
- T1.1 and the uid. Both instances run under one uid today (F8), and the human, tester and user proofs are hub's human or user proof, because `verifyHuman` requires the caller's uid to equal the bus's
same-uid cooperative checks (F3). An agent on the factory can therefore write the hub's access.json or (human.mjs:67). Inside one instance the proofs stay same-uid cooperative (3.6, P19 333).
run the hub's CLIs. Grants are only as strong as that boundary. I recommend T1.1 runs each instance If row 190 can't deliver separate uids, Mos takes a new decision to Jason then. The design doesn't plan a
under its own uid with its config dir and data root mode 0700. Without that, the hub's isolation from single-uid live run.
factory agents is policy, not mechanism. - T1.1 live also needs two Consoles, two public origins, two Traefik routes and two provider clients,
which are Jason-gated infra.
- T1.2, #412 SSO. Hard dependency. Row R6 builds on Sage's phase 1 candidate (Q177 merge-chaining) and - T1.2, #412 SSO. Hard dependency. Row R6 builds on Sage's phase 1 candidate (Q177 merge-chaining) and
goes live only after the phase 2 window. goes live only after the phase 2 window.
- T1.3, human users and RBAC. The access file is a user and grant store, which T1.3 also needs. One store - T1.3, human users and RBAC. Mos's reading (mos-0978 Q2) is one user and grant model for local and
should serve both. I propose this file as the shared shape: T1.3 adds shared threads and more levels to federated humans. Row 193 (Morgana, with Sage) decides the store. This design proposes access.json as
it, federation adds `home`. If T1.3 picks a different store, R1 and R3 rebase onto it. that shared shape: T1.3 adds shared threads and more levels, federation adds `home`. If row 193 picks
another store, R1 becomes an adapter or is dropped, and R3 and R5 rebase onto it. The broker check in
3.6 and the revocation in 3.7 don't depend on where the grants are stored.
## 6. After 10-27: instance-to-instance ## 6. After 10-27: instance-to-instance
Sketch only, so the slice doesn't block it. Sketch only, so the slice doesn't block it. If Jason puts agent-to-agent inside 10-27 (mos-0984 point 3),
rows for it are added after r2.
- Each instance gets an Ed25519 key in a secrets slot (`~/.config/mosaic-dev/secrets/<instance>/`, 0600, - Each instance gets an Ed25519 key in a secrets slot (`~/.config/mosaic-dev/secrets/<instance>/`, 0600,
per the Q173 slot rule). Peers pin each other's public keys in `federation.peers[].keys`. Public keys are per the Q173 slot rule). Peers pin each other's public keys in `federation.peers[].keys`. Public keys are
@@ -254,31 +367,39 @@ gate. Chains follow Q177: a dependent row builds on its parent's candidate.
| Row | Work | Owner model | Depends on | Test gate | | Row | Work | Owner model | Depends on | Test gate |
|---|---|---|---|---| |---|---|---|---|---|
| R1 | `packages/business/src/access.mjs`: access.json loader and validator, pure, no I/O beyond one read | Codex Sol | none | business tests: every rule in 3.5 refuses with the key path named, expiry, `*`, disjoint from `console.sso.subjects`, `human`/`tester` ids refused | | R1 | `packages/business/src/access.mjs`: access.json loader and validator. Providers, peers and businesses come in as arguments. One read plus the file checks | Codex Sol | none | business tests: every rule in 3.5 refuses with the key path named, symlink, non-regular file, other owner, group or other write, expiry, `*`, disjoint from the given subjects, `human`/`tester` ids, a level other than `collaborator` |
| R2 | `federation` key in scripts/mosaic-config.mjs | Haiku 5.5 high | Sage #412 phase 1 candidate | test-config.sh: valid key loads, unknown sub-key and bad origin refuse, config without it loads | | R2 | `federation` key in scripts/mosaic-config.mjs | Haiku 5.5 high | Sage #412 phase 1 candidate | test-config.sh: valid key loads, unknown sub-key and bad origin refuse, config without it loads |
| R3 | Broker user principal: `bindUser`, `user-cli.mjs` proof, level verb lists, project and instance checks, `user:<id>` thread owner, per-request grant read | Opus 5.5 | R1 | bus tests: each level's allowed and refused verbs, every owner verb refused, other user's thread refused, revoked and expired grant refused on next request, refusals recorded. Mutants: skip the grant read, drop the project check, accept a user named like the human | | R5 | `mosaic access list, grant, revoke`: human proof, atomic write, revision bump, receipt line. Help text states the `instances` limit and the provider-removal order | Haiku 5.5 high | R1 | cli tests: tester and agent callers refused, concurrent write refuses on revision conflict, receipt appended, bad grant refused before write |
| R4 | Revocation ends the user's thread runs | Codex Sol | R3 | bus test: held thread claims of the user end on the revision that drops the grant, another user's runs stay | | R3 | Broker user principal: `verifyUser` and `user-cli.mjs`, `bindUser`, the 3.6 changes, the collaborator verb list and its filters, per-request grant read, audit lines for every `s.human`/`s.tester` branch | Opus 5.5 | R1 | bus tests: each listed verb allowed, every other verb refused, a leak test per listed read (other projects, other instances, other users' threads), a reply-chain `message.send` into another user's thread refused, `thread.send` writes no `human.input`, revoked and expired grant refused on next request, refusals recorded. Mutants: the user CLI accepted under an agent-marked parent, accepted with a parent that isn't the Console, the grant read skipped, a read filter dropped, `#threadFor`'s user refusal removed, a user named like the human accepted |
| R5 | `mosaic access list, grant, revoke`: human-only, atomic write, revision bump, receipt line | Haiku 5.5 high | R1 | cli tests: tester and agent callers refused, concurrent write refuses on revision conflict, receipt appended, bad grant refused before write | | R4 | Revocation: 30 s timer, `access.reload` verb, claim end on loss, `threadsState` `revoked`, launcher skip and SIGTERM, launch check refusal, R5 calls `access.reload` | Codex Sol | R3, R5 | bus and launcher tests, each with a mutant: a hand edit removing the grant ends the run within one timer period, an expiry with no request ends it, a pending message followed by a launcher restart starts no run, a running revoked thread gets SIGTERM, another user's runs stay |
| R6 | Console: granted-user sign-in, `user-cli` transport per session, per-request grant re-read, owner-only controls hidden, peer links | Opus 5.5 | Sage #412 phase 1, R1, R2, R3 | webui tests on Sage's fake OP: granted user signs in, ungranted `sub` refused, revoked mid-session gets 401 on next call, a granted user's owner-verb call refused by the bus even with the UI check removed (mutant) | | R6 | Console: granted-user sign-in, `user-cli` transport per session, per-request grant re-read, owner-only controls hidden, peer links | Opus 5.5 | Sage #412 phase 1, R1, R2, R3 | webui tests on Sage's fake OP: granted user signs in, ungranted `sub` refused, revoked mid-session gets 401 on next call, a granted user's owner-verb call refused by the bus even with the UI check removed (mutant) |
| R7 | Scratch two-instance acceptance: two configs, two data roots, two Consoles, one fake OP | Haiku 5.5 high | R1-R6 | script test: Jason granted collaborator on one hub project signs in to the hub, makes a thread, sends to a granted instance, is refused on another project and instance, is revoked and gets refused with the run ended. The factory shows nothing of the hub | | R7 | Scratch two-instance acceptance: two configs, two data roots, two Consoles, one fake OP. The Consoles start outside agent ancestry (a transient user unit), as the user proof requires. They never use the live stack's units or ports | Haiku 5.5 high | R1-R6 | script test: Jason granted collaborator on one hub project signs in to the hub, makes a thread, sends to the granted instance, is refused on another project and on board reads, is revoked and gets refused with the run ended. The factory shows nothing of the hub |
| R8 | Live window, not a code row: hub provider client, Traefik route, access.json written by Jason, re-pins | Jason, Mos | T1.1 live, #412 phase 2, R7 | Jason signs in to the hub from his account, sends one message, Melanie revokes, his next request is refused | | R8 | Live window, not a code row: hub provider client, Traefik route, access.json written by Jason, re-pins | Jason, Mos | T1.1 live with separate uids, #412 phase 2, R7 | Entry gate, R8 refuses to start unless all hold: the factory and hub buses run under different uids, a factory-uid human-cli and user-cli call to the hub socket is refused (human.mjs:67), the factory uid can't read the hub data root or config dir (both 0700), and the two provider client ids differ. Then Jason signs in to the hub from his account and sends one message, Melanie revokes, and his next request is refused |
R1, R2 and R5 can start now and run in parallel. R3 starts when R1 has a candidate. R6 waits on Sage's R1, R2 and R5 can start now and run in parallel. R3 starts when R1 has a candidate, R4 when R3 and R5 do.
phase 1 candidate. R6 waits on Sage's phase 1 candidate.
## 8. Risk to 10-27 ## 8. Risk to 10-27
The slice lands only if #412 phase 1 has a reviewed candidate by about 10-23 and T1.1 is live by about The slice lands only if #412 phase 1 has a reviewed candidate by about 10-23 and T1.1 with separate uids is
10-25. R6 and R7 chain on the first, R8 needs both plus two Jason-gated windows (#412 phase 2 and the live by about 10-25. R6 and R7 chain on the first. R8 needs both, plus two Jason-gated windows (#412 phase
federation re-pin) and infra work only Jason does: a second public origin, a Traefik route and a provider 2 and the federation re-pin), plus infra work only Jason does: a second public origin, a Traefik route and
client. The one-uid layout (F8) also means grants don't isolate the hub from factory agents until T1.1 a provider client. If row 190 can't deliver separate uids, R8 doesn't run and Mos takes it to Jason.
separates uids.
## 9. Questions for Mos ## 9. Rulings (mos-0978, mos-0984)
1. Q1. Is human cross-access enough for 10-27, with agent-to-agent traffic after (section 6)? I recommend 1. Q1: human cross-access is the 10-27 slice (mos-0978). Duckworth's N8 says moving agent-to-agent past
yes. Agent traffic needs instance keys, a signed endpoint and a peer grant kind, which is at least three 10-27 narrows Jason's "Federation is configurable", so Mos is putting it to Jason (mos-0984 point 3).
more rows on top of a chain that already sits on #412. r2 doesn't wait for that answer. If he puts it inside 10-27, rows follow.
2. Q2. Does T1.3 take the access file as its user and grant store (section 5)? One store avoids two RBAC 2. Q2, open: one user and grant model for local and federated humans is Mos's reading. Row 193 (Morgana,
models. This needs Sage's agreement as T1 lead. T1.3) settles the store with Sage.
3. Q3. Does T1.1 run each instance under its own uid? Without it the hub's grants are policy only. 3. Q3 and B5: a separate uid per instance is a hard precondition of R8 (mos-0984 point 2). Mos won't ask
Jason to accept a single uid, and takes a new decision to him if row 190 can't deliver.
## 10. Notes
- The r1 push used the repo-local credential helper `scripts/git-credential-mosaic.mjs` (set in the
canonical checkout's .git/config for git.mosaicstack.dev) with its default credential file,
`~/secrets/mosaic.gitea.json`, and no `MOSAIC_GITEA_CREDENTIAL_FILE` override. That file's user is
jason.woltje. The r2 push uses the same. Gizmoduck has no seat identity until T1 row 195 adds per-agent
git identity.
+1
View File
@@ -1358,3 +1358,4 @@ are never rewritten or removed; corrections are new entries.
2026-10-20T15:40:36Z | Filbert (Opus 5.5, 048f3c63) | row 163 r2 review (D2 rehearse, candidate 2c090eefd, manifest c6f4f4c12596) | approve, comment 29447 rev 2234: 249/249 singles pass; my M1-M6 and N1 mutants all killed (R14, R9, R11, R4, R15, session test) 2026-10-20T15:40:36Z | Filbert (Opus 5.5, 048f3c63) | row 163 r2 review (D2 rehearse, candidate 2c090eefd, manifest c6f4f4c12596) | approve, comment 29447 rev 2234: 249/249 singles pass; my M1-M6 and N1 mutants all killed (R14, R9, R11, R4, R15, session test)
2026-10-20T15:42:00Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) r2 verdict intake | approved by Filbert (comment 29447, rev 2234 committed cd2b377bb); P19 337 comment 367 -> 368; candidate 2c090eefd manifest c6f4f4c12596 waits to land after B7, fresh gate if the base moves 2026-10-20T15:42:00Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) r2 verdict intake | approved by Filbert (comment 29447, rev 2234 committed cd2b377bb); P19 337 comment 367 -> 368; candidate 2c090eefd manifest c6f4f4c12596 waits to land after B7, fresh gate if the base moves
2026-10-20T18:44:50Z | Gizmoduck (Opus 5.5) | T3 federation design r1 for Vikunja #414 (G3, G4, C1.1), agents/gizmoduck/work/federation/DESIGN.md, on branch gizmoduck/federation-design off origin/refactor 9ae8c73ac | design committed and pushed; rows R1-R8 wait for Filbert or Dewey review before Sage's queue; Q1-Q3 to Mos 2026-10-20T18:44:50Z | Gizmoduck (Opus 5.5) | T3 federation design r1 for Vikunja #414 (G3, G4, C1.1), agents/gizmoduck/work/federation/DESIGN.md, on branch gizmoduck/federation-design off origin/refactor 9ae8c73ac | design committed and pushed; rows R1-R8 wait for Filbert or Dewey review before Sage's queue; Q1-Q3 to Mos
2026-10-20T18:56:54Z | Gizmoduck (Opus 5.5) | T3 federation design r2 for Vikunja #414, answering Duckworth's B1-B5 and N1-N9 (duckworth-0001) and Mos's mos-0978 and mos-0984 rulings, agents/gizmoduck/work/federation/DESIGN.md | r2 committed and pushed on gizmoduck/federation-design; sent to Duckworth and Mos for re-review; R1, R2, R5 wait for Duckworth's approval before Sage's queue