From b6631e6b4f66d17b0f62cae8e44634c816c824ba Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 4 Oct 2026 18:42:31 -0500 Subject: [PATCH] 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 --- ...slice1-data-model-addendum-b-2026-10-04.md | 459 ++++++++++++++++++ docs/SESSIONS.md | 1 + docs/plans/2026-09-26_lead-decisions.md | 21 + 3 files changed, 481 insertions(+) create mode 100644 agents/darkwing/work/slice1-data-model-addendum-b-2026-10-04.md diff --git a/agents/darkwing/work/slice1-data-model-addendum-b-2026-10-04.md b/agents/darkwing/work/slice1-data-model-addendum-b-2026-10-04.md new file mode 100644 index 00000000..020dbb1d --- /dev/null +++ b/agents/darkwing/work/slice1-data-model-addendum-b-2026-10-04.md @@ -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--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--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": ""}`. 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--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/`. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index e60003d5..03eef13a 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -478,3 +478,4 @@ are never rewritten or removed; corrections are new entries. 2026-10-04T20:42:10Z | Filbert (claude, T3 9cb9731e) | row 5 CHAT-03 I1 round 2 review (#1507) | approve: B1-B6 fixed (probes rerun, round 1 mutants killed; freeze path read, code waits for frozen 1 before members); follow-ups F1 (enumeration-under-freeze test, C4 and r2-B5b survive), F2 (takeover+Enter in one chunk), F3 (preArgs override reaches Pi unsealed); comment 26694; queue rev 60 committed ddd9cf36 (not pushed); review file agents/filbert/work/chat-03-i1-review-r2-2026-10-04.md sha256 2f88ccd9 (uncommitted) 2026-10-04T20:47:45Z | Sage (T3 Claude Code, thread 1ef1e4f8) | row 5 CHAT-03 I1 commit | both round 2 approvals (26690, 26694); manifest 2b48e333 checked; all suites green on an export; candidate, packets and Filbert's review record committed; follow-ups in DEFERRED; row 5 to waiting-on-jason for Gate E 2026-10-04T23:31:47Z | Sage (T3 Claude Code, thread 1ef1e4f8) | Vikunja probes received | Researcher's P1-P7 record (ef0beec6) committed; scratch container confirmed gone; lead decision 51; Darkwing asked for addendum B (scope map, poll gaps) +2026-10-04T23:42:31Z | Sage (T3 Claude Code, thread 1ef1e4f8) | slice 1 addendum B | Darkwing's addendum B (14e747f5) committed as a record; lead decision 52 accepts B1-B3 (sync bot, no agent reopen, schema v3 at brief time) diff --git a/docs/plans/2026-09-26_lead-decisions.md b/docs/plans/2026-09-26_lead-decisions.md index b9b54414..d7405f2e 100644 --- a/docs/plans/2026-09-26_lead-decisions.md +++ b/docs/plans/2026-09-26_lead-decisions.md @@ -848,3 +848,24 @@ which stay with him. Each item names who decided it and what happened. checks `expires_at` itself, both after minting (Vikunja accepts a past date) and before each use, then treats any 401 as a refusal and logs both possible causes. +52. **Sage's rulings on slice 1 addendum B (2026-10-04).** Source: + Darkwing (CTO), `agents/darkwing/work/slice1-data-model-addendum-b-2026-10-04.md` + (sha256 14e747f5…). Darkwing checked each claim on a scratch Vikunja + with the same image digest as the probes, since removed. Where B and + addendum A disagree, B wins. + - B1: yes. A read-only `bot--sync`, shared at permission 0, + does every poll, reconcile and compare-read. Role tokens keep their + write verbs plus `tasks` read_one. A bug in the poll path then can't + write, and both the scope and the share would stop it. The install + runbook gains one bot and one token. + - The poll is two requests per project per tick: the open-task kanban + listing and the `updated` cursor. The hourly reconcile still covers + done-task deletions and label sets, comment edits and relation + changes. + - B2: reopening a task isn't an agent action in slice 1. A person + reopens it, and the stack records that as an external change. + - B3: section 5 becomes schema v3 in `slice1-proto/` when the brief is + written. + - Section 7's unverified points join the brief's probe matrix as + preconditions. The first is which labels `GET /labels` returns to a + bot. Until that's settled, the business file lists label ids.