Files
stack/agents/darkwing/work/slice1-data-model-addendum-2026-10-04.md
T
jason.woltjeandClaude Opus 5.5 d7723e2237 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]>
2026-10-04 14:56:14 -05:00

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…, 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:

{ "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):

"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:

    "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.