Files
stack/agents/darkwing/work/slice1-data-model-addendum-b-2026-10-04.md
T
jason.woltjeandClaude Opus 5.5 b6631e6b4f docs(plans): slice 1 addendum B; lead decision 52
Real Vikunja scope map, a read-only sync bot, a two-request poll that
sees column moves and deletions, and task_snapshots changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 18:42:31 -05:00

23 KiB

Slice 1 data model, addendum B (design note, 2026-10-04)

Darkwing (CTO for Mosaic Stack), for Sage. Design only. Nothing here is built or committed.

This addendum amends addendum A (agents/darkwing/work/slice1-data-model-addendum-2026-10-04.md, sha256 0b36acb0…) after Researcher's probes (agents/researcher/work/2026-10-04_vikunja-probes.md, sha256 ef0beec6…, cited as P1 to P7) and lead decision 51. Where this file and addendum A disagree, this file wins. Decision 51's settled points are not restated: PATCH of owned fields only, labels only through the single add and remove routes, the broker checking expires_at itself, and a 401 treated as a refusal.

Summary

  • The scope map below uses real 2.7.0 route groups. Two names in it are not guessable. The kanban listing needs projects.views_buckets_tasks_get. The move needs projects.views_buckets_tasks. Neither verb authorizes the other (V1).
  • Polling moves to a separate read-only identity, bot-<business>-sync, shared into the project at permission 0. Role tokens lose the read set and keep their write verbs plus tasks read_one.
  • The 30-second poll becomes two requests per project. One is the kanban listing of open tasks. The other is the existing updated cursor. Together they catch column moves, bulk label sets on open tasks and deletions of open tasks within one tick (V3, V4). At slice 1 scale that is 4 requests a minute per project.
  • done stays coupled to the done bucket. A task.close is a move into done. The PATCH key list drops done, bucket_id, labels and assignees.
  • task_snapshots gains via and read_at, a CHECK that every snapshot carries an integer bucket (or a tombstone), a read-ordering rule in task_external_changes, and a tasks_open view. I ran it on node:sqlite, and a mutant shows the ordering rule is needed (V7).

1. What I ran

Scratch vikunja/vikunja:2.7.0 (digest e2204a1c…, the same image as the probes), SQLite, bound to 127.0.0.1, one owner, bots minted by the owner JWT. Token values stayed in process memory and were never printed or written. The container and its data directory are gone. Scripts and output: ~/darkwing-scratch/vk-out/ (probe.mjs, probe1.txt, probe2.mjs, probe2.txt) and ~/darkwing-scratch/addb/ (check.mjs, check-schema.txt, mutant.txt).

Id What Result
V1 One token per group and verb, against the v2 route slice 1 calls Table in section 2. Every row is a real call with a minted token.
V2 Kanban view of a new project done_bucket_id 6 (Done), default_bucket_id 4 (To-Do), bucket_configuration_mode manual
V3 GET /projects/{p}/views/{k}/buckets/tasks shape Each task carries its real bucket_id and its labels. filter=done = false empties the Done bucket (count 0). per_page caps tasks per bucket. page=2 pages every bucket at once. bucket.count is the bucket's full count under the filter.
V4 Bulk label set, then a delete, then the listing PUT /tasks/{t}/labels/bulk left updated unchanged (as P5) but the listing showed the new labels. A deleted task drops out of the listing, and GET /tasks/{t} gives 404 code 4002 (as P6).
V5 PATCH /tasks/{t} {"done":true} Moves the task into the done bucket and bumps updated. So done and the done bucket are one state while done_bucket_id is set.
V6 Sync bot, share permission 0, read set only All seven reads 200. PATCH and the move 401 (scope). The same bot with write verbs added gets 403 (share). Task unchanged.
V7 task_snapshots changes on node:sqlite (Node 26.8.1) Section 5. All refusals fire, both guards hold, and the mutant without the ordering rule reports a false external change.

The flat view listing (GET /projects/{p}/views/{v}/tasks) returns bucket_id 0, like GET /tasks/{t} (P3). Only the kanban listing says which column a task is in.

2. Scope map per role

V1 results. Each token held only the scopes listed, on a bot with a write share unless the row says otherwise.

Call (v2) Scopes on the token Status
GET /projects/{p}/views/{k}/buckets/tasks projects read_one, views_buckets_tasks_get 200
same projects read_one, views_buckets_tasks 401
same projects_views_tasks read_all 401
same with expand=comment_count projects views_buckets_tasks_get 401
same with expand=comment_count that plus tasks_comments read_all 200
PUT /projects/{p}/views/{k}/buckets/{b}/tasks projects views_buckets_tasks 200
same projects views_buckets_tasks_get 401
GET /projects/{p}/views/{k}/buckets projects views_buckets 200
same projects views_buckets_put, or views_buckets_post 401
POST /projects/{p}/views/{k}/buckets projects views_buckets 401
GET /projects/{p}/views projects_views read_all 200
GET /projects/{p} projects read_one 200
GET /projects/{p}/tasks with the cursor filter tasks read_all 200
GET /tasks/{t} tasks read_one 200
POST /projects/{p}/tasks tasks create 201
PATCH /tasks/{t} tasks update 200
POST /tasks/{t}/comments tasks_comments create 201
POST /tasks/{t}/labels, DELETE /tasks/{t}/labels/{l} tasks_labels create, delete 201, 204
GET /labels labels read_all 200
POST /tasks/{t}/assignees, DELETE /tasks/{t}/assignees/{u} tasks_assignees create, delete 201, 204
POST /tasks/{t}/relations, DELETE /tasks/{t}/relations/{kind}/{other} tasks_relations create, delete 201, 204
mint with projects views_buckets_tasks_bogus none 400, code 14002 at mint

views_buckets_tasks_get exists because the GET listing and the v1 POST move share a path, and Vikunja appends the method to the second key on a collision (pkg/models/api_routes.go). GET /api/v1/routes shows it. The probe file's list doesn't.

The sync identity

I recommend one more bot per business, bot-<business>-sync, shared into each tracked project at permission 0, holding only the read set. The broker does every poll, reconcile and compare-read with it. Role tokens then hold write verbs and one read verb, tasks read_one. They need that read verb as the control call that tells an expired token from a scope refusal, and for the startup probe.

The gain: a bug in the poll path can't write, and two separate checks stop it (V6: scope gives 401, the share gives 403). The cost: one more bot and token in Jason's install runbook, and one more expires date to watch. I think that's worth it. The poll runs every 30 seconds for the life of the broker, far more often than any write path.

Role files

Read set, sync only:

{ "service": "vikunja",
  "scopes": { "projects": ["read_one", "views_buckets", "views_buckets_tasks_get"],
              "projects_views": ["read_all"],
              "tasks": ["read_all", "read_one"],
              "tasks_comments": ["read_all"] } }

pm:

{ "service": "vikunja",
  "scopes": { "tasks": ["read_one", "create", "update"],
              "tasks_assignees": ["create", "delete"],
              "tasks_relations": ["create", "delete"],
              "tasks_labels": ["create", "delete"],
              "tasks_comments": ["create"],
              "labels": ["read_all"],
              "projects": ["views_buckets_tasks"] } }

coder, reviewer and cto (the same set):

{ "service": "vikunja",
  "scopes": { "tasks": ["read_one", "update"],
              "tasks_comments": ["create"],
              "projects": ["views_buckets_tasks"] } }
  • labels read_all is for resolving a label title to its id before an add. A bot attached a label the owner made (V1, 201). Which labels GET /labels returns to a bot isn't checked; see section 7.
  • tasks_comments read_all sits with sync, because expand=comments or comment_count on any listing needs it (V1).
  • tasks update authorizes PUT /tasks/{t} as well as PATCH, because v2 PATCH is an alias of the stored PUT key. The scope can't forbid the PUT that clears fields (P4). The broker verb is the only line there.

Never granted, to any role: projects create, update, delete, duplicate, views_buckets_post, views_buckets_put, views_buckets_delete; projects_users; projects_views create, update, delete; projects_webhooks; teams_members; tasks delete, duplicate, create_bulk, update_bulk; tasks_labels update_bulk (the bulk set that the cursor can't see); labels create, update, delete. Buckets, views, shares and labels are install steps.

The validator's frozen list for 2.7.0 is GET /api/v1/routes, which already merges v1 and v2 keys, plus the rule above for _get style suffixes. A role file that names a group or verb not in it is refused, the same as Vikunja's own 400 at mint.

Startup scope probe, revised

Addendum A had the coder try tasks create. If the token were too broad, that probe would create a junk task. A harmless probe is a call on a task id that doesn't exist. Vikunja checks scope in middleware before the handler, so 401 means the scope refuses, and 404 means the scope allowed the verb and only the missing row stopped it. I read that order from source and from P7 (the coder's DELETE /tasks/1 got 401). I didn't run it with a missing id.

Identity Control call Probe call, must be 401 Refuse to start on
sync GET /projects/{p}, must be 200 PATCH /tasks/{missing} {} 404 or 403
pm GET /tasks/{missing}, must be 404 DELETE /tasks/{missing} 404
coder, reviewer, cto same DELETE /tasks/{missing}, POST /tasks/{missing}/labels 404

{missing} is one more than the highest task id the sync bot sees, so the probes work on a project with no tasks yet. A role's control call answers 404 when the token is valid and holds tasks read_one, and 401 when it's expired or wrong. A 403 on the sync probe means the token holds tasks update and only the share stopped it, so that also refuses.

3. How the 30-second poll catches moves and deletions

I took the option Sage named and narrowed it: list the open tasks per bucket, not all tasks. Every tick, per tracked project, in this order:

  1. Board read. GET /projects/{p}/views/{k}/buckets/tasks with filter=done = false&per_page=50, sync token. This returns every open task with its bucket and labels. Done tasks are filtered out, so the response doesn't grow with the project's history (V3).
  2. Cursor read. The {T, seen} cursor from addendum A section 5, on GET /projects/{p}/tasks. This sees every change that bumps updated. That includes moves into and out of done (P3), single label and assignee changes, and comments (P5), on open and done tasks alike.

Then the adapter compares the board with tasks_open (section 5), the set of tasks whose latest snapshot says open:

  • A task on the board in a different bucket, or with different labels, is a change, whether or not updated moved. That covers the two gaps P3 and P5 found: moves between columns that aren't done, and bulk label sets.

  • A task in tasks_open and missing from the board has left the open set. If the cursor read in the same tick returned it with done true, the cursor snapshot explains it. Otherwise the adapter makes one GET /tasks/{t}:

    • 200 with done true: a normal snapshot, via task;
    • 200 with another project_id: tombstone gone: "moved", and the new project id goes in the task.missing body;
    • 404 code 4002: tombstone gone: "not-found" (P6, V4: Vikunja hides soft-deleted tasks from every read);
    • 403: tombstone gone: "no-access".

    Each tombstone also writes task.missing with the reason. The adapter claims no more than the status shows.

  • A task on the board that tasks_open doesn't hold is new or reopened. The cursor read normally has it too.

  • A cursor hit whose digest didn't change is most likely a comment. It can also be a change to a field the digest leaves out (section 5). The adapter fetches that task's comments, one request.

The board read goes first. A task that closes between the two reads then shows open on the board and done on the cursor, and the cursor's copy has the higher updated (P3), so it wins. In the other order, that task would be on the cursor as open and missing from the board, and cost an extra GET.

A bucket whose count is over 50 needs more pages. page=2 with the same filter pages every bucket at once (V3), so the board read costs ceil(largest count / 50) requests.

Request count at slice 1 scale

My assumptions for slice 1: one business, one tracked project, five buckets, fewer than 50 open tasks in any bucket, and fewer than 50 tasks changed in any 30 seconds.

Per project Requests
Each tick, quiet or busy 2 (board, cursor)
A task left the open set and the cursor doesn't explain it +1 each
A comment arrived +1 per task
Each hour, reconcile ceil(all tasks / 50) for the project list with expand=comment_count, + 1 per changed comment count

That's 4 requests a minute per project, plus a few on a busy tick. With ratelimit.enabled at its 100 a minute (R 1.7), 25 projects would use the whole budget, so about 20 is the practical ceiling. I assume the limit counts per user. The sync bot is then its own user, and writes by role bots don't draw on its budget. I haven't tested that; see section 7.

What only the hourly reconcile still sees

  • A done task deleted, or moved to another project. It isn't on the board and a delete doesn't bump updated (P6).
  • A bulk label set on a done task.
  • A comment edit, if editing a comment doesn't bump updated. P5 tested create and delete, not edit.
  • A relation change, if relations don't bump updated. Untested. The pm is the only writer and the digest leaves relations out (section 5).

All four concern closed tasks or rare edits. I'd accept an hour's lag on them in slice 1.

Install checks for the kanban view

At startup, before the first poll, the adapter refuses the project unless:

  • it has exactly one kanban view, with bucket_configuration_mode manual;
  • the view's buckets carry the five titles from addendum A section 7, each once;
  • done_bucket_id is the done bucket and default_bucket_id is the todo bucket.

A fresh project's view comes with To-Do, Doing and Done, Done set as the done bucket and To-Do as the default (V2). The install renames those and adds in-review and blocked. Without done_bucket_id, done and the bucket drift apart, and the board filter stops matching the done column.

4. Field table, revised

This replaces the table in addendum A section 3.

Field Writer Action Vikunja call
title, description pm at create; after assignment only through a resolved task.scope.change task.create, task.scope.change POST /projects/{p}/tasks; PATCH /tasks/{t}
due date, priority pm task.schedule, task.priority.change PATCH /tasks/{t}
percent done the assigned role task.update.assigned PATCH /tasks/{t}
state (bucket) other than done the assigned role task.update.assigned PUT /projects/{p}/views/{k}/buckets/{b}/tasks
done pm, after a review verdict task.close the same move, into the done bucket
assignee pm task.assign, task.reassign POST /tasks/{t}/assignees; DELETE /tasks/{t}/assignees/{u}
labels pm task.create, task.schedule POST /tasks/{t}/labels; DELETE /tasks/{t}/labels/{l}
relations pm task.create, task.schedule POST /tasks/{t}/relations; DELETE /tasks/{t}/relations/{kind}/{other}
comments any role on the task, append only task.update.assigned, message.send POST /tasks/{t}/comments

Rules that follow:

  • The PATCH body allows only title, description, due_date, priority and percent_done, and only the keys the action owns. The broker refuses any other key before it sends. bucket_id matters most here, because Vikunja accepts it, answers 200, echoes it and doesn't move the task (P3). done goes through the move, so state has one path.
  • The assigned role's move refuses done as a target, and refuses a task that is in done. Reopening is a person's act in slice 1. It shows up as task.changed.external, and for a queue row also as task.conflict (addendum A section 8).
  • A label delete first checks the compare-read shows the label on the task. Vikunja answers 403 for a label that isn't there (P5), and the broker reads 403 as a share refusal.
  • An unassign checks the same way. I haven't seen what Vikunja returns for an assignee that isn't on the task.
  • The compare step for a move reads the board, not GET /tasks/{t}, since only the board has the bucket. That's one request with the sync token, the same call as the poll.

5. task_snapshots changes

Columns and checks, against slice1-proto/schema-v2.sql (sha256 ffb7cf90…):

  source TEXT NOT NULL CHECK (source IN ('self','poll')),
  via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
  read_at TEXT,
  role TEXT, run TEXT,
  CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
  CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
  CHECK (json_type(fields, '$.bucket') IS 'integer'
         OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
  • via names the read that produced a poll snapshot. When a task appears in both reads of one tick, the snapshot is cursor, with the bucket taken from the board.
  • read_at is when the broker sent the read. For a self snapshot, at is when the write's response arrived.
  • Every snapshot carries fields.bucket as an integer. A board read has it. A cursor or task read of a done task gets the done bucket id, which is exact while the done coupling holds. A cursor read of an open task takes it from the same tick's board read. A self snapshot takes it from the move response, or carries the last known bucket forward for writes that don't move.
  • A tombstone is a poll snapshot with via task and fields {"gone": "<reason>"}. Nothing else can omit the bucket.
  • The digest covers project_id, title, description, done, due_date, priority, percent_done, bucket, label ids (sorted) and assignee ids (sorted), in canonical JSON. It leaves out updated, comments and relations.
  • The adapter writes a poll snapshot only when the digest differs from the task's latest snapshot. A quiet tick writes nothing. Without that rule, the table would gain one row per open task every 30 seconds.

task_external_changes gains one condition:

    AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));

The updated >= test can't order a board read against a broker move, because a move between columns that aren't done doesn't change updated (P3). Without read_at, a board read sent just before the broker's move and recorded just after it shows the old bucket with an equal updated, and the view calls the broker's own move external. The mutant in V7 does exactly that. With the rule, that read is ignored and the next tick settles it. A person's move made right after a broker write waits one tick longer. That's the same cautious side decision 50 kept.

New view, the set the board read is compared with:

CREATE VIEW tasks_open AS
  SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
  FROM task_snapshots s
  WHERE s.seq = (SELECT max(seq) FROM task_snapshots WHERE business = s.business AND task_ref = s.task_ref)
    AND json_type(s.fields, '$.gone') IS NULL
    AND json_extract(s.fields, '$.done') = 0;

V7 cases, all as expected: a matching board read after a self create is not external; a stale board read is not external; a fresh read that agrees is not external; a person's move with updated unchanged is external, via board; a tombstone leaves tasks_open. Refused: a poll without via, a poll without read_at, a self with via, fields without a bucket, a text bucket, gone on a board read, gone on a self, an unknown via. The UPDATE and DELETE guards still hold.

I first wrote the bucket CHECK with =. A missing path makes json_type NULL, SQLite passes a CHECK that evaluates to NULL, and two bad rows went in. The IS form above is the fix, and the run shows it refusing them.

Two behaviours carry over from schema-v2 unchanged, and the brief should say them:

  • A task the broker never wrote has no self snapshot, so every poll snapshot of it is in task_external_changes. That is right for a task a person created, and it means the view is "not ours", not "changed".
  • A tombstone shows in task_external_changes too, next to its task.missing event.

No new event kinds. task.missing already exists, and its body gains reason (moved, not-found, no-access) and, for moved, the new project id.

6. Changes to addendum A, by section

  • Section 2: the scope example and the action table's third column are replaced by section 2 here. The read set moves from every role to sync.
  • Section 3: the field table is replaced by section 4. The startup probe is replaced by the table in section 2. Shares: role bots at write (permission 1), sync at read (permission 0).
  • Section 5: the incremental poll is the two reads in section 3. The reconcile keeps expand=comment_count and covers the four gaps listed there. Label differences are no longer reconcile-only.
  • Section 7: the kanban view checks in section 3 join the install checks. The business file gains a sync entry under tracker, with its own bot, botId and credentials.vikunja.
  • Section 9: P1 to P7 are settled by the probe file. Nothing here touches P8.

7. Unverified

  • Which labels GET /labels returns to a bot: only its own, or every label on tasks it can see. If only its own, the pm can't find the install-time labels by title, and the business file must list label ids.
  • Whether the board listing's tasks carry assignees. They carry labels (V3). If they don't, an assignee change still bumps updated (P5), so the cursor catches it. Only the digest source changes.
  • The startup probe's order with a missing task id: 401 before 404 for a verb the token lacks, and 404 for one it holds (section 2). It follows from the middleware order in source. I didn't run it.
  • Whether a comment edit or a relation change bumps updated.
  • What DELETE /tasks/{t}/assignees/{u} returns when the user isn't assigned.
  • Whether a task moved into a project the sync bot can't see gives 403 or 404 on GET /tasks/{t}. Both map to a tombstone, with different reasons.
  • Whether the rate limit counts per user, as I assume above. If it counts per IP, the broker's identities share one budget, and the poll budget is about 20 projects minus the write traffic.
  • Behaviour with more than 50 tasks in a bucket beyond the one-task-per-page check in V3. Scale was five tasks.

8. Open questions, each with a recommendation

B1. Sync identity. Recommendation: yes, bot-<business>-sync at permission 0 with the read set. The cost is one bot and token in the runbook.

B2. Reopen. Recommendation: not an agent action in slice 1. A person reopens, and the stack reports it as external.

B3. Schema. Recommendation: fold section 5 into the prototype as schema v3 when the brief is written. The check in V7 runs against a patched copy of schema-v2 in my scratch directory and changes nothing in slice1-proto/.