From c57998772d8b34f61a6c545ad16581e6f93f23e3 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 4 Oct 2026 14:48:15 -0500 Subject: [PATCH] docs(prd): PRD draft 0.2 with requirement ids; lead decision 48 PRDY round 2 answers, and Researcher's Vikunja and Pocket ID report as a record. Co-Authored-By: Claude Opus 5.5 --- .../work/2026-10-04_vikunja-pocketid.md | 344 ++++++++++++++++++ docs/SESSIONS.md | 1 + docs/plans/2026-09-26_lead-decisions.md | 20 + docs/prd/mosaic-stack.md | 246 ++++++++++--- 4 files changed, 564 insertions(+), 47 deletions(-) create mode 100644 agents/researcher/work/2026-10-04_vikunja-pocketid.md diff --git a/agents/researcher/work/2026-10-04_vikunja-pocketid.md b/agents/researcher/work/2026-10-04_vikunja-pocketid.md new file mode 100644 index 00000000..59d62258 --- /dev/null +++ b/agents/researcher/work/2026-10-04_vikunja-pocketid.md @@ -0,0 +1,344 @@ +# Vikunja and Pocket ID for slice 1: research report + +Date: 2026-10-04. Seat: Researcher. Assignment: Sage, lead decisions 42 and 45. +Scope: research only. Public docs and public source. No containers started, nothing deployed, no secrets or token files read. + +## How to read the citations + +Source cites name a repository at a fixed tag and a file path. Line numbers are given where I recorded them. Doc cites give the URL. + +| Short | Source | Pin | +|---|---|---| +| V | `code.vikunja.io/vikunja` (mirror `github.com/go-vikunja/vikunja`) | tag v2.7.0, commit `a16be96aa454671fdf213b0fbe411dd38a098418`, 2026-10-02 | +| P | `github.com/pocket-id/pocket-id` | tag v2.17.0, commit `1abc0186fcbf0a31b51d90adb24f9b4c9cea1be6`, released 2026-10-01 | +| G | `github.com/go-gitea/gitea` | tag v28.0.0, commit `15b8a5805adf57c5189602008d38cccfd3c795e0`, released 2026-09-29 | +| VD | `https://vikunja.io/docs/` | fetched 2026-10-04 | +| PD | `https://pocket-id.org/docs/` | fetched 2026-10-04 | + +Two doc caveats. Every Pocket ID docs page I fetched carries the banner "This documentation is for an unreleased version of Pocket ID". It tracks the main branch. Where I rely on a doc claim for a feature, I checked that v2.17.0 source has the feature too. Second, Vikunja docs and v2.7.0 source disagree on webhook retries (section 1.3). I report both. + +"unverified" means I did not confirm the claim in source or docs. + +--- + +# Part 1. Vikunja as the task tracker agents manage through the API + +## 1.1 Versions and API surfaces + +- Current release: v2.7.0, 2026-10-02. Earlier: v2.6.0 2026-08-31, v2.5.0 2026-08-04, v2.4.0 2026-07-19, v2.3.0 2026-04-09, v2.2.2 2026-03-23, v2.2.0 2026-03-20, v2.1.0 2026-02-27 (GitHub releases API). About one minor release a month since July. +- Two HTTP APIs ship in the same binary (V `pkg/routes/routes.go`). + - `/api/v1`: frozen, Swagger-based. PUT creates, POST updates. + - `/api/v2`: Huma v2, OpenAPI 3.1, generated at runtime at `/api/v2/openapi.json`, `/api/v2/openapi.yaml` and `/api/v2/docs`. POST creates, PUT or PATCH updates. List responses use `{items,total,page,per_page,total_pages}`. Errors follow RFC 9457, with 422 for validation. ETag and If-None-Match work on single GETs. Search param is `q`. +- Docs position (VD `api-v2`, `versions`): v2 shipped in 2.4.0 and new clients should build against it. v1 is deprecated at 3.0 (the docs estimate Q3 or Q4 2026, not a promise) and removed at 4.0. Treat v1 as a migration risk. +- Vikunja also ships an MCP module mounted at `/api/v2/mcp` (token scope `mcp.access`, V `pkg/modules/mcp`, `pkg/models/api_routes.go:46`) and an agent CLI called `veans` (VD `veans`). I did not evaluate either. The slice-1 direction says to integrate through the API, so I leave both out. + +## 1.2 API coverage + +Verified from the v1 route table (V `pkg/routes/routes.go`) and the v2 route files (V `pkg/routes/api/v2/`). v2 has matching files for the items marked v2. + +| Capability | Covered | Notes | +|---|---|---| +| Projects: CRUD, duplicate, archive via update | yes | v2 | +| Project views and kanban buckets | yes | `/projects/:p/views/:v/buckets`, v2 `project_views`, `buckets` | +| Move a task between buckets | yes | `POST /projects/:p/views/:v/buckets/:b/tasks` (`pkg/models/kanban_task_bucket.go:255` dispatches `TaskUpdatedEvent`) | +| Tasks: CRUD, bulk update, duplicate, position, read status | yes | `bucket_id`, `position`, `index` and the project-scoped identifier (for example `PROJ-12`) are task fields | +| Assignees (single and bulk) | yes | v2 `task_assignees`. Bot users can be assignees (VD `bot-users`) | +| Labels (single and bulk on a task) | yes | A bot can edit only labels it created (VD `bot-users`) | +| Due, start, end dates, reminders, repeat, priority, percent done, hex colour | yes | task fields, `pkg/models/tasks.go` | +| Relations | yes | kinds: subtask, parenttask, related, duplicateof, duplicates, blocking, blocked, precedes, follows, copiedfrom, copiedto (`pkg/models/task_relation.go`) | +| Comments: CRUD | yes | v2 `task_comments` | +| Attachments: upload, download, list, delete | yes | v2 `task_attachments`. Max size `files.maxsize`, default 20MB (`pkg/config/config.go:483`) | +| Teams and members (admin flag) | yes | v2 `teams` | +| Sharing: project to user, to team, link shares | yes | v2 `project_users`, `project_teams`. `service.enablelinksharing` default true | +| Saved filters, subscriptions, notifications, reactions | yes | | +| Webhooks per project | yes | `/projects/:project/webhooks`, token group `projects_webhooks` | +| Time entries | licensed | v2 `time_entries.go`, feature `time_tracking` (section 1.6) | + +Token reach: the token-scopable groups include `projects_views`, `projects_buckets`, `tasks_attachments`, `tasks_assignees`, `tasks_labels`, `tasks_comments`, `tasks_relations`, `teams_members`, `projects_webhooks`, `projects_views_tasks` (V `pkg/models/api_routes.go:190-216`). So everything an agent needs for tasks can be done with a scoped token. + +### What an agent cannot do through the API + +Verified unless marked. + +1. Create or manage bots, create or revoke API tokens. The route groups `tokens`, `user_*` (this includes `user/bots` by the prefix rule), `subscriptions`, `oauth_authorize`, `mcp` and `token_test` are excluded from token scopes (V `pkg/models/api_routes.go:259-267`). Only a logged-in human session (JWT) reaches them. A token cannot mint another token. +2. Read or change user settings, user-level webhooks (`/user/settings/webhooks`), or subscriptions. Same exclusion. +3. Instance administration (list or create or disable users, admin overview, invite links). Gated by the `admin_panel` licence feature and instance-admin status (`pkg/routes/routes.go:450-457`, `pkg/license`). Without a licence there is no admin API (section 1.6). +4. Make a bot create another bot (VD `bot-users`). +5. Create users. No unlicensed API route does this. Options are the CLI `vikunja user create`, `/register` while `service.enableregistration` is true (default), or OIDC first login (section 1.5). +6. Get a webhook for `project.created`, team events, label events (section 1.3). +7. Use CalDAV or feed basic auth as a bot (VD `bot-users`). +8. Unverified: whether a token with `expand` params can read everything a JWT user can. I confirmed expand scopes are enforced on task routes, not on every route. + +## 1.3 Webhooks, events and sync + +### Webhooks + +- Per-project webhooks: `/projects/:project/webhooks` (v1 CRUD, group `projects_webhooks`). Enabled by default (`webhooks.enabled`). +- User-level webhooks exist at `/user/settings/webhooks`, only for user-directed events, and are not reachable with a token. +- Events registered for webhooks (V `pkg/models/listeners.go:64-79`, plus user-directed ones registered elsewhere): + `task.created`, `task.updated`, `task.deleted`, `task.assignee.created`, `task.assignee.deleted`, `task.comment.created`, `task.comment.edited`, `task.comment.deleted`, `task.attachment.created`, `task.attachment.deleted`, `task.relation.created`, `task.relation.deleted`, `project.updated`, `project.deleted`, `project.shared.user`, `project.shared.team`; user-directed: `task.reminder.fired`, `task.overdue`, `tasks.overdue`. +- Not registered: `project.created` (dispatched at `pkg/models/project.go:1066` but not exposed), team events, label add or remove on a task. A bucket move fires `task.updated` (`kanban_task_bucket.go:255`). A label change on a task fires no event I could find (`label_task.go` dispatches none). +- Payload: `{event_name, time, data:{task, doer, ...}}`. If a secret is set, the header `X-Vikunja-Signature` carries HMAC-SHA256 of the raw body. Optional Basic auth. User-Agent `Vikunja/`. (V `pkg/models/webhooks.go`, VD `webhooks`.) +- SSRF guard: loopback, private and link-local targets are refused by default. Allow them with `outgoingrequests.allownonroutableips` (`pkg/config/config.go:238`). `webhooks.allownonroutableips` is deprecated and maps to it (`config.go:546-548`). A Mosaic receiver on localhost or a private network needs that flag. +- Delivery guarantee: **the docs and source disagree.** VD `webhooks` says "Webhooks are delivered once. Failed deliveries are not retried." V v2.7.0 source (`pkg/events/events.go`, `WebhookDeliveryListener`) sets Retry with MaxRetries 5, exponential backoff and a poison queue. The event bus is an in-process watermill GoChannel and I found no persistence, so a Vikunja restart probably drops in-flight deliveries (inference, unverified). Timeout is `webhooks.timeoutseconds`, default 30. Design the receiver to be idempotent and keep a poll fallback. + +### Sync by polling (the reliable path) + +- `GET /tasks` and `GET /projects/:id/tasks` take `filter`, `sort_by`, `order_by`, `filter_include_nulls`, `expand` and `page` / `per_page` (V `pkg/models/task_collection.go`, `task_collection_sort.go`). +- Filter example: `updated > 2026-10-04T00:00:00Z`, sorted by `updated`. Filterable fields: id, title, description, done, done_at, due_date, created_by_id, project_id, repeat_after, priority, start_date, end_date, hex_color, percent_done, uid, created, updated, position, bucket_id, index, plus assignees, labels, reminders, created_by. Comparators: `=`, `>`, `>=`, `<`, `<=`, `!=`, `like`, `in`, `not in`. +- `expand` accepts subtasks, buckets, reactions, comments, comment_count, time_entries_count, is_unread. +- Page size is capped by `service.maxitemsperpage` (default 50), so loop on `page`. +- Soft-deleted tasks are kept 30 days with `deleted_at`. A poll sees deletes only if it reads deleted rows, and I did not confirm that an API call returns them. Treat deletes as unverified for polling; `task.deleted` webhooks cover it when delivered. +- Single-object GETs support ETag and `If-None-Match`. +- VD `filters` documents the filter syntax. I did not read that page. The filter behaviour above comes from source. + +## 1.4 API tokens and bot users + +### Tokens + +- Format `tk_` plus 40 hex characters. Stored as SHA-256 of the token. Cleartext is returned once (V `pkg/models/api_tokens.go:73`, v2 `api_tokens.go` description: "returned once in this response and is never readable again"). +- Fields: `title`, `permissions` (map of route group to a list of permission names, validated by `PermissionsAreValid`, `api_routes.go:590`), `expires_at` (required, `api_tokens.go:60`), `owner_id`. +- Permission names per group: `read_all`, `read_one`, `create`, `update`, `delete`, plus `_bulk` variants where they exist. `/api/v1/routes` lists the real set. In v2, PATCH is accepted as an alias of `update` (`api_routes.go:276-281`). +- Expiry: required; I found no maximum in source. Expired tokens fail (`api_tokens.go:356`). Tokens of disabled or locked owners are inert. +- Created in the UI **and** through the API (`POST /api/v2/tokens`), but only with a human JWT (section 1.2, item 1). An admin cannot mint tokens for arbitrary users. v2 lets a caller mint for `owner_id` set to a **bot the caller owns** (v2 `api_tokens.go:49`). `GET /tokens?owner_id=` lists a bot's tokens and `DELETE /tokens/{id}` revokes them (`api_tokens.go:40`). +- One service identity per role works: one bot per role, one token (or several) per bot. + +### Bot users (VD `bot-users`, V `pkg/routes/api/v2/bot_users.go`, available from 2.4.0) + +- Username must start with `bot-` (`pkg/user/user_create.go:151`). Normal users cannot register that prefix (`user_create.go:222`). +- No password, no email, no interactive sign-in. +- Owned by a human user. Deleting the owner deletes the bots. Bots cannot own bots. Link shares cannot create bots. +- Can be shared into projects, assigned to tasks, mentioned. A project a bot creates is owned by the bot's owner, and the bot gets admin on it. +- Owner and bots share labels. A bot edits only labels it created. +- v2 endpoints: `/user/bots` (list, create) and `/user/bots/{bot}` (read, update, delete). The exact create body is unverified; read `/api/v2/docs` on the target version. +- No licence is needed. I found no `RequireFeature` on these routes. + +## 1.5 Authentication + +- Local auth: `POST /login` (v1 and v2) with username and password, plus TOTP if enabled. Returns a short-lived JWT (`service.jwtttlshort`, default 600 s) and a refresh cookie (V `pkg/config/config.go:378`). `auth.local.enabled` (default true) switches local login. +- LDAP and OIDC are available. +- **OIDC in Vikunja** (VD `openid`, V `pkg/modules/auth/openid/openid.go`, `providers.go`): + - Authorization code flow, confidential client (id plus secret). No PKCE setting on Vikunja's side that I found; unverified. + - Config: `auth.openid.enabled`, then per provider `auth.openid.providers..{name, authurl, clientid, clientsecret, scope, logouturl, forceuserinfo, emailfallback, usernamefallback, requireavailability}`. Environment form `VIKUNJA_AUTH_OPENID_PROVIDERS__`. Default scope `openid profile email`. + - Callback URL is `https:///auth/openid/` (VD `openid`). The key is matched in lower case in the Pocket ID recipe. + - Discovery from `/.well-known/openid-configuration`. + - Username comes from `preferred_username`, then userinfo, then `nickname`, else random. + - **Team sync** uses a custom claim `vikunja_groups`: a list of `{name, oidcID, description?, isPublic?}` (`openid.go:107`). `oidcID` is a string under 250 characters. Teams are created and synced at login and marked "(OIDC)". + - Account linking: `emailfallback` and `usernamefallback` must both be true to link an OIDC login to an existing local account. Bots are rejected on that path. VD and the Pocket ID recipe both warn that this lets the provider act as a local user whenever it controls those claims. + - **No SCIM and no bulk import** (VD `openid`). A person must log in once before being assignable. +- Vikunja is also its own OAuth2 server (VD `oauth-server`, 2.3+): authorization code with mandatory PKCE S256, no client registration, `vikunja-` custom-scheme redirects only. It is for sign-in of first-party clients such as `veans`. It is not a service-account mechanism, and the OAuth authorize endpoint rejects API tokens (V `CHANGELOG.md`, GHSA-v3p6-34mc-hj7v). + +## 1.6 Licence gating: what a free instance lacks + +- `pkg/license` features: `admin_panel`, `time_tracking`, `audit_logs`, `user_invites` (V `pkg/license/license.go`). +- `/api/v1|v2/admin/*` is behind `RequireFeature(admin_panel)` plus `RequireInstanceAdmin` (V `pkg/routes/routes.go:450-457`). The CLI `vikunja user set-admin` refuses on a free instance (V `pkg/cmd/user.go`). +- The licence key is `license.key` / `VIKUNJA_LICENSE_KEY` (VD `pro`). With no key the instance makes no outbound licence calls. With a key it contacts `console.vikunja.io` and `check.vikunja.io` at startup and daily and sends the key, instance id, version, DB type, user counts, host OS and a container flag. A 72 h cache applies. It falls back to community mode silently. A user cap applies if the licence includes one. +- Consequence for Mosaic: the minting flow in Part 3 needs none of this. Do not plan around instance admin. + +## 1.7 Deployment + +- Image: Docker Hub `vikunja/vikunja`. Tags `latest`, `2`, `2.7`, `2.7.0` (pushed 2026-10-02T17:47Z), `unstable`. Architectures amd64, arm64, arm. Pin the full version tag. +- The image is one static binary on `scratch`. Runs as UID 1000, port 3456. `VIKUNJA_SERVICE_ROOTPATH=/app/vikunja/`, `VIKUNJA_DATABASE_PATH=/db/vikunja.db` (V `Dockerfile`). Mount `/app/vikunja/files` and `/db` and chown them to 1000. Label `org.opencontainers.image.licenses='AGPLv3'`. +- DB types: sqlite (default), postgres, mysql (VD `full-docker-example`). +- Key config (env prefix `VIKUNJA_`, upper-cased with underscores; full list in V `config-raw.json`, VD `config-options`): + - `service.publicurl`, `service.interface` (`:3456`), `service.secret` (JWT signing, generated if empty, set it so tokens survive restarts), `service.timezone` + - `service.enableregistration` (default true), `service.enablelinksharing` (default true), `service.enablecaldav` + - `service.jwtttlshort` (default 600), `service.maxitemsperpage` (default 50) + - `database.type|host|user|password|database|path|sslmode|schema` + - `files.basepath`, `files.maxsize` (20MB) + - `webhooks.enabled`, `webhooks.timeoutseconds`, `outgoingrequests.allownonroutableips` + - `ratelimit.enabled` (default false), `ratelimit.kind` (user), `ratelimit.limit` (100), `ratelimit.period` (60) + - `auth.local.enabled`, `auth.openid.*`, `cors.enable`, `audit.enabled` (licensed feature), `license.key` +- The CLI is in the image: `vikunja user create|list|set-admin|change-status|reset-password|delete`, `doctor`, `dump`, `restore`, `migrate`, `repair` (VD `cli`). +- Backup guidance: VD `what-to-backup` (database plus files directory). + +### What the installer needs + +**Path A, existing instance** +1. Base URL. +2. Version at least 2.4.0 (bots and v2). Check with `GET /api/v1/info`; I did not verify that endpoint's output shape. +3. A human owner account that Mosaic controls, with username and password (and TOTP secret if enabled). Local login must be enabled, or the account must be able to produce a JWT some other way. An OIDC-only instance (`auth.local.enabled=false`) has no documented way to get that JWT headlessly. That would block minting. Unverified. +4. `service.publicurl` correct, so links and webhooks work. +5. If the Mosaic receiver is on a private address, `outgoingrequests.allownonroutableips=true` on their instance. That is an operator change on a system Mosaic does not own, so surface it as a prerequisite. +6. Confirm the instance admin does not mind bot users and long-lived tokens. + +**Path B, bundled instance** +1. Compose service from the pinned image with a Postgres or SQLite volume, `/app/vikunja/files` volume, UID 1000 ownership. +2. Secrets via runtime-only means: `VIKUNJA_SERVICE_SECRET`, DB password. Nothing in the repository or image. +3. `VIKUNJA_SERVICE_PUBLICURL`, and `VIKUNJA_OUTGOINGREQUESTS_ALLOWNONROUTABLEIPS=true` if the receiver is on the compose network. +4. Create the owner once with `vikunja user create -u -e -p ` inside the container, then set `VIKUNJA_SERVICE_ENABLEREGISTRATION=false`. The CLI creation path needs no HTTP admin. +5. Keep `auth.local.enabled` true until the tokens are minted. If Pocket ID is wired later, keep one local owner account. + +## 1.8 Licence + +- Root `LICENSE` is the GNU AGPL v3. Source headers say "version 3 or later". The image label says AGPLv3 (V `LICENSE`, `Dockerfile`). +- AGPL §13 attaches a source-offer duty to **modified** versions that users interact with over a network. Mosaic would call the API of an unmodified upstream image. My reading is that this is neither modification nor linking, so §13 would not be triggered by Mosaic. That is a reading of the text, not legal advice. Treat the legal consequence as unverified and ask counsel if the stack ever patches Vikunja, redistributes a modified image, or embeds Vikunja code (decision 43 says it will not). +- Shipping the unmodified upstream image still means distributing AGPL software. Keep the image reference upstream and unmodified, and keep the licence notice available. + +--- + +# Part 2. Pocket ID as SSO for people + +Version: v2.17.0 (2026-10-01). Licence: BSD 2-Clause (P `LICENSE`). Stack: Go backend, Gin, fosite OAuth library. Storage: SQLite by default, PostgreSQL via `DB_CONNECTION_STRING` (PD `configuration/environment-variables`). Single process; v2 refuses to run two instances on one database (PD `setup/major-releases/migrate-v2`). + +## 2.1 Release and maturity + +- Tags: v2.17.0 2026-10-01, v2.16.0 2026-09-20, v2.15.0 2026-09-19, v2.14.0 2026-08-18, v2.13.0 2026-08-07, v2.12.0 2026-07-29, v2.11.0 2026-07-13, v2.10.0 2026-07-10, v2.9.0 2026-06-16, v2.8.0 2026-05-31, v2.7.0 2026-05-11, v2.6.2 2026-04-21 (GitHub releases API). Twelve releases in about five months. Minor releases come every one to four weeks. +- Within a major version upgrades need no config change; the database migrates itself at startup; downgrade is refused unless `ALLOW_DOWNGRADE=true` (PD `setup/upgrading`). +- Images: `ghcr.io/pocket-id/pocket-id` and `pocketid/pocket-id`. Port 1411. Needs `APP_URL` (HTTPS for passkeys, except localhost) and `ENCRYPTION_KEY` (at least 16 characters, mandatory since v2.0). Release feature set is moving fast. Pin the tag. +- I did not assess project governance or the number of maintainers. Unverified. + +## 2.2 OIDC features + +Verified in P and in the discovery document built at `backend/internal/controller/well_known_controller.go:98-127`. + +| Item | Result | +|---|---| +| Flows | authorization code, refresh token, device authorization, client credentials (confidential clients only) | +| Response types | `code` only. No implicit flow | +| PKCE | `plain` and `S256` advertised. Per-client `pkceEnabled`. When on, the authorize request must carry a `code_challenge` (`oidc/authorization_service.go:750-757`). Public clients are supported (`isPublic`) | +| Pushed authorization requests | supported, per-client requirement flag | +| Token endpoint auth | `client_secret_basic`, `client_secret_post`, `none`; federated JWT client assertion (PD `guides/oidc-client-authentication`) | +| Scopes | `openid`, `profile`, `email`, `groups`, `offline_access`, plus permissions of APIs you define | +| Claims | `sub`, `given_name`, `family_name`, `name`, `display_name`, `email`, `email_verified`, `preferred_username`, `picture`, `groups`, `auth_time`, `amr` | +| Tokens | ID token 1 h, access token JWT (RFC 9068) 60 min configurable per client, refresh token 30 days of inactivity configurable per client, auth code 15 min (PD `guides/scopes-and-claims`). RS256 by default | +| Revocation on refresh | at each refresh, it checks that the user exists, is not disabled and is still in an allowed group (PD `guides/scopes-and-claims`, `oidc/claims_service.go:37-62`) | +| Logout | RP-initiated end-session plus back-channel logout | +| Per-client access control | allowed user groups (`isGroupRestricted`, `PUT /api/oidc/clients/:id/allowed-user-groups`) | +| Client ID Metadata Documents | supported when an allowlist is configured | +| Callback URL wildcards | strict rules since v2.0 | + +### Client registration + +- Admin UI and REST: `POST /api/oidc/clients`, `PUT /api/oidc/clients/:id`, `DELETE`, secrets at `/api/oidc/clients/:id/secrets` (create, list, delete), allowed groups at `/allowed-user-groups` (P `controller/oidc_controller.go:30-54`). The client id can be supplied on create (`min=2,max=128`). A client secret can be supplied by the caller (`min=16`) or generated, with an optional expiry (`dto/oidc_dto.go`). The secret is shown once. +- No dynamic client registration endpoint is advertised in discovery. + +### Groups and custom claims + +- `groups` claim: array of group **names** (not display names) when the `groups` scope is requested (`claims_service.go`, PD `guides/scopes-and-claims`). +- Custom claims are set on a user or on a group (`PUT /api/custom-claims/user/:userId` and `/user-group/:userGroupId`). They are released with the `profile` scope, in the ID token and userinfo, **not** in the access token. Values that parse as JSON are emitted as JSON. Reserved names (`groups`, `email`, `sub`, `name`, and so on) cannot be used (`service/custom_claim_service.go:22-45`). +- Collision rule: a user's own claim wins over a group claim. If several groups set the same claim, which wins is undefined in the docs, and in source the first one found in the database order is kept (`custom_claim_service.go:247-289`). **Do not spread one claim name over several groups.** +- Fit with Vikunja team sync: Pocket ID's `groups` claim is a list of strings, but Vikunja wants `vikunja_groups` as objects with `name` and `oidcID`. The way to bridge it is a custom claim named `vikunja_groups` with a JSON value such as `[{"name":"mosaic","oidcID":"mosaic"}]`, set per user (or on one group). It needs the `profile` scope on the Vikunja provider, which is already in Vikunja's default scope. That is untested; I did not run it. + +### User and group provisioning + +- Users: `GET|POST /api/users`, `PUT /api/users/:id`, `PUT /api/users/:id/user-groups`, `DELETE`. Create body: username, email, names, `isAdmin`, `disabled`, `userGroupIds` (P `dto/user_dto.go`). +- Groups: `GET|POST /api/user-groups`, `PUT /:id`, `PUT /:id/users`, `PUT /:id/allowed-oidc-clients`. +- First sign-in is a passkey. An admin can create a login code through `POST /api/users/{id}/one-time-access-token` and the user adds a passkey with it (PD `setup/user-management`, `guides/sign-in-methods`). Signup tokens exist for self-service signup (`/api/signup-tokens`). +- LDAP sync of users and groups is built in (`LDAP_*`, PD `configuration/ldap`). +- **SCIM is outbound only.** Pocket ID acts as a SCIM client and pushes users and groups to an app's SCIM endpoint (PD `configuration/scim`, P `scimsync/`). It does not expose a SCIM server. Vikunja states it has no SCIM (VD `openid`) and I found no SCIM in Gitea source (grep for "scim", case-insensitive, over G v28.0.0 returned nothing). So SCIM does not help with either target. + +## 2.3 Machine and non-passkey credentials + +Stated plainly: people sign in to Pocket ID only with passkeys, login codes, "sign in with another device" and (optionally) a login code by email. There is no password login (PD `guides/sign-in-methods`). + +For machines, Pocket ID does offer: + +1. **Client credentials grant.** Confidential clients only. The client needs an "API" defined in Pocket ID (an audience URL plus named permissions) and a client-access (M2M) grant for the permissions it may request. The token request carries `resource=` and `scope=`. The access token has no identity scopes, subject `client-`, lifetime per client, no refresh token (P `oidc/token_handler.go:66-110`, `oidc/client.go:50-60`, PD `guides/apis`). This is the OIDC-standard service identity. +2. **Federated client credentials**: the client authenticates with a JWT from another issuer (Kubernetes, Azure, GitLab, a tailnet) instead of a secret (PD `guides/oidc-client-authentication`). +3. **Device authorization grant** (user-in-the-loop on another device). +4. **Admin API keys** (section 2.4). These are for Pocket ID's own REST API, not for other apps. + +What this does **not** give you: a way for an agent to obtain a Vikunja or Gitea credential. A Pocket ID client-credentials token is a JWT with `aud` equal to an API you define. Vikunja's documented use of OIDC is user login via authorization code (VD `openid`). Gitea's OIDC is likewise an authentication source for web login (PD `client-examples/gitea`). I found no documented setting in either that accepts an external bearer token as an API credential. I did not read the Vikunja or Gitea auth middleware to rule it out, so treat this as unverified. Plan on per-role tokens issued by each service, as the assignment says. + +## 2.4 Admin API and API keys + +- "Everything the admin UI does goes through the REST API" (PD `api`). Endpoints list: PD `api/endpoints`. Header `X-API-Key`. List endpoints take `pagination[page]` and `pagination[limit]`. Errors have a stable `code` and a `request_id`. +- Automatable through it: users, user groups, group membership, OIDC clients and their secrets and allowed groups, custom claims, APIs and their permissions and client grants (`/api/apis...`), SCIM providers, signup tokens, one-time access tokens, application configuration, audit log, LDAP sync trigger. +- API key properties (P `apikey/`, `middleware/api_key_auth.go`): + - A key belongs to the user who created it and acts with that user's rights. The middleware requires `user.IsAdmin` on admin routes. The model has no scopes field, so keys are all-or-nothing. A non-admin's key reaches only the non-admin routes. + - Required `expiresAt` in the future. Name 3 to 50 characters. Token is 32 random alphanumeric characters, stored as SHA-256, returned once. An expired key can be renewed (a new token is issued). The server emails the owner before expiry when configured. + - Creating or renewing a key needs a normal logged-in session. It rejects an API key (`apikey/module.go:70-71`, `authWithoutApiKey`). A key cannot mint keys. + - `STATIC_API_KEY` (env, at least 16 characters, supports `_FILE`): a fixed admin key that acts as a synthetic "Static API User" with `IsAdmin` true. No expiry, no scope (P `common/env_config.go:70,222`, `apikey/service.go:initStaticApiKeyUser`). Docs: "Prefer regular API keys where you can." +- Headless bootstrap: the first admin normally registers at `/setup` and adds a passkey in a browser. The setup count ignores the static user (`usersignup/service.go:220-228`), so with `STATIC_API_KEY` set an installer can create a first real admin with `POST /api/users` and then mint a login code for the passkey step. The passkey still needs a human at a browser. A recovery CLI exists: `pocket-id one-time-access-token ` (PD `troubleshooting/account-recovery`). + +## 2.5 Official integration recipes + +- **Vikunja** (PD `client-examples/vikunja`): create a client with callback `https:///auth/openid/pocketid`, restrict by allowed groups, then set `auth.openid.providers.PocketID.{name, authurl=, clientid, clientsecret, scope="openid profile email", forceuserinfo}` or the env form `VIKUNJA_AUTH_OPENID_PROVIDERS_POCKETID_*`. Optional `usernamefallback` and `emailfallback` link existing local accounts, with a security warning about it. Note: the recipe also shows `auth.openid.redirecturl`. I did not find that key in V v2.7.0 `pkg/config/config.go`, where the redirect URL arrives from the client request (`openid.go:52,625`). Treat that line as stale (unverified which). Vikunja's own docs `openid-example-configurations` mention no Pocket ID (grep returned 0). +- **Gitea** (PD `client-examples/gitea`): callback `https:///user/oauth2/PocketID/callback`. In Gitea, Site Administration, Authentication Sources, OAuth2, provider "OpenID Connect", name `PocketID`, auto-discovery URL from Pocket ID, "Skip local 2FA" on, additional scopes `openid email profile`. The source name must match the callback path. +- Gitea group mapping flags exist in source: `--group-claim-name`, `--admin-group`, `--restricted-group`, `--group-team-map`, `--required-claim-name` (G `cmd/admin_auth_oauth.go:99-129`). I did not find an official Pocket ID recipe that uses them; that is untested. + +--- + +# Part 3. Recommendation + +## 3.1 How a role's credential is minted and handed out at launch + +Per-role identities, minted once by a gated step, then supplied to the role at launch as runtime-only secrets (matches AGENTS.md invariant 3, decision 44, and the direction doc: "Provided at launch first. Minting stays gated"). + +**Vikunja** (no admin or licence needed): +1. A Mosaic-controlled human owner account exists (bundled: created by CLI. Existing: the operator provides it). +2. With the owner's JWT (`POST /api/v2/login`), for each role `R`: + a. `POST /api/v2/user/bots` with username `bot-`. + b. Share the projects the role may touch to that bot, through the project-users route. Choose read or write. (v2 path unverified, see `/api/v2/docs`. v1 is `PUT /projects/:p/users`.) + c. `POST /api/v2/tokens` with `owner_id=`, a `permissions` map limited to the groups the role needs (for example `tasks`, `tasks_comments`, `tasks_assignees`, `tasks_labels`, `tasks_relations`, `tasks_attachments`, `projects`, `projects_views`, `projects_buckets` with the specific verbs), and an `expires_at`. +3. The cleartext `tk_...` token is returned once. Write it straight to the role's secret store (read-only mount or environment variable at launch). Do not log it. +4. The owner credential is used only in this step. Keep it out of the role launch path. +5. Rotation: mint a new token for the same bot, switch the role over, then `DELETE /tokens/{id}`. Expiry is mandatory, so rotation is forced. +6. Revocation: `DELETE /tokens/{id}` as the owner, or delete the bot. + +**Gitea** (public mechanics; Mosaic's Gitea setup and tokens not read): +1. A Gitea user per role (admin creates it: `POST /api/v1/admin/users` with a token holding the admin scope, or the CLI `gitea admin user create`). Access to repos is granted through collaborator or team membership, not through the token. +2. Mint the token. Two routes: + - Server-side CLI: `gitea admin user generate-access-token --username --token-name --scopes --raw` (G `cmd/admin_user_generate_access_token.go`). Needs shell access to the Gitea host or container, and no HTTP credential. + - HTTP: `POST /api/v1/users/{username}/tokens` with `{name, scopes}`. The route requires **basic auth**, either the user's own password or an admin's password (`reqSelfOrAdmin()` plus `reqBasicOrRevProxyAuth()`, G `routers/api/v1/api.go:1145-1149`). A bearer token is rejected on that route. Unverified: how this behaves when the account has 2FA enabled. +3. Gitea tokens have **no expiry field** (G `models/auth/access_token.go:20-34`). Rotation is manual: create, switch, delete (`DELETE /users/{username}/tokens/{id}`). A token cannot mint a token of broader scope (`CanCreateChildScope`, `routers/api/v1/user/app.go:131-140`). +4. Use the narrowest scopes (for example `write:repository`, `write:issue`, `read:user`; add `public-only` only for public repos). The scope list is in G `models/auth/` token-scope code and the swagger example. +5. Admin `sudo` (`Sudo` header or `?sudo=`) lets an admin token act as another user. Do not hand that to agents. + +**Pocket ID's role in slice 1: none for agents.** It is for people only. Design now, wire later (decision 44): +- One Pocket ID client per app (Vikunja, Gitea), restricted to a Mosaic user group. +- Keep Vikunja `auth.local.enabled` true and keep the local owner account, since bot minting needs a password login and that account. +- Provision `vikunja_groups` as a per-user custom claim if team sync is wanted. +- Pocket ID client-credentials can later authenticate **Mosaic's own services** to each other. Define an API in Pocket ID and issue M2M grants. That is a separate decision from tracker credentials. + +## 3.2 What needs an admin token (gated actions for Jason) + +| Action | Credential | Why it is gated | +|---|---|---| +| Vikunja: create the owner account (bundled) | shell access to the container, CLI | creates the root of trust for all bots | +| Vikunja: create bots, share projects, mint tokens | the owner's JWT (password login) | the owner password is the high-value secret. Everything below it is scoped and expiring | +| Vikunja: change `outgoingrequests.allownonroutableips`, disable registration (existing instance) | instance operator | changes someone else's server settings | +| Gitea: create a role user | admin API token (`write:admin`) or CLI | creates identities | +| Gitea: mint a role token | CLI on the host, or an admin's or user's password | no expiry and a basic-auth credential | +| Pocket ID: create an admin API key | admin passkey session (UI only; a key cannot create keys) | | +| Pocket ID: `STATIC_API_KEY` | an environment secret on the server | full admin, no expiry. If used for install, remove it afterwards | +| Pocket ID: create or change clients, groups, users | admin API key | changes who can sign in to what | + +I do not recommend keeping any of these admin credentials in the agent launch path. + +## 3.3 Open risks + +1. **Owner password is the single high-value secret on the Vikunja side.** With it one can mint tokens for every bot, and a bot's tokens cannot be minted any other way. Store it outside agent reach, use it only in the minting step. If the owner is deleted, every bot and its tokens are deleted. +2. **No Vikunja instance admin without a paid licence.** Bulk user management and an admin overview are unavailable. The plan avoids it, but an existing instance's admin may have other expectations. Licence use also sends telemetry to Vikunja's servers (section 1.6). +3. **v1 deprecation.** If the adapter is written against `/api/v1`, it breaks at 4.0 (deprecation at 3.0, date not committed). Build on v2. +4. **Webhook delivery is uncertain.** Docs say no retries, v2.7.0 source retries, and the bus is in-memory. Use webhooks as a hint, polling with `updated > ` as the truth. +5. **SSRF default blocks private receivers.** A localhost or compose-network receiver needs `outgoingrequests.allownonroutableips=true`. On a user-supplied instance, that is a prerequisite the operator may refuse. +6. **Event gaps.** No webhook for `project.created`, label changes, or team changes. Poll those. +7. **Tokens cannot do identity management.** Any flow that needs a new bot (a new role) needs the owner session again. Plan for it. +8. **Gitea tokens never expire** and token creation needs a password or host shell. Make rotation a scheduled, recorded step. +9. **Pocket ID passkey-only login.** The first admin needs a human and a browser. Headless install can create users but not their passkeys. Bundled install must pause for a person. Recovery is a CLI one-time link. +10. **Pocket ID release pace.** Roughly one release every two weeks in 2.x, with the docs site ahead of releases. Pin `v2.17.0`, read the changelog on every bump. +11. **SCIM does not apply.** Pocket ID's SCIM is outbound only and neither Vikunja nor Gitea documents a SCIM server. A Pocket ID user must log in to Vikunja once before they can be assigned. +12. **Group claim shape mismatch.** Pocket ID `groups` is a string list. Vikunja team sync needs `vikunja_groups` objects. The bridge is a custom claim, per user (collisions across groups are undefined). Untested. +13. **AGPL.** My reading is that an unmodified image plus API calls does not trigger §13. Not legal advice (section 1.8). +14. **Docs and source drift** shows up in two places already (Vikunja webhook retries, Pocket ID recipe `redirecturl`). Pin versions and re-verify at each bump. + +## 3.4 Unverified list + +- Exact v2 request bodies for `POST /user/bots` and for sharing a project with a user. +- Whether any maximum token lifetime exists in Vikunja (none found in `api_tokens.go`). +- Whether an `expires_at` in the past is rejected at creation. +- Whether polling returns soft-deleted tasks. +- Whether Vikunja has a PKCE option for its OIDC client. +- Whether Vikunja or Gitea accept any external bearer JWT as an API credential (none documented; middleware not read). +- Whether OIDC-only Vikunja instances can produce an owner JWT headlessly. +- Gitea basic-auth token creation on a 2FA account. +- Legal consequence of AGPL for the Mosaic stack. +- Pocket ID project governance and maintainer count. +- `/docs/filters` and `/docs/api-documentation` on vikunja.io were not read. Filter behaviour above comes from source. +- Whether a Pocket ID custom claim `vikunja_groups` works end to end in Vikunja team sync (not run). + +## Scratch material + +Cloned sources and fetched doc text live in `/tmp/rsrch/` (outside the repository): `vikunja` (v2.7.0), `pocket-id` (v2.17.0), `gitea` (v28.0.0), and `*.txt` doc extracts. Nothing in this repository changed except this file. Nothing was committed or pushed, no containers were started, no messages were sent, and `docs/SESSIONS.md` was not touched because the assignment limits writes to `agents/researcher/work/`. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 441181a2..0c6c5a42 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -471,3 +471,4 @@ are never rewritten or removed; corrections are new entries. 2026-10-04T19:19:14Z | Sage (T3 Claude Code, thread 1ef1e4f8) | slice 1 data model received | Darkwing's note (948b94ce) and prototype committed as records; lead decision 46 accepts 6.1-6.5, 6.7, 6.8 and sends 6.6 (PM launching sessions) to Jason in PRDY round 3 2026-10-04T19:26:12Z | Filbert (T3 Claude Code, thread 9cb9731e) | meta-harness survey done (slice 1 step 8) | agents/filbert/work/meta-harness-survey-2026-10-04.md sha256 05f83807…0989; Pi tool_call is the only fail-closed hook of the three, so hard lines are credential scope, verbs, tool ceiling and container; no commits 2026-10-04T19:26:50Z | Sage (T3 Claude Code, thread 1ef1e4f8) | meta-harness survey received | Filbert's survey (05f83807) committed as a record; lead decision 47 rules on its section 8; two DEFERRED items (host-seat --approve, worker provider-key exposure) +2026-10-04T19:48:15Z | Sage (T3 Claude Code, thread 1ef1e4f8) | PRDY round 2, PRD 0.2 | lead decision 48; PRD draft 0.2 with requirement ids; Researcher's Vikunja and Pocket ID report (fcfc970f) committed as a record (Researcher wrote no SESSIONS line, per its limits) diff --git a/docs/plans/2026-09-26_lead-decisions.md b/docs/plans/2026-09-26_lead-decisions.md index 3f71f1ff..e6affb1f 100644 --- a/docs/plans/2026-09-26_lead-decisions.md +++ b/docs/plans/2026-09-26_lead-decisions.md @@ -753,3 +753,23 @@ which stay with him. Each item names who decided it and what happened. environment or files. The existing exposure of worker provider keys goes in DEFERRED. 7. Build order: Pi, then Claude Code, then Codex. +48. **PRDY round 2 answers (2026-10-04).** Jason, thread 1ef1e4f8. The + answers fill PRD draft 0.2 (`docs/prd/mosaic-stack.md`), with + requirement ids: + 1. Out of scope for v1: "all". That covers other businesses, credential + minting, Codex, Pocket ID and SSO, and access from beyond localhost. + 2. "A". A gated decision reaches the CLI inbox, plus a Discord DM when + it blocks work. + 3. "A". Blocking decisions arrive right away. Everything else goes into + one daily digest. + 4. "A". Sage is PM and Darkwing is CTO for Mosaic Stack. + 5. "A". `mosaic` talks to an agent session the stack launches and owns, + running Pi or Claude Code headless. T3 stays a development tool. + 6. "A". The PM launches role sessions within limits set in the business + file: role instances only, 4 Opus and 4 Sonnet, each launch logged, + revocable with one word. That settles open question 6.6 from + decision 46. + Researcher's report + (`agents/researcher/work/2026-10-04_vikunja-pocketid.md`, sha256 + fcfc970f…) was committed as a record with this entry. The PRD's + technical considerations draw on it. diff --git a/docs/prd/mosaic-stack.md b/docs/prd/mosaic-stack.md index 6665039e..58eff9ce 100644 --- a/docs/prd/mosaic-stack.md +++ b/docs/prd/mosaic-stack.md @@ -1,81 +1,219 @@ # PRD: Mosaic Stack -- Status: draft, version 0.1. Jason approves it, and once approved it is +- Status: draft, version 0.2. Jason approves it, and once approved it is never edited in place. Changes after approval are a new version. - Owner: Jason. Sage writes it from the PRDY interview. - Template: PRDY "software" (`v1/packages/prdy/src/templates.ts`), filled by hand until PRDY is ported. -- Interview record: Sage's thread 1ef1e4f8. Round 1 was answered on - 2026-10-04 and is recorded as lead decision 45. +- Interview record: Sage's thread 1ef1e4f8. Round 1 is lead decision 45. + Round 2 is lead decision 48. Design inputs are lead decisions 43, 44, + 46 and 47. ## Introduction ### Objective -Working north star, ratified as lead decision 44: "Jason declares +The working north star, ratified as lead decision 44: "Jason declares businesses, projects and roles. Agents in those roles carry the work end to end under declared policy, and only gated decisions reach him." ### Context -See `docs/plans/2026-10-04_foundation-direction.md`. Agents can already do -real work here, but a person has to launch them, relay between them, hand -them credentials and correct them. There's no CLI or complete WebUI for -Jason, and no place where outstanding decisions collect. +See `docs/plans/2026-10-04_foundation-direction.md`. Agents already do +real work here. But a person has to launch them, relay between them, hand +them credentials and correct them. Jason has no CLI and no complete WebUI, +and outstanding decisions don't collect anywhere. ## Users -Jason alone for now. The stack must be built so outside users can install -it later: an installer, no paths or names specific to Jason, and -configuration through the variable layers (round 1, answer D). +For now the only user is Jason. The stack must still be installable by +outside users later, which means: +- an installer; +- no paths or names specific to Jason; +- configuration through the variable layers. + +(Round 1, answer D.) ## Problem statement In priority order (round 1): -1. **Admin falls on Jason.** He launches seats, relays between them, +1. **Admin falls on Jason.** He launches agents, relays between them, finds credentials and corrects them. 2. **Jason can't see what the agents are doing or what's waiting on him.** 3. **Agents drift from what the business needs.** Nothing ties a piece of work to a stated goal. -Credential sprawl wasn't picked as a top problem. The roles work and hard -limit C cover it. - ## Scope and non-goals ### Hard limits -Every one of these is a gated decision, or simply not done (round 1, -"all five"): -- A. The stack never spends money without Jason. -- B. It never speaks externally as Jason without his approval. -- C. Agents never hold Jason's personal credentials. Each role gets its own. -- D. It is not a multi-tenant SaaS in v1. -- E. It doesn't replace Claude Code, Codex or Pi. It wraps them. +The stack doesn't do any of these. Each one is either a gated decision or +off the table (round 1, "all five"): +- A. Spend money without Jason. +- B. Speak externally as Jason without his approval. +- C. Give agents Jason's personal credentials. Each role gets its own. +- D. Become a multi-tenant SaaS in v1. +- E. Replace Claude Code, Codex or Pi. It wraps them. ### Where it runs -Self-hosted first, on Jason's machines and homelab (round 1, "A first"). -Cloud workers aren't ruled out later. - -### In scope for v1 - -Slice 1 of the foundation direction: -- roles, variables and credentials per role; -- decision records and messages addressed by role; -- tasks in Vikunja through its API; -- the `mosaic` CLI, then the WebUI; -- the meta-harness. +Self-hosted, on Jason's machines and homelab (round 1, "A first"). Cloud +workers aren't ruled out later. ### Out of scope for v1 -To be set in round 2. +Round 2, "all": +- businesses other than Mosaic Stack (SetSpark joins after v1); +- minting credentials (v1 hands out tokens Jason creates); +- Codex (v1 covers Pi and Claude Code, and Codex comes next); +- Pocket ID and SSO; +- access from beyond localhost. ## Requirements -To be set in rounds 2 and 3. Each requirement gets a stable id -(`REQ--`). Every task the stack creates must cite one. +Every task the stack creates cites one of these ids. A task with no +parent requirement is refused. + +### Roles + +- **REQ-ROLE-1.** Each role is a reviewed file, `roles/.json` at + version 2. The file holds: + - a contract; + - an authority map over a closed vocabulary of actions; + - the credentials it needs, by service and scope. + + Any action the file doesn't list is gated. +- **REQ-ROLE-2.** Each role instance has one holder at a time. A claim + takes a lock and logs it, and releasing the role or ending the run frees + it. Revoking a stuck holder is gated, and the revoke cites a resolved + decision. +- **REQ-ROLE-3.** A business file declares the role instances, the + arbiters and the credential references. The user writes it, the stack + never writes it, and a missing or invalid file fails closed. +- **REQ-ROLE-4.** The first business is Mosaic Stack, with four role + instances: + - PM, held by Sage; + - CTO, held by Darkwing; + - coder; + - reviewer. + + The schema can also declare CEO, CFO and the other C-suite roles. + +### Variables + +- **REQ-VAR-1.** Variables come in four layers: system, business, project + and agent. Every key is declared in code with a type, the layers allowed + to set it, and a merge rule. + - The most specific layer wins for plain values. + - Limits intersect, so a lower layer can only narrow them. + - An unknown key is refused, and so is a key set at a layer it isn't + allowed in. +- **REQ-VAR-2.** `~/.config/mosaic-dev/config.json` stays the only system + config, and nothing writes it automatically. A secret appears only as a + reference, either a file path or an environment variable name. The + stack checks a referenced file with `stat` and never reads its + contents. + +### Credentials + +- **REQ-CRED-1.** Every role instance has its own token for each service + (Gitea, Vikunja). In v1 Jason creates these tokens by hand. The broker + holds them, and they never enter an agent's environment or files. +- **REQ-CRED-2.** A session that finds only founder credentials stops. + +### Decisions + +- **REQ-DEC-1.** Every decision is a record in an append-only SQLite + table. The record holds: + - the role and run that raised it; + - its class and action; + - the options and a recommendation; + - who resolved it; + - its timestamps. + + A correction is a new row. +- **REQ-DEC-2.** Routing follows the decision's class: + - routine and within-role decisions are logged; + - cross-role decisions go to the business's arbiter for that domain; + - gated decisions go to the human. +- **REQ-DEC-3.** A human resolution comes only from a `mosaic` CLI session + started outside any agent run. Agents reach the decision store only + through a broker. The broker stamps role and run from the launch record. +- **REQ-DEC-4.** A gated decision that blocks work reaches Jason right + away: in the CLI inbox, plus a Discord DM. Everything else goes into one + daily digest. (Round 2, answers 2A and 3A.) + +### Messages + +- **REQ-MSG-1.** Messages are addressed to a role, not a thread. They are + append-only and go to whoever currently holds the role. T3 and Discord + carry messages; the address is still the role. + +### Tasks + +- **REQ-TASK-1.** Tasks live in Vikunja, reached through `/api/v2` with a + bot token for each role. Each task cites a requirement id. Only the + assigned role writes a task's mutable fields. A person's edits come in + as events. +- **REQ-TASK-2.** Polling for changes since the last check is the source + of truth, and webhooks only trigger a check sooner. +- **REQ-TASK-3.** The installer offers two choices: point at an existing + Vikunja, or deploy the bundled one. The bundled one is the unmodified + upstream image. No Vikunja code enters the repository. +- **REQ-TASK-4.** The queue keeps the agents' lock, review rounds and + audit log. No field syncs in both directions. + +### Interface + +- **REQ-CLI-1.** `mosaic` is the front door. From it Jason can: + - talk to the PM; + - see the decision inbox and resolve decisions; + - see tasks and agents; + - follow the trail of any piece of work. +- **REQ-CLI-2.** The PM session that `mosaic` talks to is one the stack + launches and owns, running Pi or Claude Code without a window. The + product doesn't depend on T3. (Round 2, answer 5A.) +- **REQ-WEB-1.** The WebUI shows the same data as the CLI: conversations, + the inbox, tasks, agents and trails. + +### Launching + +- **REQ-LAUNCH-1.** The PM launches role sessions within limits written in + the business file: + - role instances only; + - at most 4 Opus and 4 Sonnet sessions at once; + - every launch logged; + - revocable with one word. + + (Round 2, answer 6A.) + +### Meta-harness + +- **REQ-HARN-1.** The meta-harness generates each session's launch bundle + from the role and the resolved variables: + - the prompt; + - the policy; + - skills; + - typed tools for the vocabulary actions; + - a manifest. + + Pi comes first, then Claude Code. Codex comes after v1. +- **REQ-HARN-2.** Four layers actually enforce the rules: + - credential scope; + - verbs that refuse without a resolved decision; + - the harness's tool limit; + - a container. + + Hooks only refuse early and log, and no document may call them + enforcement. A probe run against the live harnesses must pass before any + build relies on a block. + +### Events + +- **REQ-EVT-1.** Every step in slice 1 emits an event to the append-only + store. Measures are computed from events, never from tagging chat + messages by hand. ## Acceptance criteria and success measures @@ -89,20 +227,34 @@ piece: ## Technical considerations -Ratified as lead decision 44: -- Vikunja is integrated through its API, never annexed. Mosaic Stack's - own work uses a dedicated local instance. The installer offers an - existing instance or the bundled one. -- Decisions and messages live in SQLite, in append-only tables. Run - records stay as write-once files. -- People sign in through Pocket ID, wired in after slice 1. Agents use - service tokens per role. +- Vikunja v2.7.0 is AGPL-3.0. Its API tokens can't create users or mint + more tokens. A Mosaic-owned account mints one bot user and one scoped, + expiring token per role. The owner password for that account is the one + high-value Vikunja secret. Instance admin features need a paid licence, + and the plan doesn't use them. Source: + `agents/researcher/work/2026-10-04_vikunja-pocketid.md`. +- Gitea tokens never expire, and creating one needs a password or the host + shell. Rotation will be a scheduled step with a record. +- Pocket ID v2.17.0 is BSD-2. It offers passkey-only login for people and + a client-credentials flow, but it can't issue tokens that Vikunja or + Gitea accept. Agents use per-service role tokens. Pocket ID is wired in + after v1. +- Decisions, messages, role claims and events share one SQLite file, + `/bus/bus.sqlite`. A guard on every table refuses UPDATE, + DELETE and REPLACE. Run records stay write-once files. +- None of the three harnesses' hooks fails closed by default except Pi's + `tool_call`. Source: `agents/filbert/work/meta-harness-survey-2026-10-04.md`. ## Risks and open questions -To be set in round 3. +Round 3 sets these. Known so far: +- Agents running as the same OS user as Jason can write anything that + user can write. Until seats run as another user or in a container, the + broker is a rule they follow, not a wall. +- The Vikunja owner password and Gitea token creation are gated. Jason + holds them. ## Milestones -Slice 1 first, in the order on the foundation direction page. The -milestones get set once the requirements exist. +Slice 1, in the order on the foundation direction page. The slice 1 brief +maps each step to requirement ids.