gizmoduck: T3 federation design r1 for #414 (G3, G4, C1.1): 10-27 slice is OIDC-verified cross-instance user grants enforced in the broker, rows R1-R8, Q1-Q3 for Mos

This commit is contained in:
2026-10-20 13:44:50 -05:00
parent 9ae8c73acb
commit 6925f78f0e
2 changed files with 285 additions and 0 deletions
+284
View File
@@ -0,0 +1,284 @@
# 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
(Filbert or Dewey) before any row goes to Sage's queue. Code lines are at origin/refactor 9ae8c73ac. The
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
PLAN.md T3 (all in jason-master-restart).
## 1. Summary
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
collaborator level. The receiving instance verifies that person itself through OIDC (Q175) and its broker
enforces the grant on every request. Revoking a grant refuses the next request and stops that user's
thread runs.
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
block it.
## 2. What exists today (measured)
- F1. The bus is a local Unix socket per data root: `socketPath` at packages/cli/src/config.mjs:19, opened
at packages/bus/src/runtime.mjs:169. Nothing listens on a network port. No live federation code exists.
The PRD lists REQ-FED-1 as post-v1 (packages/tasks/requirements.json:61).
- 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).
- 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
the same way (packages/bus/src/tester.mjs:5-9). 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`
(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.
- 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).
`#threadHolders(business, instance, owner)` already 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
(packages/webui/src/bus.mjs:1-9, 27-31). Its sign-in today trusts a proxy header (Sage's design, section
"What exists 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
(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
config.json and not the business file.
- 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.
### The archived v1 federation (v1/apps/gateway/src/federation)
What it does: mTLS between gateways with certificates from step-ca. The grant id and subject user id sit
in custom certificate OIDs (ca.service.ts:22-24). A peer enrolls with a single-use, 15-minute bearer
token. The guard reads the client certificate off the TLS socket (server/federation-auth.guard.ts:93-106)
and re-checks the grant row on every request. Scopes are zod-validated resource lists
(scope-schema.ts:15-99). Shared data is read-only list and get queries.
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
new stack has none of those.
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
inbound call would get 401.
3. mTLS is the wrong fit for the homelab path. Traefik terminates TLS in front of the Console (Sage's
design, public path). A client certificate would reach the app as a forwarded header, which is the
header trust Q175 point 1 rules out.
Ideas worth keeping: re-check the grant on every request, never cache it in the credential. Keep
enrollment short-lived and single-use. Tag every federated result with its source instance.
## 3. The 10-27 slice
### 3.1 What it must do
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.
2. D2. The owner of an instance grants a person access to one business, with a project list, an agent
instance list and a level. 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
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,
which instances, and which threads (own threads only).
5. D5. Revoking a grant refuses that user's next request on the Console and on the bus, and ends the runs
of that user's threads in that business.
### 3.2 What waits
- 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
only.
- 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.
- W4. A registry, `mosaic comms`, enrollment tokens and key rotation tooling. With two instances,
out-of-band setup by Jason is cheaper.
- W5. Federated reads that merge results from both instances into one view.
### 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
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
instance key is needed until instances talk to each other (W1).
### 3.4 Remote user identity
A user is `(provider, sub)` where `provider` is an id from that instance's `console.sso.providers`. Q175
already verifies signature, issuer, audience, expiry and nonce, and allows several providers. If the hub
and the factory use the same Authentik, Jason's `sub` is the same on both. If Melanie's hub uses Pocket
ID, the hub lists both providers. Either way the receiving instance checks the token. It never takes the
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
else comes from the access file. The two lists are disjoint, and the loader refuses a subject in both.
### 3.5 The access file
`<configDir>/access.json`, next to config.json, so it is per instance (business.mjs:25-27 sets configDir
from `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
in the business file because agents can write that (F7).
```json
{
"accessVersion": 1,
"revision": 3,
"users": [
{ "id": "jason", "home": "factory", "subjects": [{ "provider": "authentik", "sub": "<sub>" }] }
],
"grants": [
{
"id": "g-hub-1",
"user": "jason",
"business": "creative-hub",
"projects": ["*"],
"instances": ["researcher", "fact-check"],
"level": "collaborator",
"expires": "2026-12-31T00:00:00Z"
}
]
}
```
Rules the loader enforces, refusing the whole file on any failure (fail closed):
- 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
keeps a granted user from owning threads as the owner or the tester (F5).
- `subjects[].provider` names a provider in `console.sso`. A `(provider, sub)` pair appears once in the
file and never in `console.sso.subjects`.
- `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
`["*"]` or declared instances. `level` is `viewer` or `collaborator`. `expires` is optional ISO time.
- A grant past `expires` is treated as absent.
Levels, as templated human roles. A grant can only narrow them through `projects` and `instances`:
| Level | Allowed |
|---|---|
| viewer | Read the board, projects and instances it is granted. Read its own threads. No sends. |
| collaborator | Viewer, plus create its own threads in granted projects on granted instances, send in them, read replies. |
Writes go through a human-only CLI verb (row R5), atomically with a revision bump and one receipt line
in `<dataRoot>/state/access-log.jsonl` (append-only, as `activation-log.jsonl` is). Jason may also edit the
file by hand. The next read refuses a bad edit, which revokes everything, the safe direction.
### 3.6 Enforcement in the broker
A new bus principal, the user. It binds through a new path-bound CLI, `packages/bus/src/user-cli.mjs`,
proven the way `tester-cli.mjs` is (F3), carrying `{ business, user }`. The broker:
1. Reads the access file through a reader that re-reads on change, the way `settings.current(id)` does
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
`human-cli.mjs`, a granted user's session uses `user-cli.mjs`. 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
- Removing or expiring a grant: the next Console request drops the session, and the next bus request
fails `access-revoked`. Both re-read the file, so there is no cache to flush.
- 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.
- Removing a provider from `console.sso` refuses every user of that provider at sign-in. Live sessions
hold the provider id and are dropped on the next request when it no longer resolves.
## 4. Fit with the current code
- The bus stays a local socket. Federation reaches it only through the receiving instance's Console, which
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,
next to `#human`, because humans have no role claim and `#agent` would refuse them (broker.mjs:367-374).
- The business file keeps one `human`. Granted users sit outside it, which keeps every existing
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
(mosaic-config.mjs:112-116). Row R2 chains on that candidate and repeats its blast-radius rule: the key
goes in only after a copy that accepts it is pinned.
```json
"federation": {
"self": "hub",
"peers": [{ "id": "factory", "origin": "https://mosaic.woltje.com", "label": "Software factory" }]
}
```
## 5. Dependencies on T1
- T1.1, several instances per host. Needed for the live two-instance acceptance, not for building the
rows. The rows and the scratch acceptance (R7) use separate `MOSAIC_CONFIG` files and data roots, which
the code already honours (business.mjs:24). Live needs two Consoles, two public origins, two Traefik
routes and two provider clients, which are Jason-gated infra.
- T1.1 and the uid. Both instances run under one uid today (F8), and the human, tester and user proofs are
same-uid cooperative checks (F3). An agent on the factory can therefore write the hub's access.json or
run the hub's CLIs. Grants are only as strong as that boundary. I recommend T1.1 runs each instance
under its own uid with its config dir and data root mode 0700. Without that, the hub's isolation from
factory agents is policy, not mechanism.
- 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.
- T1.3, human users and RBAC. The access file is a user and grant store, which T1.3 also needs. One store
should serve both. I propose this file as the shared shape: T1.3 adds shared threads and more levels to
it, federation adds `home`. If T1.3 picks a different store, R1 and R3 rebase onto it.
## 6. After 10-27: instance-to-instance
Sketch only, so the slice doesn't block it.
- 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
not secrets.
- Requests between instances are signed with HTTP Message Signatures (RFC 9421) over method, target URI,
authority, content digest, created time and a nonce. The receiving Console verifies the signature
itself, so the proof survives Traefik. A created time outside 60 s or a reused nonce refuses. No shared
secret, no bearer token.
- A peer agent becomes a grant subject of kind `peer` (`factory/researcher`), in the same access file, so
grant and revoke work the same way. Removing a peer key refuses all of that peer's traffic at once.
- First use: a factory agent sends a message into a hub thread it was granted, owned by `peer:factory`.
## 7. Rows
Owner model per Q177 and Q178. Opus 5.5 for hard design and builds, Haiku 5.5 at high effort for mid work,
Codex gpt-6.1-sol for parallel builds. Every row has its own independent review (Filbert or Dewey) and
gate. Chains follow Q177: a dependent row builds on its parent's candidate.
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
R1, R2 and R5 can start now and run in parallel. R3 starts when R1 has a candidate. R6 waits on Sage's
phase 1 candidate.
## 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
10-25. R6 and R7 chain on the first, R8 needs both plus two Jason-gated windows (#412 phase 2 and the
federation re-pin) and infra work only Jason does: a second public origin, a Traefik route and a provider
client. The one-uid layout (F8) also means grants don't isolate the hub from factory agents until T1.1
separates uids.
## 9. Questions for Mos
1. Q1. Is human cross-access enough for 10-27, with agent-to-agent traffic after (section 6)? I recommend
yes. Agent traffic needs instance keys, a signed endpoint and a peer grant kind, which is at least three
more rows on top of a chain that already sits on #412.
2. Q2. Does T1.3 take the access file as its user and grant store (section 5)? One store avoids two RBAC
models. This needs Sage's agreement as T1 lead.
3. Q3. Does T1.1 run each instance under its own uid? Without it the hub's grants are policy only.
+1
View File
@@ -1357,3 +1357,4 @@ are never rewritten or removed; corrections are new entries.
2026-10-20T15:26:33Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) r2 for Filbert's r1 changes (F1-F3, N1) | in-review r2, Filbert reviewer: candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5, manifest c6f4f4c12596; tests R14 R15 + R4/R9/R11 cases, read-only Registry #write() returns; gate passed, K34-K41 killed; Mos checked manifest (mos-0967); revs 2230 (Filbert) and 2231-2233 committed 92cc6011c c539995cf; request comment 29446; P19 337 comment 367; packet 7af166574; lands after B7
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-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