diff --git a/agents/launchpad/work/queue-255/DESIGN.md b/agents/launchpad/work/queue-255/DESIGN.md new file mode 100644 index 000000000..b04a6c47d --- /dev/null +++ b/agents/launchpad/work/queue-255/DESIGN.md @@ -0,0 +1,386 @@ +# Row 255 design: a bundled Pocket ID for users with no OIDC provider (Q203, Gitea #1760, Vikunja 458) + +Launchpad, draft 1, 2026-10-25, on `launchpad/row255` off refactor 78b69e1e6. Brief +`docs/plans/2026-10-21_bundled-pocket-id.md` (blob 26f34400). Reviewer Webby. Sage checks it against Q203, O6, +REQ-WEB-2 and REQ-INST-1. REQ-INST-2 (PRD 1.10) is post-v1 (mos-1204), and so is everything here. + +This is a design only. Nothing here installs, downloads or starts Pocket ID. It changes nothing on .53, .54 or +dragon-lin, which keeps Authentik. + +## Sources + +- Q203 and O6 as the brief quotes them. PRD `docs/prd/mosaic-stack.md` REQ-WEB-2 (:902), REQ-INST-1 (:1509), + REQ-INST-2 (:1513) and REQ-INST-3 (:1528). Lead decision 44 items 5 and 6 + (`docs/plans/2026-09-26_lead-decisions.md:655-663`). +- The Q175 design `agents/sage/work/q175-sso/DESIGN.md` sections 4 and 10, and the code row 192 landed: + `packages/webui/src/sso-config.mjs` (the `console.sso` shape), `packages/webui/src/sso.mjs` (the slot read, + the discovery checks at :105-118, the client at :147-148, the callback at :133, the post-logout URI at :332). +- Researcher's note `agents/researcher/work/2026-10-04_vikunja-pocketid.md` part 2 (v2.17.0). +- A fresh read of upstream on 2026-10-25, from release metadata and source text only: no binary was fetched or + run. The latest stable release is **v2.19.1** (2026-10-22). Source paths below are under + `backend/internal/` at tag v2.19.1 in `github.com/pocket-id/pocket-id`, marked "P". Releases v2.18.0, + v2.19.0 and v2.19.1 are not marked breaking. + +## What the Console already needs from any provider + +This design keeps the Console's sign-in and start checks exactly as row 192 built them. Pocket ID becomes one +`console.sso.providers` entry, the same shape any compliant OP uses (Q175 section 4, "Several providers"). The +Console gets no new sign-in path, no password and no proxy-header identity (O6, REQ-WEB-2, REQ-INST-2). The +facts that drive the rest of the design: + +- `publicOrigin` and every `issuer` must be https (`sso-config.mjs` `httpsURL`). A plain-http Pocket ID can't + be a provider at all. +- Each provider needs 1 to 64 `subjects`. A `console.sso` with no subject refuses to load. So the first + person's subject has to exist before the Console starts with `--sso` (section 5). +- The client authenticates with `client_secret_basic` (`sso.mjs:148`), so the client is confidential. +- The slot is `/secrets/mosaic-stack/`: a regular file, owned by the Console's uid, mode + with no group or other bits, and outside the repo and every stack copy (`readSlot`). +- `config.json` is user-authored. bootstrap.sh creates it and nothing overwrites it (canon invariant 2, Q175 + section 4). This matters for sections 1 and 5. + +## 1. The choice + +The installer is REQ-INST-3's install command (Vikunja #432; today `stack-upgrade.sh` and the per-case +install scripts). It takes one sign-in option per instance: + +- `--oidc existing`. The user points the Console at one or more OIDC providers they already have. Nothing + is installed. The user writes `console.sso` themselves, as Q175 step 4 does today. The installer writes + nothing into `console.sso`; it prints the entry shape and the two URIs to register + (`/auth/callback`, `/auth/signed-out`). +- `--oidc bundled`. The stack installs Pocket ID as an install component (section 2), and + `console.sso.providers` holds exactly one entry, `id: "pocket-id"`, whose `issuer` is the component's + origin. +- No option keeps today's behaviour: no `console.sso`, and the Console runs without `--sso`. + +How the bundled choice is recorded. REQ-INST-1 lets a component be switched on "in the organization file +or system config". It has to be the system config. The organization file can be written by agents through +`organizationWriter`, which is the reason Q175 B1 put `console.sso` in config.json, and the same reason +holds for a component that issues the Console's identities. So config.json gets one new optional key: + +```json +"components": { + "pocket-id": { + "release": "v2.19.1", + "origin": "https://id.example.internal", + "port": 1411 + } +} +``` + +The loader (`scripts/mosaic-config.mjs`) checks the shape: `release` is a `vX.Y.Z` tag, and the component script refuses one that +differs from the pin file (section 2), `origin` is an https origin, `port` is 1024 to 65535. It also cross-checks: when +`components["pocket-id"]` is present, `console.sso` must have exactly one provider, `id` `pocket-id`, +`issuer` equal to `origin`. A config with `console.sso` providers and no `components` is the existing +choice and loads as today. **M1 (to Mos): this is a new top-level config key.** The loader refuses unknown +keys today, so it is a config schema change, with the same rollback trap Q175 section 4 names: a copy older +than the build row refuses a config that has the key. + +What each choice writes into `console.sso` is in section 5. For the bundled choice, Pocket ID is the only +providers entry. The design keeps it that way because REQ-INST-2 says "the Console's one providers entry". +A user who later adds an existing provider moves to `--oidc existing` and removes the component (section 2). + +## 2. The component, against REQ-INST-1 + +**Pinned.** A Pocket ID release is a set of raw binaries plus `checksums.txt` (sha256). From v2.16.0 there is +an SPDX SBOM per binary, and the release workflow attests `checksums.txt` through Sigstore (`actions/attest` +in `.github/workflows/release.yml`). The repo holds one pin file, +`scripts/pocket-id-pins.sh`, sourced the way `scripts/case1-pins.sh` is: the tag, and for each supported platform (linux amd64 and arm64 to +start) the asset name and its sha256. The build row copies the sha256 from `checksums.txt` at the tag and +records the `gh attestation verify` output for each asset as its evidence. The pin is checked by hand at +build time, not trusted at install time. No Pocket ID source and no binary goes in the repo. The binary is +not in any image either, since the container install comes after v1 (REQ-INST-3). + +Recommended pin: v2.19.1, the current release. The build row checks it again, because Pocket ID ships a +minor every one to four weeks. Upgrading is a pin file change in a reviewed commit. + +**Fetched and checked.** `scripts/component-pocket-id.sh fetch` downloads the pinned asset for the host's +platform over https into a new 0700 staging directory, computes its sha256, and compares it with the pin. +On a mismatch it deletes the file and refuses with `pocket-id-digest-mismatch`, naming the expected value +and the computed one. On a match it installs the binary read-only (0555) at +`/components/pocket-id/bin/pocket-id-`. The fetch is the only network step, and the user +runs it at install. Tests never run it against the network (section 9). + +**Runs.** A systemd user unit, `mosaic-pocket-id.service` (`mosaic-pocket-id-NAME.service` for a named +instance, as row 190 names the bus and webui units), as the instance's user: + +- `ExecStart=/components/pocket-id/bin/pocket-id-`. +- `WorkingDirectory=/components/pocket-id/run`, a directory the unit owns, so Pocket ID's habit of + reading `.env` from the working directory reads nothing we didn't write. The build row checks there is no + `.env` there at start. +- Environment, all from the rendered unit: + - `APP_URL=` and `HOST=127.0.0.1` (P `common/env_config.go`: HOST defaults to `0.0.0.0`). + - `PORT=`. + - `DB_CONNECTION_STRING` and `UPLOAD_PATH` as absolute paths under `/components/pocket-id/data/` + (P defaults are `data/pocket-id.db` and `data/uploads`, relative to the working directory). + - `ALLOW_USER_SIGNUPS=disabled` and `ALLOW_INSECURE_CALLBACK_URLS=false`. + - `EMAIL_ONE_TIME_ACCESS_AS_UNAUTHENTICATED_ENABLED=false` and `..._AS_ADMIN_ENABLED=false`. + - The `OUTBOUND_ALLOWED_HOSTS_*` allowlists set empty (added in 2.19.0). + - No `TRUST_PROXY` unless section 6's proxy step sets it to the proxy's address. +- `ENCRYPTION_KEY` (mandatory since v2.0, at least 16 bytes) never goes in the unit text. It lives in slot + `/secrets/mosaic-stack/pocket-id-encryption-key`, 0600. The component script generates it from + `/dev/urandom` straight into the file and never prints it. The unit passes it with + `LoadCredential=` and `ENCRYPTION_KEY_FILE=%d/...`. The build row confirms `ENCRYPTION_KEY_FILE` at the pinned + tag; if it isn't there, it refuses rather than putting the key in `Environment=`. +- The unit never carries `STATIC_API_KEY`. That key exists only during the enroll run (section 4). + +The data directory holds the SQLite database, Pocket ID's signing key (stored encrypted with +`ENCRYPTION_KEY`), and the passkey registrations. It is 0700. Agent sandboxes bind only an allowlist +(`packages/seat/src/sandbox.mjs`), and `/components/` is not on it. The build row adds a sandbox +test that pins that. + +**Health-checked.** `GET http://127.0.0.1:/healthz` returns 204 (P `controller/healthz_controller.go`). +`component-pocket-id.sh status` reports the unit state, the binary tag against the pin, the binary's sha256 +against the pin, and the health result. The Console's own `/healthz` already shows `sso: { "pocket-id": ready +| unreachable | refused }` from its discovery retries, so an operator sees the provider from either side. + +**Removable.** `component-pocket-id.sh remove` refuses while `console.sso` still names `pocket-id`, so the +Console can't be left pointing at a provider that's gone. Once the user has taken the entry out (or moved to +`--oidc existing`), the script stops and removes the unit, then moves the data directory to +`/components/pocket-id.removed-` with a receipt line in `/state/components.log` +(append-only). It never deletes the passkey database outright. Deleting the moved directory and the +encryption key slot is a separate `purge` verb that names both paths and needs `--yes`. Removing the +`components` key from config.json is the user's edit. + +## 3. The Q175 start checks against the bundled instance + +The Console checks are unchanged. Here is each one against Pocket ID, and the setting it needs: + +| Check (row 192) | Pocket ID v2.19.1 | Default that fails | Fix | +|---|---|---|---| +| `issuer` is https (loader) | issuer = `APP_URL` exactly (P `controller/well_known_controller.go`) | `APP_URL` defaults to `http://localhost:1411` | `APP_URL=`, section 6 | +| discovery `issuer` equals config `issuer` | equal when `APP_URL` has no trailing slash; Pocket ID refuses a path | none | config `issuer` = `origin`, no trailing slash; the loader cross-check compares them | +| `id_token_signing_alg_values_supported` overlaps `algs` | advertises only the current key's alg. RS256 (RSA-2048) by default (P `service/jwt_service.go`) | none | `algs: ["RS256"]` | +| `code_challenge_methods_supported` has `S256` | advertises `plain` and `S256` | none | none. The Console always sends S256 (`sso.mjs:221`) | +| JWKS has a usable key for `algs` | one key at `/.well-known/jwks.json` | none | none. The build row's fake OP pins `use`/`alg`/`kid` as Pocket ID emits them | +| `end_session_endpoint` is https | `/api/oidc/end-session` | http, if `APP_URL` is http | same fix as row 1 | +| `redirect_uri` is registered | exact match first, then wildcard patterns; loopback callbacks match any port (P `utils/callback_url_util.go`) | `ALLOW_INSECURE_CALLBACK_URLS` defaults to true | register exactly `/auth/callback`, with no `*`; set `ALLOW_INSECURE_CALLBACK_URLS=false` | +| post-logout URI | per-client `logoutCallbackURLs`, same matching | none | register exactly `/auth/signed-out` | +| ID token alg in `algs`, `iss` match | as above | none | none | + +Two points the Console doesn't check, set on the client anyway: + +- `pkceEnabled: true`. Then Pocket ID refuses an authorize request that has no `code_challenge`. Pocket ID has + no per-client S256-only setting (P `oidc/provider.go` turns on `plain` for the server). That doesn't relax + anything: the Console sends S256 every time, and `plain` stays available only to other clients. +- `isPublic: false`, which matches `client_secret_basic`. + +Key rotation. `pocket-id key-rotate --alg ...` replaces the one signing key (P `cmds/key_rotate.go`). After a +rotation to another alg, the Console's discovery check refuses on the next restart (no overlap with +`algs`), and that is the intended fail-closed result. A rotation is a user step that updates `algs` in the +same edit. The component script never rotates. + +## 4. The client, and where its secret goes + +The person running the install creates the client through `component-pocket-id.sh enroll`. No agent runs +this verb. It refuses when any agent marker is in its environment: the `MARKERS` list in +`packages/bus/src/human.mjs` (`MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP`, `CLAUDECODE` and the rest) that the human +boundary uses. A test pins the refusal. + +Enroll needs admin rights before any person has a passkey. Pocket ID offers two ways to get them, and one of +them is a hazard: + +- `/setup` and its backing `POST /api/signup/setup` are unauthenticated: whoever calls first becomes admin + (P `usersignup/service.go`). Sandboxed agents share the host's network namespace (`sandbox.mjs` unshares + user, pid and ipc, not net), so any agent could reach a loopback Pocket ID and take that first admin. +- `STATIC_API_KEY` is a fixed admin key with no expiry (P `apikey/service.go`). It doesn't count as + completing setup, so `/setup` stays open while it's set. + +So enroll never lets the real unit serve before an admin person exists. It runs the pinned binary once in a +**private network namespace** (`unshare --user --net`, or `bwrap --unshare-net` with the binary and data +directory bound), where only enroll's own loopback can reach it: + +1. Generate a one-run static key into a 0600 temp file in a new 0700 directory, and start Pocket ID inside + the namespace with `STATIC_API_KEY_FILE` pointing at it, plus the unit's environment. +2. Wait for `/healthz` 204. +3. Create the admin user, `POST /api/users` with `isAdmin: true` and the username and email given with + `--admin-user` and `--admin-email`. Keep the returned `id`. That is the OIDC `sub` (P + `oidc/claims_service.go` uses `user.ID`, a UUIDv4 set at creation, so it's stable). +4. Create the Console client: client id `mosaic-console-`, callback and logout URLs exactly as in + section 3, `pkceEnabled: true`, `isPublic: false`. Supply the secret instead of letting Pocket ID + generate it. Enroll generates 48 random bytes, base64url, and writes them straight to slot + `/secrets/mosaic-stack/console-sso-pocket-id` (0600, `O_CREAT|O_EXCL`, not a symlink). It sends + the same bytes in the create body over the namespace's loopback (`dto/oidc_dto.go` accepts a supplied + secret of 16 or more characters). The value is never printed, logged or put in an argv, and a test pins + that it appears in no output. +5. Mint a one-time access token for the admin, `POST /api/users//one-time-access-token`. +6. Stop the namespaced process, then shred and remove the static key file. The real unit has never had a + static key, so no admin key survives enroll. +7. Start the real unit and wait for `/healthz` 204. +8. Print the sign-in link `/lc/` to the user's terminal (section 5), and the `console.sso` + entry (section 5). + +The build row confirms each route and body against the pinned tag's source before it writes the client (the +upstream read for this design didn't trace the create-client route). If any of steps 1 to 7 fails, enroll +stops the process, removes the static key, leaves no slot file it created, and refuses with the step name. + +The secret never enters the repo or an image. It sits in two places only: the slot file and Pocket ID's +database (stored as a hash). The Console's `readSlot` reads it as it does any provider's. + +## 5. The first person, and their subject in `console.sso` + +Pocket ID is passkey-only for people (REQ-INST-2). The installing user enrolls like this: + +1. Enroll prints `/lc/`. The token is valid for one hour and is a credential, so it goes only + to the terminal of the person who ran enroll. It goes in no log and no file. +2. The user opens it in a browser that can reach `origin` (section 6). Pocket ID signs them in and asks them + to add a passkey. Without one they can't sign in again. If the hour passes, `pocket-id + one-time-access-token ` (P `cmds/one_time_access_token.go`), run by the user against the data + directory, prints a new link. `component-pocket-id.sh relink` wraps that call. +3. Enroll already has the subject, the `id` from step 3 of section 4. It doesn't wait for a sign-in or read a + `subject-unknown` log line. + +How the subject gets into `console.sso`. config.json is user-authored and never auto-written. Two ways +to respect that; I recommend the first: + +- **R1 (recommended).** Enroll prints the full entry and writes nothing: + + ```json + "console": { "sso": { "publicOrigin": "", "providers": [ { + "id": "pocket-id", "issuer": "", "clientId": "mosaic-console-", + "clientSecret": { "slot": "console-sso-pocket-id" }, "algs": ["RS256"], + "subjects": [ { "sub": "", "name": "" } ] } ] } }, + "components": { "pocket-id": { "release": "", "origin": "", "port": } } + ``` + + The user pastes it into config.json, as with Q175 step 4. Rerunning `scripts/bootstrap.sh` (or + `--instance NAME`) then validates it through the loader, including the section 1 cross-check, and writes + nothing. Nothing changes in invariant 2. +- **R2.** `enroll --write-config` writes those two keys itself, with a backup of config.json first, and + refuses if `console` or `components` is already present. That would be the first tool to modify an + existing config.json. **M2 (to Mos, only if R2 is wanted): R2 changes canon invariant 2's + "never overwritten" practice.** + +Whose subject. Enroll creates one person, the one running it, and only that subject goes in the entry. On +Jason's instances, only his subject goes into `console.sso` (mos-1197). Further people are added with +Pocket ID's admin UI (a user plus a one-time link), and their subjects go into `console.sso` by the user's +edit, never by an agent. Pocket ID users who aren't in `subjects` can sign in to Pocket ID, but the Console +refuses them `subject-unknown`, so REQ-WEB-2's "must map to an authorized human" holds. + +## 6. Reach: origin, TLS and proxy + +WebAuthn takes its RP id from `APP_URL`'s host name, and its allowed origin is `APP_URL` +(P `webauthn/module.go`). Browsers allow passkeys only on a secure origin. The Console also needs https for +both `publicOrigin` and `issuer`. Pocket ID serves plain http, and I found no TLS listener setting in its +environment config. So: + +- Pocket ID always binds `127.0.0.1:`. That is the default, and the component never binds anything + else (`HOST=127.0.0.1`; the status verb refuses a unit whose `HOST` differs). +- `origin` is an https origin served by a TLS front that the user already has for the Console. The Console + needs one anyway, since `publicOrigin` is https. Pocket ID goes behind the same front under **its own host + name**, for example `id.`. Pocket ID doesn't support a path prefix (`APP_URL` must have no + path). A separate host name also keeps Pocket ID's cookies apart from the Console's + `__Host-mosaic_*` cookies; the two share cookies if they sit on one host name on different ports. +- The front reaches Pocket ID on loopback, so the front has to run on the same host. If it doesn't, Pocket + ID would need a non-loopback bind, and that is a `network.expose` hard wait (REQ-WAIT-1), not a component + option. +- `TRUST_PROXY` is set to the front's address only, never `true`. +- The Console fetches discovery and JWKS from `issuer` over https from the server side, so the Console's + node has to trust the front's certificate. A public CA does that. A private CA needs + `NODE_EXTRA_CA_CERTS` in the Console unit, which the build row adds as a render flag. + +These steps are the user's, and on Jason's hosts they are Jason's, because each one changes who can reach +the Console or Pocket ID, or who the host trusts: +- J1: the DNS name for `origin`; +- J2: the certificate for it, or a private CA and its trust in the browser and in `NODE_EXTRA_CA_CERTS`; +- J3: the TLS front's route from `origin` to `127.0.0.1:` (a Traefik change on Jason's hosts); +- J4: any listener off loopback. + +The component checks and refuses; it never does them itself. With none of them done, Pocket ID runs on +loopback with an https `APP_URL` that nothing serves yet. Enroll's step 2 health probe goes straight to +`127.0.0.1`, so enroll still works. The person can't add a passkey until J1 to J3 are done. + +## 7. Agents + +Unchanged. Agents keep their per-role service tokens (lead decision 44 item 6, REQ-INST-2). Pocket ID issues +nothing an agent uses. The Console client is the only client enroll creates. No API, no client-credentials +grant and no admin API key exist after enroll. Residuals, stated plainly: + +- Agents can reach `127.0.0.1:`, because the sandbox shares the host network. What they can reach there + is the public sign-in pages, discovery and JWKS. Signup is disabled, email codes are off, `/setup` is + closed because an admin exists, and there is no static key. A test of the rendered unit pins all four + settings. +- The data directory and the two slots are outside every sandbox bind (section 2). +- A seat that ran as the same OS user outside a sandbox could read the data directory. The Console's + existing slot has the same exposure; this design adds no new class of it. + +## 8. Instances + +One Pocket ID per named instance, never shared: + +- Each instance has one system config (row 190), and `components` and `console.sso` live in it. A shared + Pocket ID would make one instance's config depend on a unit, a data directory and an encryption key that + belong to another instance's config and data root. +- Unit names, ports, data directory, slots and removal are all per instance (`mosaic-pocket-id-NAME`, + `/components/pocket-id`), so removing one instance's component can't break another's + sign-in. +- Each instance needs its own `origin` (section 6), so passkeys are per instance too. A person enrolls once + per instance. +- An instance that wants to use another instance's Pocket ID can still do it with `--oidc existing`. Then it + is just an OIDC provider, with its own client registered by that instance's owner. Nothing special-cases it. + +Two instances on one host each need their own `port`. The loader can't see other instances' configs, so +`component-pocket-id.sh` refuses at fetch and enroll when the port is already bound on loopback. + +## 9. Tests, stand-ins only + +No test downloads or starts a real Pocket ID, and no test touches auth.diversecanvas.com, +mosaic.woltje.com or 127.0.0.1:7332. The stand-ins: + +- **Fake Pocket ID OP**, a node server like row 192's fake OP, shaped from v2.19.1: + - the discovery fields `well_known_controller.go` emits (issuer = origin, `plain` and `S256`, + `end_session_endpoint` at `/api/oidc/end-session`, the three auth methods); + - one RS256 JWK at `/.well-known/jwks.json`; + - a token endpoint that takes only `client_secret_basic`; + - `/healthz` 204. + + The Console starts against it under `--sso` with the section 5 entry, with no check changed. Negative + cases: + - an http issuer refuses at load; + - an ES256-only JWKS against `algs: ["RS256"]` refuses at discovery; + - an issuer with a trailing slash refuses on the cross-check. +- **Fake admin API** on the same stand-in: `POST /api/users`, the client create, the one-time token, and a + static-key check. Enroll runs against it with the binary replaced by a stub script that serves the stand-in. + The tests pin: + - the namespace argv; + - the slot file's mode and `O_EXCL`; + - that the secret appears in no stdout, stderr or argv; + - the static key file gone after success and after a failure at each step; + - the refusal under an agent marker in the environment. +- **Fetch**: a local file served by a test https server (self-signed, trusted only by the test) with a + matching pin, a mismatched pin and a truncated file. +- **Unit render**: the environment table in section 2. `HOST=127.0.0.1`, no `STATIC_API_KEY`, no + `ENCRYPTION_KEY` value in the text, and `ALLOW_USER_SIGNUPS=disabled`. +- **Loader**: test-config.sh cases for the `components` key and the cross-check, and the copy-rollback note. +- **Sandbox**: `/components/` is in no bind list. + +## 10. Build rows, in landing order + +All are post-v1 (REQ-INST-2). Each row has Webby or Sage's named reviewer and its own gate. None of them +runs on .53, .54 or dragon-lin, and none installs Pocket ID on any host. + +1. **P1, loader key.** `components["pocket-id"]` in `mosaic-config.mjs` and the cross-check against + `console.sso`. Gate: test-config.sh green with the new cases, plus Mos's ruling on M1 first. +2. **P2, pin and fetch.** `scripts/pocket-id-pins.sh` with the build-time sha256 and the + attestation evidence, and `component-pocket-id.sh fetch|verify`. Gate: the fetch tests against the local + stand-in; the evidence file shows `gh attestation verify` output for each pinned asset. +3. **P3, unit.** The unit template, `render|install|status|remove|purge`, and the encryption key slot. Gate: + the render and remove tests, and the sandbox bind test. +4. **P4, enroll.** The namespaced bootstrap, the client, the subject, `relink`, and printing the entry (R1), + or R2 if Mos rules for it. Gate: the enroll tests in section 9, and a secret-leak grep over every test log. +5. **P5, Console against the fake Pocket ID.** The section 9 fake-OP start tests, and the + `NODE_EXTRA_CA_CERTS` render flag for the Console unit. Gate: the webui suite, with no change to + `sso.mjs` checks (a diff of `sso.mjs` and `sso-config.mjs` against the base shows none). +6. **P6, installer option.** `--oidc existing|bundled` on REQ-INST-3's install command, calling P2 to P4 in + order, and the docs: `docs/TOOLS.md`, the host run sheet's J1 to J4 steps. Gate: the installer's own + suite against the stubs; the docs list J1 to J4 as the user's steps. + +## Open items for review + +- **M1 (Mos):** the new top-level `components` key in config.json (section 1). +- **M2 (Mos, only if R2):** a tool writing config.json (section 5). R1 needs no ruling. +- **Q1 (Webby):** the private network namespace for enroll (section 4). The other option is to start the + real unit and race `/setup`, which I think is unsafe while agents share the host network. +- **Q2 (Webby):** one-providers-entry for the bundled choice (section 1). REQ-INST-2's wording reads that + way. Mixing a bundled Pocket ID with existing providers would need Sage's ruling. +- Not covered: Pocket ID for Vikunja or Gitea people sign-in (the Researcher note's part 3), and + client-credentials between Mosaic services. Neither is in Q203.