With tracker.descendants on, the tracker project is the root of a tree.
Each reconcile reads the sync bot's project list (archived projects and
negative pseudo-project ids left out) and walks it by parent_project_id.
A child that passes the install checks is read through its board, and
one that doesn't is watch-only (bucket 0) and never refuses the
business. A tick reads each project in the tree. A child answering 403
or 404 leaves the tree only if GET /projects/<id> then answers 403 or
404 too, and its open tasks get the one-by-one read. Task refs carry
the project, so a task moved inside the tree shows under its new ref
and the old ref gets its tombstone. tracker.descendants reaches the adapter
through the business var and packages/cli/src/config.mjs (lead decision
87). Off, the adapter reads as v0 did.
Darkwing built it (agents/darkwing/work/queue-62), in three rounds.
Round 3 was approved by Filbert (27317) and Dewey (27338) on manifest
8b81e836, 13 paths. Their non-blocking notes and Darkwing's follow-ups
(two surviving mutants in test 18, the fake's view-404 code) go to a
follow-up row.
Sage's gate on 918a4a19, sequential and teed: node suites tasks 71,
business 60, bus 80, cli 110 and queue 150, 0 failed; webui 39 of 40,
then 40 of 40 on three reruns (row 62 touches no webui file; a timing
test under load 17 with Dewey's gate running beside it); test-auth 15,
test-conductor 17, test-config 24, test-discord 66,
test-extension-package 18, test-foundation 44, test-queue 27,
test-release 14, test-task 98, all 0 failed (Docker on). edc5de71..HEAD
touches none of the 13 paths.
Live read, 2026-10-11T21:28:03Z, tasks.woltje.com v2.7.0, root #19,
descendants on, sync key only, GETs only (the startup scope probes not
sent): reconcile 15 GETs, 42 snapshots; tick 8 GETs, 0 snapshots.
Children 18, 20 and 21 are watch-only. A missing view id on #18 answers
404 (code 3014) while #18 reads 200, as the README says. Record:
agents/sage/work/vikunja-setup/2026-10-11_tick-62.json.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Tasks adapter, slice 1 S3
This package connects the broker to Vikunja v2. It handles the eight task
verbs, polls each business's project (or project tree), 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:
{op: 'boot', config: {dataRoot, businesses, readers, repoRoots,
trackers: {acme: {baseUrl: 'https://tasks.example.org', project: 12,
pollSeconds: 30, reconcileMinutes: 60, descendants: false}}}}
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. descendants defaults to false
and must be a boolean. See "Project trees".
From the business definition (busBusiness output) the adapter needs:
tracker.sync.botIdandtracker.sync.credentials.vikunja, the sync bot. Its credential reference is<business>/@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
trackerblock,tracker.botIdand its Vikunja credential. Exactly one of those roles must have definitionpm.
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:
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:
GET /inforeports v2.4 or later (tracker-version). Anything other than v2.7.0, the probed release, is logged asuntested-versionand allowed.- The sync credential
<business>/@syncexists (credential-unavailable) and no Vikunja credential of the business is past itsexpires_at(credential-expired). The adapter checks the date itself, because Vikunja accepts a token minted with a past expiry (probes.md, O). - The sync bot can read the project (
tracker-project). - The project has exactly one manual kanban view with the buckets
todo,in-progress,in-review,blockedanddone, each once.doneis the done bucket andtodothe default (tracker-install). - 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). - 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), sosvc-$BIZowns the labels.
Then it runs one reconcile, and the business is ready. With
descendants on, that reconcile also reads the project tree, so a 401 on
the project list refuses startup too.
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:
- The field-table writer check.
task.create,task.assign,task.reassign,task.schedule,task.priority.changeandtask.closewrite fields only the pm writes, so another role getsfield-writer. This runs beforeauthorize, so a refused call consumes no decision. - A compare-read with the sync token: the task, and its bucket from the board when it's open.
- If the caller passed
expect, a digest of the fields they last saw, and it differs, atask.conflictevent andtask-conflict. - A done task refuses every verb with
task-done. Reopening is a person's act. broker.authorizewith the decision id and the task ref.- The writes, each with the acting role's own token. Only the writes that change something are sent.
- 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
updatedis 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.createdis 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:<project>/<task>, 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.createneedsrequest, the id of ahuman.inputevent of this business (request-not-foundotherwise), and arequirementlikeREQ-TASK-3.task.createdis 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.assignrefuses a task a bot already holds (task-assigned).task.reassignrefuses 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.scheduleremoves only labels the compare-read showed, because removing an absent label answers 403 (probes.md, D). Every label id must be intracker.labels, or the verb refuses withlabel-not-allowed. That allowlist is the second guard behind label ownership (decision 68).task.update.assignedrefusesnot-assignedunless the caller's bot is an assignee.stateistodo,in-progress,in-revieworblocked.doneisn't a target here (invalid-state), because closing is the pm'stask.close. The comment text stays in Vikunja. The event recordscomment: trueand nothing of the text.task.closemoves the task into the done bucket.verdictis 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
crossRolegrant oftask.reassigncan'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.
Project trees
tracker.descendants is a project-layer business variable, false by
default. packages/cli/src/config.mjs passes it to the adapter as
descendants (lead decision 87). Off, the adapter reads one project,
exactly as before, and never reads the project list.
On, the tracker project is the root of a tree. The sync token then also
needs projects read_all, for GET /projects. Without it startup
refuses with tracker-unauthorized. At startup and at each reconcile the
adapter reads that list, every page, and follows parent_project_id down
from the root to every depth. It skips negative ids (saved filters and
Favorites) and archived projects. Access in Vikunja inherits down the
tree, so a share on the root is enough for the sync bot to read every
child. The adapter writes no project, view or bucket, in the tree or out
of it.
Startup checks the root's install as before. A root that fails refuses
the business with tracker-install. Each child gets the same install
checks at discovery:
- A child that passes is installed. The adapter reads it through its board, like the root.
- A child that fails is watch-only, and never refuses the business. The
adapter reads its open tasks from the task list and records them with
bucket: 0(WATCH_ONLY), because it has no board it trusts. Vikunja bucket ids start at 1, so 0 is never a real bucket. Nothing maps 0 to a bucket or a state. A move between a watch-only child's buckets changes no recorded field. Closing or reopening the task still changesdone. - A child whose views or buckets can't be read (5xx, no answer) is
neither. That refuses with
tracker-unavailable, which is transient. At startup the adapter tries again at the next poll. At a later reconcile the reconcile fails, the tree from the last one stays, and the next reconcile tries again.
A task in a bucket outside the five, like a Backlog someone added,
keeps that bucket's raw id in its snapshot.
Refs stay vikunja:<project>/<task>. Verbs act on the root only. A verb
on a child's task refuses with task-project, as it does for any other
project.
A task moved between two projects of the tree gets task.missing with
reason moved and the new project under its old ref, and a first
snapshot under its new ref. A task moved out of the tree gets the same
tombstone and nothing after it.
A project leaves the tree when it is archived, moved under a parent outside the tree, no longer shared with the sync bot, or deleted. The next reconcile drops it, or a tick before that (below), and each of its tasks the bus holds is read once:
- The sync bot can still read the task, in that project:
task.missingwithno-access. The business no longer reaches it. - The read answers 403:
no-access. 404:not-found. - The task moved to another project at the same time:
moved.
The tombstone keeps the task's last updated. Nothing in the ledger is
rewritten: the earlier snapshots stay and the tombstone is a new row.
A tick doesn't read the project list, so it keeps reading an archived
child, or one moved out with a share of its own, until that reconcile.
A child that answers 403 or 404 in a tick (no longer shared, moved under
a project the sync bot can't read, or deleted) leaves the tree at that
tick only if GET /projects/<id> then answers 403 or 404 too, and its
tasks get the same one-by-one read. A deleted kanban view answers 404
while its project still reads 200, so the child stays and the tick fails
until the next reconcile reads the child again, watch-only if it has no
installed kanban view. A 403 or 404 on the root
still fails the tick, and so does any other failure on a child. A new
child joins at the next reconcile, and so does a dropped one the sync
bot can read again.
Each tick reads per project. An installed project costs the board and
the cursor walk, a watch-only one its open task list and the cursor
walk. That is two GETs per project, plus one per extra page of 50. A root
with three watch-only children, like the bus's #19, costs 8 GETs on a
quiet tick. On top of that a tick reads comments once per task the cursor
returned with a new updated, and reads each open task that left a
board once. A reconcile adds GET /projects (one page per 50 projects),
then each child's views and kanban buckets, then two GETs per project.
tests/tree.test.mjs counts both for a three-level tree.
Vikunja v2.7.0 can also list tasks across every project the token reads
in one walk, GET /api/v2/tasks with a project in ... filter
(agents/darkwing/work/queue-62/probes.md). That would make a tick two
GETs whatever the tree's size. The adapter doesn't use it yet: it waits
on a live read with the sync key, and a switch is a follow-up row.
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.
The fake also models project trees: parent_project_id, the project
list with archived projects and saved filters, and access inherited from
an ancestor's share (project_access.go: the nearest grant wins, admin
anywhere wins). No recording holds a GET /projects list, so
shapes.test.mjs checks its items against the recorded
GET /projects/{id} object, which has parent_project_id and
is_archived.
tests/tree.test.mjs covers descendants.
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.