# Tasks adapter, slice 1 S3 This package connects the broker to Vikunja v2. It handles the eight task verbs, polls each business's project, and records what it reads as task snapshots and events in `bus.sqlite`. It holds no token. Every call goes through the broker's `credentials.use()`, so the token goes into the Authorization header and nowhere else. No line it logs carries a token, a comment body or a description. Addendum B section 4 defines the verbs and section 7 the probes. The probe results it relies on are in `agents/darkwing/work/slice1-s3/probes.md`, cited below by section letter. ## Host contract The host forks `packages/bus/src/process.mjs` and puts a `trackers` map in the boot config. The broker process loads this adapter only when the map is present: ```js {op: 'boot', config: {dataRoot, businesses, readers, repoRoots, trackers: {acme: {baseUrl: 'https://tasks.example.org', project: 12, pollSeconds: 30, reconcileMinutes: 60}}}} ``` `baseUrl` is the origin only, for example `https://tasks.example.org`. The adapter adds `/api/v2` and refuses a URL with a path with `tracker-config`. S1's business validator accepts a path today, so a wrong value fails at boot, not at validation. That's a follow-up for S1. `pollSeconds` defaults to 30 and must be at least 10. `reconcileMinutes` defaults to 60 and must be at least 1. From the business definition (`busBusiness` output) the adapter needs: - `tracker.sync.botId` and `tracker.sync.credentials.vikunja`, the sync bot. Its credential reference is `/@sync`. - `tracker.labels`, a map from label title to label id. These are the only label ids a verb may add or remove. - for each role with a `tracker` block, `tracker.botId` and its Vikunja credential. Exactly one of those roles must have definition `pm`. A business with no `trackers` entry has no tracker, and its task verbs refuse with `tracker-unconfigured`. Any other problem in this config refuses the whole boot with `tracker-config`. To embed it without the process wrapper, pass the factory to `startBroker`: ```js import { tasksAdapter } from '@mosaic/tasks'; await startBroker({dataRoot, businesses, tasks: tasksAdapter({trackers})}); ``` The factory returns `{handle, timeout, close}`. `timeout` is 60 s (`TIMEOUT`). A verb can make several Vikunja writes, and the socket server raises its idle timeout to this value for task verbs. A client that calls a task verb must wait at least that long too. Give up sooner and the outcome is unknown, not failed. ## Startup The broker doesn't wait for Vikunja. Startup runs in the background, and until it passes every verb for that business refuses with `tracker-starting`. In order, it checks: 1. `GET /info` reports v2.4 or later (`tracker-version`). Anything other than v2.7.0, the probed release, is logged as `untested-version` and allowed. 2. The sync credential `/@sync` exists (`credential-unavailable`) and no Vikunja credential of the business is past its `expires_at` (`credential-expired`). The adapter checks the date itself, because Vikunja accepts a token minted with a past expiry (probes.md, O). 3. The sync bot can read the project (`tracker-project`). 4. The project has exactly one manual kanban view with the buckets `todo`, `in-progress`, `in-review`, `blocked` and `done`, each once. `done` is the done bucket and `todo` the default (`tracker-install`). 5. Each token is no broader than its role (`scope-too-broad`). The sync bot's PATCH on a missing task must answer 401. Each role must get 404/4002 reading it and 401 deleting it, and each non-pm role 401 adding a label to it. The missing task is id 2147483647 (`MISSING`). The addendum said one more than the highest id the sync bot sees, but on a shared instance that id can be another project's task, which answers 403. The largest int32 is missing on any instance this stack will meet. A scope 401 comes before the 404 (probes.md, O). 6. The pm can see every configured label through `GET /labels` (`tracker-labels`). Otherwise label writes would answer 403. A bot sees a label only through its owner (probes.md, L), so `svc-$BIZ` owns the labels. Then it runs one reconcile, and the business is `ready`. A startup refused with `tracker-unavailable`, `tracker-rate-limited` or `service-failed` is tried again at the next poll. Any other refusal stays until the broker restarts, because it needs a person to fix the install or a token. A 401 at any step refuses with `tracker-unauthorized`. ## Verbs All eight go through the same steps: 1. The field-table writer check. `task.create`, `task.assign`, `task.reassign`, `task.schedule`, `task.priority.change` and `task.close` write fields only the pm writes, so another role gets `field-writer`. This runs before `authorize`, so a refused call consumes no decision. 2. A compare-read with the sync token: the task, and its bucket from the board when it's open. 3. If the caller passed `expect`, a digest of the fields they last saw, and it differs, a `task.conflict` event and `task-conflict`. 4. A done task refuses every verb with `task-done`. Reopening is a person's act. 5. `broker.authorize` with the decision id and the task ref. 6. The writes, each with the acting role's own token. Only the writes that change something are sent. 7. A final read, then one self snapshot and its event. A write that answers 5xx or doesn't answer is never retried. The adapter reads the task again. If the write took effect, it continues. If not, or the re-read fails too, the verb refuses with `write-uncertain`. A create can't be checked that way, so an uncertain create is reported as `write-uncertain` and a person checks the board. On success the self snapshot holds the fields the verb set, not what the final read saw. If a person edits the task between the last write and that read, the next poll still records the edit as theirs. The final read can fail after the writes landed. Its refusal would hide them, so it isn't passed on: - Every write landed: the verb succeeds and records its event and a snapshot of the fields it set. The snapshot's `updated` is the compare-read's, since nothing newer was read. - Some landed and a later one failed: `write-uncertain`, and nothing is recorded. The poll records what the tracker holds. - None landed: the failed write's own code. - A create: the create's answer, plus the labels it wrote, in the default bucket. `task.created` is recorded and the verb succeeds. A refusal here would read as "nothing happened" and invite a second create. | Verb | Who | Arguments | Event | |---|---|---|---| | `task.create` | pm | `title`, `request`, `requirement`; optional `description`, `due_date`, `priority`, `labels`, `relations` | `task.created` | | `task.assign` | pm | `task_ref`, `role` | `task.assigned` | | `task.reassign` | pm | `task_ref`, `role` | `task.assigned` | | `task.schedule` | pm | `task_ref`; one or more of `due_date`, `labels.{add,remove}`, `relations.{add,remove}` | snapshot only | | `task.priority.change` | pm | `task_ref`, `priority` 0 to 5 | snapshot only | | `task.scope.change` | any role, by authority | `task_ref`; `title` and/or `description` | snapshot only | | `task.update.assigned` | the assigned role | `task_ref`; one or more of `percent_done` 0 to 1, `state`, `comment` | `task.state` | | `task.close` | pm | `task_ref`, `verdict` | `task.closed` | Every verb also takes `decision` and `expect`. A task ref is `vikunja:/`, and a ref in another project refuses with `task-project`. Due dates have one-second precision. Vikunja v2.7.0 stores `due_date` to the second, though a write's answer echoes the milliseconds it was sent. The adapter drops the milliseconds before it writes, so `2026-12-01T09:00:00.456Z` is stored, recorded and digested as `2026-12-01T09:00:00.000Z`. Without that, the poll would read the bot's own write back as a person's edit. Details that aren't obvious from the table: - `task.create` needs `request`, the id of a `human.input` event of this business (`request-not-found` otherwise), and a `requirement` like `REQ-TASK-3`. `task.created` is recorded even when a label or relation write after the create fails. The task exists, and the verb then refuses with that write's code. - `task.assign` refuses a task a bot already holds (`task-assigned`). `task.reassign` refuses one no bot holds (`task-unassigned`). Both leave people's assignments alone, and remove only bots the compare-read showed, because Vikunja answers 204 to removing someone who isn't assigned (probes.md, D). - `task.schedule` removes only labels the compare-read showed, because removing an absent label answers 403 (probes.md, D). Every label id must be in `tracker.labels`, or the verb refuses with `label-not-allowed`. That allowlist is the second guard behind label ownership (decision 68). - `task.update.assigned` refuses `not-assigned` unless the caller's bot is an assignee. `state` is `todo`, `in-progress`, `in-review` or `blocked`. `done` isn't a target here (`invalid-state`), because closing is the pm's `task.close`. The comment text stays in Vikunja. The event records `comment: true` and nothing of the text. - `task.close` moves the task into the done bucket. `verdict` is a citation of the review verdict in the queue. The adapter records it and doesn't check it. Checking it against the queue is a follow-up. - A coder role's `crossRole` grant of `task.reassign` can't be used. The writer check refuses it before authority is looked at. If a coder should hand work on, that's a change to the field table, not to the role file. Verbs for one business run one at a time, in the same queue as the poll, so a poll never reads between two writes of a verb. ## Sync Every `pollSeconds` the adapter reads the whole board, then the task list filtered on `updated >` the start of the previous tick minus 60 s (`WINDOW_MS`), floored to the second. Decision 68 set both. The window covers Vikunja's late `updated` bump (probes.md, U, which has the measured lag). Moving a task between open buckets doesn't bump `updated` at all, so the board read is what catches moves. The adapter drops any read whose digest matches what `task_current` already holds. Each read that differs becomes a poll snapshot, with `via` set to `board`, `cursor`, `task` or `reconcile`, and a `task.changed.external` event naming the fields that changed. A read no newer than the last self snapshot for that task is skipped, and the next tick reads it again. That keeps a poll that started before a verb's write from recording the old state over it. A task that was open and isn't on the board any more is read once. Done gives a snapshot in the done bucket and a `task.changed.external` event. Moved to another project, not readable (403) or deleted gives a `task.missing` event with the reason (`moved`, `no-access` or `not-found`) and a tombstone snapshot. Still open in the project means the board read raced a move, and the next tick places it. For each task the cursor returned with a new `updated`, the tick reads the comments and counts those by anyone but this business's bots, newer than the last one it saw. On the first look at a task it counts only comments from inside the window, so a restart doesn't report old ones. The event carries the count, never the text. Every `reconcileMinutes`, and once at startup, a full walk reads every task with `expand=comment_count`. A task whose count changed since the last reconcile gets the same comment check. This catches comments on tasks the cursor didn't return, so those show up within one reconcile period, not one poll. The adapter checks credential state after startup and after every tick. Each of `credential.expiring`, `credential.expired` and `credential.changed` is recorded once per instance per process. `changed` means the token file's inode, size or mtime moved. `close()` stops the timers and waits for the queue. A verb still queued at that point refuses with `tracker-closed`. ## Refusals | Code | Meaning | |---|---| | `tracker-config` | the boot config or business definition is wrong | | `tracker-unconfigured` | the business has no tracker | | `tracker-starting` | startup hasn't passed yet | | `tracker-closed` | the broker is shutting down | | `tracker-unauthorized`, `tracker-forbidden` | Vikunja answered 401 or 403 | | `tracker-not-found`, `task-not-found` | 404 without and with Vikunja's code 4002 | | `tracker-invalid` | Vikunja answered 400 or 422 | | `tracker-rate-limited` | 429 | | `tracker-unavailable` | 5xx or no answer on a read | | `write-uncertain` | 5xx or no answer on a write, not settled by a re-read | | `tracker-shape`, `tracker-paging`, `tracker-unexpected` | Vikunja sent something the probes didn't show | | `task-moved`, `task-placement`, `task-project` | the task is in another project, or on no bucket | | `task-conflict`, `task-done`, `task-assigned`, `task-unassigned`, `not-assigned` | the task's state doesn't allow the verb | | `field-writer`, `label-not-allowed`, `unknown-role`, `invalid-state`, `request-not-found`, `invalid-request` | the call itself is wrong | Startup adds `credential-unavailable`, `credential-expired`, `tracker-version`, `tracker-project`, `tracker-install`, `scope-too-broad` and `tracker-labels`, listed above. ## Tests `node --test tests/*.test.mjs` from this directory. The tests use `FakeVikunja` (`src/fake.mjs`, also exported as `@mosaic/tasks/fake`), an in-memory Vikunja v2.7.0 that follows the probe notes. `tests/fixtures/v2-shapes.json` holds responses recorded from the pinned image. `shapes.test.mjs` checks that every key the fake sends is one Vikunja sent in the same place, with the same JSON type. One limit of that check: user and task objects are compared against every user or task any recording held, so a key one route adds, like `comment_count`, passes on any route. The fake may also leave keys out. The shape test catches a fake that invents a field, not one that puts a real field on the wrong route. Nothing in these tests reaches a real Vikunja. The probes and the live run use a scratch container on 127.0.0.1, never a shared instance. ## Bundled Vikunja `deploy/vikunja/` has the compose file for installs without their own Vikunja. See its README.