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]>
24 KiB
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…, commitc5799877), 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/v1assumption is withdrawn. - Vikunja token scopes are a map of route group to verbs, so the role
file's
scopesfor Vikunja becomes an object (section 2). - Route groups are coarse. A token with
tasks: updatecan 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 pushfor 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/v2only. Its one exception is the version check: R 1.7 namesGET /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-scopedindex(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 inaction.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:
{ "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"] } }
scopesis 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/routeson 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_taskscovers a bucket move if the adapter moves tasks through the view route. If abucket_idin 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 examplebot-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
tokensgroup 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
taskscreate. 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
stata 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.allowedoraction.refused.
Coder git push is the hard case. Three options:
- 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. - The broker runs
git pushinside the agent's workspace with the token onGIT_ASKPASS. Rejected. The workspace's.git/hooks/pre-pushand.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. - Recommended: a
git.push.workingtool. 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 bygit-upload-pack, which runs no hooks, and git ignoresuploadpack.packObjectsHookin repository config. Probe P8 should still prove it: plant a pre-push hook and acredential.helperin 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:expiresfor Vikunja,rotateByfor 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), andcredential.changed. The broker writescredential.changedwhen 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):
GETthe project's tasks withfilter=updated >= <T>,sort_by=updated(thenid, if the API takes a second sort key), ascending,per_page=50(R 1.3: the default cap isservice.maxitemsperpage50). The exact v2 path comes from/api/v2/openapi.jsonon 2.7.0.- The cursor is
{T, seen}: the lastupdatedvalue and the ids already handled at exactly that value.>=plusseentolerates 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.enabledis 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 bumpupdatedis unverified (probe P5). - A comment count that moved means the adapter fetches comments. Whether
a comment bumps the task's
updatedis 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, sourceselforpoll, 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
selfsnapshot. A polled version whose digest matches noselfsnapshot is a person's edit:task.changed.externalwith the changed field names (REQ-TASK-1). Comparing the digest, not onlyupdated, 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:
- GET the task with
If-None-Match: <etag from the last snapshot>. A 304 means nothing changed since the broker last saw it. - On 200, compare
updatedand the digest with the caller'sexpectUpdated. If they differ, refuse withtask.conflictand write nothing. - 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.
- Store the PATCH response as a
selfsnapshot.
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.
allownonroutableipsstops 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_SECRETand the database password are runtime-only (invariant 3). The owner comes fromvikunja user createin the container, thenVIKUNJA_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):
"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
pmrole file listsrole.launchunderwithinRole. The verb also requires alaunchblock in the business file. With no block, the action is gated, so a business that never wrote the block fails closed:"launch": { "by": "pm", "instances": ["coder", "reviewer"], "max": { "opus": 4, "sonnet": 4 } } -
"Role instances only" means
instancesmust 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.launchedwithout a matchingsession.ended. It maps a model id to a family through a reviewed table, and an unknown model refuses. A launch over a limit isaction.refusedwith reasonlaunch-limit. -
session.launchedgainslaunched_by(the human, or the PM's run) andmodel_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.revokedhuman.inputevent, andlaunch.restoredreverses it. The verb checks the latest of the two. My assumption is that the word stops new launches. Ending a running session is a separatemosaic stop <instance>. Sage should confirm that reading with Jason in round 3 if it matters.
REQ-DEC-4:
decisionsgainsblocking 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 citetask_ref, so the field is checkable.- A gated decision with
blocking = 1goes to the CLI inbox and a Discord DM at once. Delivery is amessagesrow to the human plusdeliverieswith transportsclianddiscord-dm. Every other item waits for the daily digest. That's one message per day listing decision ids, with adigest.sentevent. - 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 arepiandclaude-code, and the PM in this repository should start withpi(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_atin the past (R 3.4). - P3. Whether a PATCH with
bucket_idmoves 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
taskscreate, and the refusal's status code. - P8. The broker push from section 4: a planted pre-push hook and a
credential.helperin 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
statand 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 withstat." - 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.