# Row 38, S3: tasks (the Vikunja adapter, broker task verbs, sync) Darkwing, 2026-10-08. Issue #1520, reviewer Filbert. Brief: `docs/plans/2026-10-04_slice-1.md`, section "Slice 1 S3". Design: addendum B sections 3, 4 and 7, decisions 68 and 70. Nothing in the candidate is committed, staged or pushed. Base is 81339889 (refactor HEAD at gate time). The candidate was built on bee89d10, and nothing under `packages/` or `scripts/` changed between the two. In a fresh worktree at 81339889 the patch applies and the result matches the manifest 28/28. The row can reach review but can't close. The brief's gate wants the live run against the instance from row SR, and that waits for T236's base URL. What's here is the same run rehearsed against a scratch container. ## Files `build.patch` (sha256 `eb225e5305f50811354604ee45a6e0a7489d9a76543615b2caaba102c13d5700`) changes 5 files under `packages/bus/src/` and adds 23. `build-manifest.sha256` (sha256 `be8dbd8ca03ca7264666ba171bd7bffbaaa08c6b265d60e7d587f1bc53897daf`) pins all 28 after the patch. New, `packages/tasks/`: - `src/vikunja.mjs`: the v2 client. Every call goes through the broker's `credentials.use()`, so the adapter never holds a token. Maps Vikunja's answers to refusal codes. - `src/startup.mjs`: the six startup checks. - `src/verbs.mjs`: the eight task verbs. - `src/sync.mjs`: the tick (board read plus `updated` cursor), the reconcile walk, the comment check and missing-task handling. - `src/adapter.mjs`: the factory the broker loads, one serial queue per business, timers, credential state, `status()`. - `src/digest.mjs`, `src/index.mjs`, `src/fake.mjs` (`FakeVikunja`, the recorded fake of the v2 routes). - `tests/`: 44 tests in 6 files, `world.mjs` (a broker, credentials and the fake wired together) and `fixtures/v2-shapes.json` (responses recorded from the pinned image). - `deploy/vikunja/compose.yaml` and its README: the bundled option, REQ-TASK-3. The upstream image by digest, published on 127.0.0.1, no secret in the file. `tests/deploy.test.mjs` checks the digest against the runbook and the bind address. - `live/run.mjs` and its README: the live run. - `README.md`, `package.json`. Changed, `packages/bus/` (registering the task verbs, as the brief allows): - `broker.mjs`: `TASK_VERBS`; `requestTask`, which checks the caller, runs the secret check on args and result, and records a refusal the same way `request` does; `recordTask`, which takes self snapshots from a role session and poll snapshots and events from the adapter (cap `null`), each from a closed list of event kinds; `taskView`. The refusal recording moved into `#refused` so both paths share it. - `runtime.mjs`: `startBroker({tasks})` takes the adapter factory, refuses an adapter without `handle` or a valid `timeout`, and closes it. - `server.mjs`: task verbs go to the adapter, async, with the socket idle timeout raised to the adapter's 60 s. Other verbs stay synchronous. - `process.mjs`: a plain-data `trackers` map in the boot config loads the adapter. Decision 70 puts the poller and reconcile in the broker runtime, and this is how the host process gets them. - `index.mjs`: exports `TASK_VERBS`. - `tests/tasks.test.mjs`: 9 tests for the above. No file outside `packages/bus` and `packages/tasks` changes. The bus schema (`task_snapshots`, `task_current`, `tasks_open`) was already in S2. ## Probes `probes.md` in this directory has every probe of addendum B section 7. The results the brief asked for: - Labels (section L), the first open point. With the runbook as written, a bot sees every label its owner made, in any project, and can attach them. A bot owned by a separate `svc-$BIZ` account sees only labels on tasks it can read. Decision 68 took that: svc owns the bots and the labels, and the broker keeps the label-id allowlist as a second guard. Startup check 6 refuses a business whose pm can't see its configured labels. - The async bump lag (section U). Comments, assignees and relations bump `updated` from an async listener. On an idle server the bump showed 0 ms after the 201. Under load one read straight after missed it and the next, 5 s later, had it. That bounds the lag at 5 s, and the 60 s window decision 68 set is twelve times that. Moving between open buckets doesn't bump `updated` at all, which is why every tick reads the whole board. - probe-s7g (section G) checked the reads the code relied on without a probe: the `related_tasks` shape, `assignees` null when empty, done tasks in the cursor, `due_date: null` stored as year 1, the missing id, the id-keyed walk. Each one held. ## Decisions 68 and 70 - svc-$BIZ owns the bots: a runbook matter (T236), not code. The adapter checks what follows from it, the labels at startup. - Label-id allowlist: `tracker.labels`; any other id refuses `label-not-allowed`. - Poller: a full board read every tick, the cursor from the previous tick's start minus 60 s, floored to the second, and a digest dedupe against `task_current`. - The broker hosts the poller and reconcile: `startBroker({tasks})` and the `trackers` boot config. ## A defect the tests found The first time the comment check looks at a task, it counts comments created at or after a bound. For the reconcile that bound is the start of the previous reconcile, to the millisecond. A comment read back from `GET /tasks/{t}/comments` has `created` in whole seconds (only the POST answer carries nanoseconds, see `fixtures/v2-shapes.json`). So a comment posted after the previous reconcile, in the same second, read as older than the bound and wasn't counted. `foreignComments` in `sync.mjs` now floors the bound to the second. The cost is that on a first look a comment up to a second older than the bound can be counted. After the first look the check goes by comment id, so this doesn't repeat. ## Live run, rehearsed `live-rehearsal.txt` (sha256 `2a79eda46af5bb20b3aa0f5e5de959320b310a299b1c157c2712df9da74c012f`) is `live-run.txt` from the rehearsal: the real broker, adapter and socket clients against a scratch v2.7.0 container, the pinned digest, on 127.0.0.1 with its own data directory. Nothing touched the estate instance or tasks.mosaicstack.dev. `rehearse.mjs` and `rehearse-setup.json` are the scratch setup that produced it. `rehearse.mjs` registers `svc-demo`, builds the project and board, makes the label and four bots, mints their tokens into environment variables only, and runs `run.mjs`. It isn't in the candidate. The first rehearsal failed the priority and scope steps with `decision-required`. The pm holds both verbs cross-role, and the run didn't raise a decision. The run now has the pm raise one and the operator resolve it through the human CLI binding, and it checks the refusal without one and the refusal on reuse. The second rehearsal went as expected in every step. Its two ticks and the reconcile after the run's own writes recorded 0 snapshots, so the dedupe works against a real Vikunja. The log was checked for every token and for "bearer" before it was written. For the estate run, the operator writes a setup file with the T236 project, bot ids, label ids and token files (`live/README.md`), then runs `run.mjs` with `--base` repeating the URL. That log goes into this directory as `live-run.txt` in a later round. ## Gate In the worktree at 81339889 with the patch applied, run one after another, each to its own file: | Suite | Result | |---|---| | `node --test 'packages/tasks/tests/*.test.mjs'` | 44/44 | | `node --test 'packages/bus/tests/*.test.mjs'` | 67/67 | | `test-auth.sh` | 15/15 | | `test-conductor.sh` | 17/17 | | `test-config.sh` | 24/24 | | `test-discord.sh` | 63/64, see below | | `test-extension-package.sh` | 18/18 | | `test-foundation.sh` | 44/44 | | `test-queue.sh` | 27/27 | | `test-release.sh` | 14/14 | | `test-task.sh` | 98/98 | After the gate I corrected the `expires` format in `live/README.md`'s example (the broker takes `YYYY-MM-DD`, not a timestamp). That's the only change since, and the tasks suite passed 44/44 again on it. The patch and manifest above are the corrected ones. `test-discord.sh` isn't clean, and I can't show it's unrelated by more than reasoning and reruns. Two engine timing tests failed: "when pi has not started a timed-out turn by the end of the abort grace..." and "a timed-out run pi did start outlives the grace...". Nothing in `packages/discord` or `scripts/test-discord.sh` imports `packages/bus` or `packages/tasks`. The load average was 22 to 28 from other sessions on the host. Reruns, full suite: | Run | With the patch | Base 81339889 | |---|---|---| | 1 | 63/64 | not run | | 2 | 64/64 | 64/64 | | 3 | 63/64 | 64/64 | | 4 | 64/64 | 64/64 | | 5 | 64/64 | 64/64 | `engine.test.mjs` alone passed 6/6 in each tree. Three of five with the patch against four of four without is a small sample. If you want it settled, the next step is the full suite on a quiet host. ## Follow-ups, not in this row - S1's business validator accepts a `tracker.baseUrl` with a path. The adapter refuses it at boot with `tracker-config`, which is later than it should be. - `task.close` records the verdict citation and doesn't check it against the queue. - A coder's cross-role grant of `task.reassign` can never be used, because the field table lets only the pm write the assignee. Either the role file drops it or the field table changes. - The comment-count bound above.