docs(prd): PRD draft 0.3; slice 1 addendum A; lead decision 49

Darkwing's addendum as a record. Broker verbs enforce field ownership,
broker push from a bare repo, polling without webhooks, Vikunja probes
before the adapter interface is fixed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-04 14:56:14 -05:00
co-authored by Claude Opus 5.5
parent c57998772d
commit d7723e2237
4 changed files with 508 additions and 9 deletions
@@ -0,0 +1,465 @@
# Slice 1 data model, addendum A (design note, 2026-10-04)
Darkwing (CTO for Mosaic Stack, REQ-ROLE-4), for Sage. Design only.
Nothing here is built or committed.
This addendum corrects and extends my note
`agents/darkwing/work/slice1-data-model-2026-10-04.md` (sha256
948b94ce…, commit bb7e37dd). The note stays as written. Where the two
disagree, this file wins. Sources:
- Researcher's report `agents/researcher/work/2026-10-04_vikunja-pocketid.md`
(sha256 fcfc970f…), cited below as "R" plus a section number;
- PRD draft 0.2 `docs/prd/mosaic-stack.md` (sha256 6a4d23bc…, commit
c5799877), cited by requirement id;
- lead decisions 46, 47 and 48.
Nothing below was run against a Vikunja instance. Every item the report
marks unverified stays unverified here, and section 9 lists the probes
that would settle each one.
## Summary
- The adapter targets `/api/v2` (REQ-TASK-1, R 1.1). The note's `/api/v1`
assumption is withdrawn.
- Vikunja token scopes are a map of route group to verbs, so the role
file's `scopes` for Vikunja becomes an object (section 2).
- Route groups are coarse. A token with `tasks: update` can edit every
task its bot can see, so "only the assigned role writes" can't live in
the credential. With the broker holding tokens (REQ-CRED-1), the
broker's verbs are where it is checked (section 3).
- REQ-CRED-1 overturns the note's section 2, which handed tokens to the
agent. The biggest consequence is `git push` for the coder, and the
obvious fix, a credential helper, leaks the token. Section 4 proposes a
broker push from a broker-owned repository.
- Sync is polling with a keyset cursor plus a periodic full reconcile.
I recommend slice 1 ship no webhook receiver at all (section 5).
- 6.8 stands: compare, then write. The ETag on GET makes the compare
cheap, and a partial PATCH narrows the race to fields both sides touch
(section 6).
- REQ-LAUNCH-1, REQ-DEC-4 and REQ-CLI-2 each need a small schema or
business-file change (section 8).
- Two places in PRD 0.2 read too strictly against the design. Section 10
proposes wording for 0.3.
## 1. Vikunja version and references
- The adapter speaks `/api/v2` only. Its one exception is the version
check: R 1.7 names `GET /api/v1/info`, and the report found no v2
equivalent. That call breaks at Vikunja 4.0 (R 3.3 item 3), so it sits
behind one function the adapter can swap.
- Version gate, fail closed: refuse below 2.4.0 (bots and v2 arrive
there, R 1.4) and refuse any major other than 2. A tested version is a
reviewed change to a constant, the same rule as the Pi pin. R 3.3
item 14 found docs and source already disagreeing in two places, which
is the case for pinning.
- The tracker ref stays `vikunja:<projectId>/<taskId>` with numeric ids.
The project-scoped `index` (the "#12" a person sees) is display only.
It can change if a task moves projects.
- v2 lists come back as `{items,total,page,per_page,total_pages}` and
errors as RFC 9457 bodies, 422 for validation (R 1.1). The adapter maps
422 to a refusal code and logs the problem body in `action.refused`,
never the request headers.
## 2. Role files: Vikunja scopes as group-to-verb maps
R 1.4: a token's `permissions` maps a route group to verbs (`read_all`,
`read_one`, `create`, `update`, `delete`, `_bulk` variants). `expires_at`
is required. The note's `["tasks.read", "tasks.update"]` was a guess and
is wrong. Corrected coder entry:
```json
{ "service": "vikunja",
"scopes": { "tasks": ["read_all", "read_one", "update"],
"tasks_comments": ["read_all", "create"],
"projects_views": ["read_all"],
"projects_buckets": ["read_all"],
"projects_views_tasks": ["update"] } }
```
- `scopes` is an array for Gitea and an object for Vikunja. The
validator checks Vikunja group names against a frozen list for the
pinned version, taken from `/api/v1/routes` on 2.7.0 (R 1.4 says that
route lists the real set). An unknown group or verb refuses the role
file (REQ-ROLE-1).
- `projects_views_tasks` covers a bucket move if the adapter moves tasks
through the view route. If a `bucket_id` in the task PATCH is enough,
the group drops out. Unverified, probe P3.
What each slice 1 action needs from Vikunja. Every role also gets the
read set: `tasks` read_all and read_one, `tasks_comments` read_all,
`projects_views` and `projects_buckets` read_all.
| Action (note 1.4) | Roles | Vikunja group and verbs |
|---|---|---|
| `task.create` | pm | `tasks` create |
| `task.assign`, `task.reassign` | pm (reassign is cross-role for others) | `tasks_assignees` create, delete |
| `task.schedule`, `task.priority.change`, `task.close` | pm | `tasks` update |
| `task.update.assigned` | all four | `tasks` update, `tasks_comments` create, bucket move |
| relations (blocked-by, subtask) | pm | `tasks_relations` create, delete |
| labels | pm | `tasks_labels` create, delete |
No role gets `projects` create or update, `projects_webhooks` or
`teams_members`. R 1.4: a project a bot creates belongs to the owner and
the bot gets admin on it, so a role that can create projects can also
reach admin on what it made.
## 3. Bots, minting and who checks what
Facts from R 1.2, 1.4 and 3.1:
- Tokens can't create users or bots, or mint tokens. Only a human JWT
can, from `POST /api/v2/login`.
- A bot belongs to a human owner, and deleting the owner deletes its
bots and their tokens.
- There's no instance admin without a paid licence (R 1.6), and the plan
doesn't use one (PRD, technical considerations).
Design:
- One bot per role instance, named `bot-<business>-<instance>`, for
example `bot-mosaic-stack-coder`. A change of holder doesn't change the
bot (note 5.1 still holds).
- The owner is a dedicated account, not Jason's daily login. Jason's own
account is an ordinary user shared into the projects. That keeps the
owner password out of daily use. It's the one high-value Vikunja secret
(R 3.3 item 1).
- v1 doesn't mint (PRD out of scope; REQ-CRED-1). Jason creates the
owner, the bots, the project shares and the tokens by hand. The
share level is write for all four roles. The report's sequence (R 3.1)
is the runbook. The slice 1 brief should give it as numbered steps
with the exact scopes from section 2, because a token's permissions
can't be read back later. The `tokens` group is excluded from token
scopes (R 1.2 item 1), so the broker can't introspect its own token.
- So the broker checks scope by probe at startup (REQ-HARN-2). For each
role it makes one call that should succeed and one that should be
refused, for example the coder reads its project's tasks and tries
`tasks` create. The coder's create must fail. If it succeeds, the token
is broader than declared and the broker refuses to start that role.
- Bots can't read their own user record (`user_*` is excluded). The
business file therefore records each bot's username and numeric id
next to the credential reference. Assignment needs the id.
- A person must log in to Vikunja once before anyone can assign them
(R 3.3 item 11). The brief should list that as an install step for
Jason's account.
Field ownership. Route groups can't say "assigned tasks only". A
`tasks: update` token edits any task its bot can see in a shared
project. The rule in REQ-TASK-1 is enforced by the broker verb, which
checks the assignee before it writes. Since the agent never holds the
token (REQ-CRED-1), that verb is the only path to a write, subject to
the same-OS-user caveat the PRD already records under risks. This is a
real loss against the note's claim in 1.4 that credential scope is the
hard line for task writes. It is still the hard line for whole groups:
the coder can't create, assign or relabel at all.
The verbs need a field table, since PRD wording "only the assigned role
writes a task's mutable fields" would stop the PM from rescheduling an
assigned task:
| Field | Writer | Action |
|---|---|---|
| title, description | pm at create; after assignment only through a resolved `task.scope.change` | `task.create`, `task.scope.change` |
| assignee | pm | `task.assign`, `task.reassign` |
| due date, priority | pm | `task.schedule`, `task.priority.change` |
| state (bucket), percent done | the assigned role, except `done` | `task.update.assigned` |
| `done` | pm, after a review verdict | `task.close` |
| relations, labels | pm | `task.create`, `task.schedule` |
| comments | any role on the task, append only | `task.update.assigned`, `message.send` |
The requirement id (REQ-TASK-1) is not a label. R 1.4 says a bot edits
only the labels it created, and label changes fire no webhook (R 1.3).
The `task.created` event in `bus.sqlite` holds the authoritative
`requirement_ref`. The adapter also writes a last line
`Requirement: REQ-TASK-1` into the description for people to read. If a
person deletes that line, the event still holds the link. `create`
refuses an id that isn't in the PRD version the business file names.
## 4. The broker holds tokens (REQ-CRED-1)
This replaces the note's section 2 sentence "The launcher hands it to
the agent as a read-only mount or an environment variable". R 3.1 step 3
says the same thing as my note did, and REQ-CRED-1 overrules both.
- The resolver and launcher still only `stat` a reference (REQ-VAR-2).
The broker is the one component that reads token contents. It holds
them in memory, never logs them, never writes them, and never passes
them to a child that runs in an agent's workspace.
- A `{env: NAME}` reference names a variable in the broker's own
environment. The launcher starts every agent with an allow-listed
environment, so those names never reach it. Decision 47 item 6 already
puts the existing worker provider-key exposure in DEFERRED. This rule
stops new exposure.
- Agents reach Vikunja and Gitea only through typed tools in the launch
bundle (REQ-HARN-1). Each tool is one vocabulary action. The broker
stamps role and run from the launch record (REQ-DEC-3) and emits
`action.allowed` or `action.refused`.
Coder `git push` is the hard case. Three options:
1. A git credential helper that asks the broker per push. Rejected. Git
prints the token to whoever calls `git credential fill`, and the agent
can run that itself.
2. The broker runs `git push` inside the agent's workspace with the
token on `GIT_ASKPASS`. Rejected. The workspace's `.git/hooks/pre-push`
and `.git/config` (`core.sshCommand`, `credential.helper`,
`core.hooksPath`) belong to the agent. A planted hook runs in the
broker's push with the askpass helper in reach.
3. Recommended: a `git.push.working` tool. The broker fetches the named
branch from the workspace into a bare repository it owns
(`<dataRoot>/broker/git/<business>/<project>.git`). It checks that the
ref is the role's working branch from the resolved variables. Then it
pushes from its own repository with hooks off
(`-c core.hooksPath=/dev/null`) and the token supplied only to that
process. The fetch is served by `git-upload-pack`, which runs no
hooks, and git ignores `uploadpack.packObjectsHook` in repository
config. Probe P8 should still prove it: plant a pre-push hook and a
`credential.helper` in a workspace and check that neither runs.
Gitea branch protection on `next` and `main` stays the server-side line
(REQ-HARN-2, credential scope). The broker's ref check sits in front of
it. It isn't a replacement for it.
Expiry and rotation (R 1.4, R 3.3 item 8; PRD technical considerations):
- Vikunja tokens carry a mandatory `expires_at`. Gitea tokens have no
expiry. Since the broker can't read either back, Jason records a date
on each reference when he creates the token: `expires` for Vikunja,
`rotateBy` for Gitea. A missing date refuses the reference.
- New event kinds, all with service and role instance, never the value:
`credential.expiring` (7 days ahead, goes in the daily digest),
`credential.expired` (the broker refuses that service for that role and
raises a blocking gated decision, so Jason gets a DM under REQ-DEC-4),
and `credential.changed`. The broker writes `credential.changed` when a
referenced file's inode, size or mtime changes, which is how a rotation
shows up without anyone reading the file twice.
## 5. Sync (REQ-TASK-2)
Polling is the truth and webhooks are hints (R 1.3, R 3.3 item 4). The
report found docs saying "no retries" while v2.7.0 source retries five
times from an in-memory bus.
Incremental poll, every `tracker.pollSeconds` (default 30):
- `GET` the project's tasks with `filter=updated >= <T>`, `sort_by=updated`
(then `id`, if the API takes a second sort key), ascending, `per_page=50` (R 1.3: the default cap is
`service.maxitemsperpage` 50). The exact v2 path comes from
`/api/v2/openapi.json` on 2.7.0.
- The cursor is `{T, seen}`: the last `updated` value and the ids already
handled at exactly that value. `>=` plus `seen` tolerates several tasks
sharing one timestamp. A strict `>` would drop a task updated in the
same second as the last one read.
- No offset paging across the range. A task updated mid-scan jumps to the
end, every later item shifts left by one, and page 2 skips one task.
Each request asks for page 1 from the current cursor instead. If 50 or
more tasks share one timestamp, the adapter pages within
`updated = <T>` alone, which holds still while the scan runs.
- `ratelimit.enabled` is off by default and 100 a minute when on (R 1.7).
One request per project per 30 seconds stays far under it.
Full reconcile, every `tracker.reconcileMinutes` (default 60) and at
startup:
- List every task in the project, with `expand=comment_count`, and compare
with the last snapshot of each.
- A task that's gone becomes `task.missing`. R 3.4 couldn't confirm that
polling returns soft-deleted rows, so the adapter doesn't claim
"deleted".
- Label differences become `task.changed.external`. Label changes fire
no webhook, and whether they bump `updated` is unverified (probe P5).
- A comment count that moved means the adapter fetches comments. Whether
a comment bumps the task's `updated` is unverified (probe P5).
Snapshots and who changed what:
- The adapter needs the last known state of each task to tell a change
from a non-change. I propose one more append-only table in
`bus.sqlite`, `task_snapshots` (seq, at, task_ref, updated, etag,
digest, fields JSON, source `self` or `poll`, role, run), with the same
four guards as the others. It isn't in the prototype yet.
- Every broker write stores the task the server returns as a `self`
snapshot. A polled version whose digest matches no `self` snapshot is a
person's edit: `task.changed.external` with the changed field names
(REQ-TASK-1). Comparing the digest, not only `updated`, catches a person
editing in the same second as a broker write.
Webhooks: I recommend slice 1 ship none. A 30-second poll meets every
slice 1 need. A receiver brings an HMAC secret to hold, and
`outgoingrequests.allownonroutableips` on the instance (R 1.3). On path
A that's a setting on someone else's server (R 1.7). When a receiver
comes, it checks `X-Vikunja-Signature`, is idempotent, and does nothing
but start a poll early.
## 6. Concurrency (6.8 stays)
The report found ETag and If-None-Match on single GETs only, and nothing
on conditional updates (R 1.3, R 1.1). Decision 46 kept 6.8
provisionally, and Sage's message keeps it as decided. The refinement:
1. GET the task with `If-None-Match: <etag from the last snapshot>`. A
304 means nothing changed since the broker last saw it.
2. On 200, compare `updated` and the digest with the caller's
`expectUpdated`. If they differ, refuse with `task.conflict` and write
nothing.
3. PATCH only the fields the action owns (section 3 table), never a full
PUT. A person's concurrent edit to a different field then survives.
v2 accepts PATCH (R 1.4), but its merge semantics are unverified
(probe P4). If PATCH turns out to replace the whole object, the
adapter keeps compare-then-write and the race covers every field.
4. Store the PATCH response as a `self` snapshot.
The window between steps 2 and 3 remains. A person who edits the same
field in that gap loses the edit with no signal. One holder per role,
one writer per field and a sub-second window keep it rare. The brief
should say it exists and not claim the write is atomic.
## 7. Installer and variables (REQ-TASK-3)
New and changed keys (REQ-VAR-1). All are plain values, and the most
specific layer wins.
| Key | Layers | Note |
|---|---|---|
| `tracker.kind` | business | `vikunja` only in v1 |
| `tracker.baseUrl` | business | |
| `tracker.project` | project | the Vikunja project id; the note had no project key |
| `tracker.pollSeconds` | business, project | default 30, minimum 10 |
| `tracker.reconcileMinutes` | business | default 60 |
- Bucket titles are fixed in code, not a variable: `todo`,
`in-progress`, `in-review`, `blocked`, `done`. The adapter finds their
ids in the project's kanban view at startup. A missing title refuses,
and creating buckets is an install step, not an agent action.
- Path A, existing instance (R 1.7): base URL, the version gate from
section 1, an owner account with local login, the bots and tokens.
`allownonroutableips` stops being a prerequisite if slice 1 has no
receiver.
- Path B, bundled: `vikunja/vikunja:2.7.0`, pinned by full tag (better,
by digest), unmodified, no Vikunja code in the repository (REQ-TASK-3).
`VIKUNJA_SERVICE_SECRET` and the database password are runtime-only
(invariant 3). The owner comes from `vikunja user create` in the
container, then `VIKUNJA_SERVICE_ENABLEREGISTRATION=false`.
- The installer never writes the business file (REQ-ROLE-3). It prints
the block Jason pastes, with the reference paths and placeholders for
bot ids and dates, and validates the file he saves.
Business file credential entry, revised from the note's section 2 (no
secret values):
```json
"coder": { "definition": "coder", "vars": { "harness": "pi" },
"tracker": { "bot": "bot-mosaic-stack-coder", "botId": 7 },
"credentials": {
"gitea": { "file": "/abs/path/coder-gitea.token", "rotateBy": "2027-01-04" },
"vikunja": { "file": "/abs/path/coder-vikunja.token", "expires": "2027-01-04" } } }
```
## 8. PRD answers that change the note
REQ-LAUNCH-1 settles 6.6:
- The `pm` role file lists `role.launch` under `withinRole`. The verb
also requires a `launch` block in the business file. With no block, the
action is gated, so a business that never wrote the block fails closed:
```json
"launch": { "by": "pm", "instances": ["coder", "reviewer"],
"max": { "opus": 4, "sonnet": 4 } }
```
- "Role instances only" means `instances` must name roles the business
file declares. The PM can't launch a seat that holds no role, or itself.
- The broker counts live sessions per model family from `session.launched`
without a matching `session.ended`. It maps a model id to a family
through a reviewed table, and an unknown model refuses. A launch over a
limit is `action.refused` with reason `launch-limit`.
- `session.launched` gains `launched_by` (the human, or the PM's run) and
`model_family`.
- "Revocable with one word": the business file is Jason's and the stack
never writes it, so the stack can't revoke by editing it. Instead, a
CLI command started outside any agent run writes a `launch.revoked`
`human.input` event, and `launch.restored` reverses it. The verb checks
the latest of the two. My assumption is that the word stops new
launches. Ending a running session is a separate `mosaic stop
<instance>`. Sage should confirm that reading with Jason in round 3 if
it matters.
REQ-DEC-4:
- `decisions` gains `blocking INTEGER NOT NULL CHECK (blocking IN (0,1))`.
The raiser sets it when its task can't move until the decision
resolves. A blocking decision should cite `task_ref`, so the field is
checkable.
- A gated decision with `blocking = 1` goes to the CLI inbox and a
Discord DM at once. Delivery is a `messages` row to the human plus
`deliveries` with transports `cli` and `discord-dm`. Every other item
waits for the daily digest. That's one message per day listing decision
ids, with a `digest.sent` event.
- The DM goes only to Jason, so it isn't external speech (hard limit B).
His Discord user id is a business variable, `human.discordUserId`. The
bot token stays with the connector.
REQ-CLI-2:
- The note's business file sketch set `"harness": "t3"` for the PM. That
is wrong under REQ-CLI-2. Allowed values are `pi` and `claude-code`,
and the PM in this repository should start with `pi` (decision 47 item
7). T3 can still be a message address on a claim. It isn't a harness.
REQ-ROLE-4: Sage holds `pm` and I hold `cto`. A claim belongs to a run,
not a name, and REQ-CLI-2 means the product PM is a stack-launched
session, not Sage's T3 thread. During slice 1, a T3 seat's claim stays
advisory (decision 46, 6.3). The brief needs to say at which step Sage
moves to a stack-launched session and starts holding `pm` for real.
REQ-TASK-4: the note's 5.2 row "Vikunja owns task state, the queue
mirrors it on claim and close" has the state field moving both ways.
Tightened: for a task with a queue row, the queue is the source for
in-progress, in-review and done. The adapter writes those to Vikunja one
way and never reads state back into the queue. A person's state change
in Vikunja becomes `task.changed.external` plus `task.conflict`, raised
to the PM as the note already says. For a task with no queue row, the
bucket in Vikunja is the only state.
REQ-EVT-1: new kinds from this addendum are `credential.expiring`,
`credential.expired`, `credential.changed`, `task.missing`,
`launch.revoked`, `launch.restored` and `digest.sent`.
## 9. Probes before the build relies on any of this
Run against a scratch `vikunja/vikunja:2.7.0` with SQLite. No real
tokens are needed, since the scratch owner mints test ones:
- P1. Exact v2 bodies for bot create and project share (R 3.4).
- P2. A token created with `expires_at` in the past (R 3.4).
- P3. Whether a PATCH with `bucket_id` moves a task, or the view route
is needed.
- P4. Whether PATCH merges fields or replaces the task.
- P5. Whether a label change or a new comment bumps the task's `updated`.
- P6. Whether a task list ever returns soft-deleted tasks (R 3.4).
- P7. The scope probe from section 3: a coder token refused on
`tasks` create, and the refusal's status code.
- P8. The broker push from section 4: a planted pre-push hook and a
`credential.helper` in the workspace do not run.
P1 to P7 need only a scratch container and its owner login. P8 belongs
to whoever builds the broker.
## 10. Proposed PRD 0.3 wording
- REQ-VAR-2: "The stack checks a referenced file with `stat` and never
reads its contents" contradicts REQ-CRED-1, because the broker has to
read a token to use it. Proposed: "Only the broker reads a secret's
contents. It holds them in memory and never logs or writes them.
Everything else checks a referenced file with `stat`."
- REQ-TASK-1: "Only the assigned role writes a task's mutable fields"
would stop the PM from rescheduling an assigned task. Proposed: "Each
task field has one writing role, set in the field table of the slice 1
brief. The assigned role writes the task's state." That table is the
one in section 3.
## 11. Open questions, each with a recommendation
A1. **Coder push.** Recommendation: the broker push from section 4,
option 3. Branch protection on Gitea stays the server-side line.
A2. **Webhooks in slice 1.** Recommendation: none. Poll every 30
seconds and reconcile hourly.
A3. **`task_snapshots` table.** Recommendation: add it, append-only with
the same guards. I can extend the prototype if Sage wants it run before
the brief.
A4. **Probes P1 to P7.** Recommendation: one seat runs them on a scratch
container before the brief freezes the adapter interface.
A5. **The word that revokes launching.** Recommendation: it stops new
launches, and `mosaic stop` ends a running one. Confirm with Jason only
if he meant otherwise.