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]>
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 needsprojects.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 plustasksread_one. - The 30-second poll becomes two requests per project. One is the
kanban listing of open tasks. The other is the existing
updatedcursor. 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. donestays coupled to the done bucket. Atask.closeis a move intodone. The PATCH key list dropsdone,bucket_id,labelsandassignees.task_snapshotsgainsviaandread_at, a CHECK that every snapshot carries an integer bucket (or a tombstone), a read-ordering rule intask_external_changes, and atasks_openview. I ran it onnode: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"] } }
labelsread_all is for resolving a label title to its id before an add. A bot attached a label the owner made (V1, 201). Which labelsGET /labelsreturns to a bot isn't checked; see section 7.tasks_commentsread_all sits with sync, becauseexpand=commentsorcomment_counton any listing needs it (V1).tasksupdate authorizesPUT /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:
- Board read.
GET /projects/{p}/views/{k}/buckets/taskswithfilter=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). - Cursor read. The
{T, seen}cursor from addendum A section 5, onGET /projects/{p}/tasks. This sees every change that bumpsupdated. That includes moves into and out ofdone(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
updatedmoved. That covers the two gaps P3 and P5 found: moves between columns that aren't done, and bulk label sets. -
A task in
tasks_openand missing from the board has left the open set. If the cursor read in the same tick returned it withdonetrue, the cursor snapshot explains it. Otherwise the adapter makes oneGET /tasks/{t}:- 200 with
donetrue: a normal snapshot,viatask; - 200 with another
project_id: tombstonegone: "moved", and the new project id goes in thetask.missingbody; - 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.missingwith the reason. The adapter claims no more than the status shows. - 200 with
-
A task on the board that
tasks_opendoesn'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_modemanual; - the view's buckets carry the five titles from addendum A section 7, each once;
done_bucket_idis thedonebucket anddefault_bucket_idis thetodobucket.
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,priorityandpercent_done, and only the keys the action owns. The broker refuses any other key before it sends.bucket_idmatters most here, because Vikunja accepts it, answers 200, echoes it and doesn't move the task (P3).donegoes through the move, so state has one path. - The assigned role's move refuses
doneas a target, and refuses a task that is indone. Reopening is a person's act in slice 1. It shows up astask.changed.external, and for a queue row also astask.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'))
vianames the read that produced a poll snapshot. When a task appears in both reads of one tick, the snapshot iscursor, with the bucket taken from the board.read_atis when the broker sent the read. For aselfsnapshot,atis when the write's response arrived.- Every snapshot carries
fields.bucketas an integer. A board read has it. A cursor or task read of a done task gets thedonebucket 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. Aselfsnapshot 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
viataskandfields{"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 outupdated, 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
selfsnapshot, so every poll snapshot of it is intask_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_changestoo, next to itstask.missingevent.
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_countand 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
syncentry undertracker, with its ownbot,botIdandcredentials.vikunja. - Section 9: P1 to P7 are settled by the probe file. Nothing here touches P8.
7. Unverified
- Which labels
GET /labelsreturns 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 carrylabels(V3). If they don't, an assignee change still bumpsupdated(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/.