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:
@@ -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/`.
|
||||
Reference in New Issue
Block a user