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]>
This commit is contained in:
2026-10-04 18:42:31 -05:00
co-authored by Claude Opus 5.5
parent 5175ca10f2
commit b6631e6b4f
3 changed files with 481 additions and 0 deletions
@@ -0,0 +1,459 @@
# 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:
```json
{ "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:
```json
{ "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):
```json
{ "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…):
```sql
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:
```sql
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:
```sql
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/`.