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:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user