Files
stack/packages/tasks

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.botId and tracker.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 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:

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 <business>/@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. 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:

  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. Each task a relation names, in task.create or task.schedule's add and remove, is read once with the sync token. It must exist (task-not-found otherwise) and sit in the project its ref names (task-moved otherwise).
  6. broker.authorize with the decision id and the task ref.
  7. The writes, each with the acting role's own token. Only the writes that change something are sent.
  8. A final read, then one self snapshot and its event.

fields.json holds the slice 1 field table as data (row 61, lead decision 113): the writer each verb checks in step 1 or in task.update.assigned (pm, the assigned role or any role), and the fields each verb writes with their Vikunja calls. loadFields() checks its shape and refuses any other. Whether a write also needs a resolved decision isn't in it. That comes from the role's authority in roles/*.json, which authorize reads in step 6. tests/fields.test.mjs walks every verb against the fake and checks both.

requirements.json holds the approved PRD's requirement ids as data (row 90, #1584): the PRD's path and version, and each id with a one-line title, in the PRD's order. The PRD (docs/prd/mosaic-stack.md) stays the reviewed source. tests/requirements.test.mjs reads its **REQ-...** bullets and its status line, and fails unless the list holds the same ids in the same order at the same version. A later PRD version is a new list in a reviewed commit. loadRequirements() (src/requirements.mjs) checks the file's shape, refuses any other and returns it frozen; the broker loads it at startup and preserves the unscoped requirements response. A bad catalogue refuses startup. bootConfig passes an explicit {business,project,tracker_project} builtin binding only after checking the code project's committed identity, Stack foundation ancestry, and exact repository-owned PRD/catalogue bytes. A name, requirement prefix or board number cannot establish that binding. New legacy creates use its baked version/hash; existing recorded tasks gain no scope retroactively.

Product reads and creates use Broker.requirementsFor and the Task 4 catalogue. requirements({project_key,requirement_snapshot?}) returns the exact approved snapshot, including PRD version, records commit and spec commit. A product call never falls back to the baked Stack catalogue. Creation checks that snapshot while planning and immediately before admission, with current session/role authority and refreshed tracker membership/installation. task.created records scope: {project_key,requirement_snapshot,requirement}, source version/commits and op. Task filters accept project_key and requirement_snapshot so identical requirement IDs in different products remain distinct in task lists and trails. Scoped pushes, reviews and exports must use the task's bound code project.

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 legacy create without an operation key can't be checked that way, so it reports write-uncertain and a person checks the board. Creates with op use the durable recovery protocol below.

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 project, description, due_date, priority, labels, relations, op, project_key, requirement_snapshot 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>. A verb acts in the ref's project, on that project's own board. The project must be installed: the root, or with tracker.descendants on, an installed child (see Project trees). A ref to a project outside the tree refuses with task-project, and one to a watch-only child with project-not-installed. The same holds for the task a relation names.

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 from its approved catalogue snapshot, like REQ-TASK-3. An id in the right form that the snapshot does not hold refuses unknown-requirement. The request is the request field message.receive gives a human's message, not the message's own id. Every intake request, including selection and discovery, requires project_key, requirement_snapshot and a stable op; missing fields refuse task-scope-required. The request's unique confirmed binding resolves the tracker board. project, when supplied, asserts that numeric board; a mismatch refuses request-tracker-refused. Unscoped legacy calls require the explicit Stack binding and no conflicting source product association. Any other project than an installed one of the tree refuses with project-not-installed, and a value that isn't a positive integer with invalid-request. The decision a create may need is bound to no target, so it doesn't name the project. 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.
  • The coder role holds no task.reassign grant (row 73). The writer check refuses a non-pm task.reassign before authority is looked at anyway. 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.

Recoverable creates (lifecycle Task 5a)

A product caller supplies project_key, requirement_snapshot and a stable op with task.create, reusing all three for retries of that exact request and body. The adapter validates the approved snapshot and request binding; it never generates an omitted product operation key. Unscoped legacy calls retain the existing response/body behavior through the explicit Stack binding and can opt into recovery by supplying op.

The adapter adds this visible suffix to the description, omitting absent fields:

Mosaic operation: <op>; request: <request>; scope: <project_key>; snapshot: <snapshot>

Before any create-path network I/O, the broker atomically checks authority, consumes any approval once and appends an action.allowed event with operation: tracker.create.prepared. It stores the original role/run, bot, request, requirement, complete POST body and SHA-256 digest, target project and installed board, labels and relations. The prepared event is a one-shot POST reservation. A repeated op with a changed body or target refuses with task-create-conflict. Even a restart before the POST leaves the operation reserved; a negative scan cannot prove that no create happened.

An uncertain answer records an action.refused reconciliation wait with operation: tracker.create.wait and code task-create-uncertain. Recovery walks the target project's full task list by increasing ID, including completed tasks. Only one matching marker, the original creator bot, unchanged create fields and the original installed board permit adoption. Missing, edited, duplicate or foreign markers, changed fields and incomplete/failed scans stay uncertain. There is no replacement POST. The wait is deduplicated; a later task.created receipt resolves it without rewriting history.

The adapter's host-only reconcileCreates(business) runs at startup and can be called for recovery. It only reads the tracker with sync authority. The host-only inspectProject(business, project) exposes the same installation checks startup uses. Broker.recordRecoveredCreate({receipt, task_ref, snapshot}) verifies the stored preparation, body digest, target and creator, then atomically writes the missing self snapshot and task.created event once under the original role/run. These are host methods, never socket verbs.

A live PM retry resumes labels and relations using the existing compare-read and uncertain-write checks, rechecking current authority before effects. Startup never writes these as sync. Assignment remains the existing separate task.assign operation on the adopted reference. A closed task can be adopted; recovery does not reopen it or add follow-up writes to it.

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 changes done.
  • 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 in every installed project of the tree, on that project's own board: task.update.assigned moves a child's task between the child's buckets, and task.close puts it in the child's done (row 63, #1556). A verb on a watch-only child's task refuses with project-not-installed, because the adapter has no board there it trusts. With descendants off the tree is the root alone.

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.missing with no-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
task-create-uncertain reserved operation requires reconciliation; never authorizes another POST
task-create-conflict an operation key or recovery receipt has a different identity, body or target
task-scope-required an intake/product create omitted its code project, snapshot or operation
requirement-snapshot-required, requirement-snapshot-refused, requirement-snapshot-revoked no approved catalogue binding, or an invalid/foreign/stale snapshot
request-project-refused, request-tracker-refused the request's confirmed product or tracker board does not match the create
task-project-refused a scoped task's Git operation names another code project
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
project-not-installed the project is watch-only, or not an installed project of the tree
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, unknown-requirement, 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.

Durable comment cursor (row 49)

The bus stores each business/task's highest observed comment ID separately from task_current and its digest. Adapter startup restores those IDs before its first reconcile. The first read of each task's comment list after startup catches outage comments, even outside the normal updated window. A reconcile also checks comment lists when the expanded count changes. Bot IDs advance the cursor but do not produce external-comment counts. The cursor and event commit together; in-memory ID, count and timestamp caches advance only after a successful commit. A crash after commit reloads the durable cursor and does not repeat the event.

No cursor row is written until a new ID is observed. Empty tasks implicitly have cursor zero; initial timestamps use the database's creation/migration epoch minus a 60-second skew margin. This can duplicate comments at the first migration if the tracker clock is ahead, and clocks more than a minute behind remain outside the guarantee. Once a cursor exists, timestamps do not govern freshness. See the bus README for the explicit offline schema migration and Mos's separate approved downtime/re-pin procedure. No development seat migrates the live store.