Files
stack/agents/darkwing/work/queue-61/build.md
T
jason.woltjeandClaude Opus 5.5 f39023818f darkwing: row 61 (#1550) packet, round 1
The task field-writer table as data: build.patch (12 files on 4f8ac1e7),
candidate manifest, gate summary (all green), mutants 33/33 killed.
The candidate is not committed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:43:39 -05:00

8.4 KiB

Row 61, the task field-writer table as data, round 1 (Darkwing)

Gitea #1550, Vikunja #208. Brief: docs/plans/2026-10-10_design-implementation.md § "Task field-writer table as data". Lead decision 113 settles the code and prose disagreements I raised, and queue rev 672 adds the field table section of docs/plans/2026-10-04_slice-1.md to the files owned. Base 4f8ac1e7; nothing from there to the packet commit touches the candidate's paths.

The candidate is build.patch, 12 files, applied with git apply --index on 4f8ac1e7. candidate-manifest.sha256 lists the 12 files after the apply. Nothing here is committed to refactor.

What ships

The table, packages/tasks/fields.json. It has two layers, as decision 113 rules.

  • verbs maps each of the 8 broker task verbs to the writer verbs.mjs checks first: pm (the pm definition, field-writer otherwise), assigned (a role whose bot is an assignee, not-assigned otherwise) or any (no writer check). Only task.scope.change is any, and only task.update.assigned is assigned.
  • fields lists the 11 fields in the slice 1 table. Each field names the verbs that write it and the Vikunja calls each verb makes for it, with the same placeholders as the prose.

Whether a write also needs a resolved decision isn't in the file. It comes from the role's authority in roles/*.json, which the broker already reads, so a second copy could only drift.

The schema is code. packages/tasks/src/fields.mjs has checkFields, which refuses any other shape: unknown or missing keys, a writer outside the three, a verb set that isn't exactly the broker's TASK_VERBS, a field twice, a verb twice in one field, a call that isn't a Vikunja write, a call twice, and a verb that writes no field. Labels are capped at 80 characters, the source and notes at 256. loadFields() reads the committed file, checks it and freezes it. Its errors name fields.json, never a path. The repository has no JSON Schema validator, and packages/business checks its files the same way, so I followed that instead of adding a dependency.

The test, packages/tasks/tests/fields.test.mjs. It reads both expectations rather than writing them out. The writer layer comes from the table, and authority from roles/*.json through packages/business loadRole, so the researcher's version 1 file grants nothing. Then it drives every task verb as pm, coder, reviewer and researcher against the fake Vikunja, with the cto as the technical arbiter:

  • a role the table's writer excludes gets field-writer or not-assigned and sends no write;
  • a role without the verb within-role gets decision-required and sends no write; the test raises a decision, checks it routes to the technical arbiter when the role lists the verb cross-role and to the human otherwise, resolves it and retries;
  • every write that goes out is mapped back to the fields it carries (a PATCH by its body keys, a bucket move into done as done), and at the end each verb's writes must equal the table's calls for that verb.

A change to the table, a role file or verbs.mjs that leaves them disagreeing fails this file. Two smaller tests check that the committed table loads frozen with exactly the broker's verbs, and the schema cases.

The inspector. The task page (#/tasks/<ref>) has a "Who may write" section before the trail, with one row per field: the label, its note, and each verb with its writer in words. Its intro says the decision layer comes from the role's authority on Business, and that edits made in Vikunja aren't checked against this table. A table that fails to load shows the read error in that section only; the rest of the page renders.

To get the table to the browser, serve.mjs has GET /api/task-fields, and reads.mjs has fieldTable(file), named so it doesn't read as the taskFields digest in packages/tasks. The table is repository data, so the route answers without --business or a system config and carries no at. A table that fails its check is 503 read-failed with the loader's message and no path; other methods are 405. startServer takes a fieldsFile option for tests only. I count serve.mjs and reads.mjs as part of "the webui Tasks inspector": the page can't read the table without them. If reviewers read the files owned more narrowly, that's the place to say so.

The prose. Only the field table section of the slice 1 plan changes, with the five corrections from decision 113 and a paragraph saying where the data lives. One wording differs from what I proposed: the priority row says "a change after create needs a resolved decision (cross-role)", because task.create is within-role for pm, so priority at create needs none. The READMEs for packages/tasks and packages/webui describe the file, the route and the section.

Out of scope and unchanged: who may write a field, roles/*.json, and close not checking the verdict (Vikunja #218).

This lands on the live Console

Since the 10-12 cutover the Console runs from the canonical checkout (decision 109). Landing this changes serve.mjs, reads.mjs and public/bus.js there, so Sage notifies Mos before it lands, and the status and healthz checks follow. I don't start or restart the Console.

Overlap with row 81

Dewey's row 81 (#1575, in review) also changes serve.mjs, public/bus.js and packages/webui/README.md. The two patches touch different hunks. On 4918d410 I applied both with git apply --3way --index, in each order, and both went in cleanly both times. Whichever row lands second still needs a new round, because its manifest names the other's lines.

Tests

In ~/darkwing-scratch/r61/gate, a detached worktree at 4f8ac1e7 with build.patch applied by git apply --index and node_modules linked. Node v26.8.1, run in order from 18:39:31Z to 18:42:55Z on 2026-10-12 (gate-summary.txt). Every one exits 0:

Suite Result
packages/tasks (node --test tests/*.test.mjs) 74 pass, 0 fail
packages/webui (node --test tests/) 82 pass, 0 fail
test-auth 15 passed
test-conductor 17 passed
test-config 24 passed
test-discord 66 passed
test-extension-package 18 passed
test-foundation 44 passed
test-queue 150 node tests, 27 suite checks
test-release 14 passed
test-task 26 passed, run with DOCKER_HOST=unix:///nonexistent, so its 4 live checks skip

Afterwards I removed the gate_default Docker network the gate left behind, which had no containers.

Mutants

mutants.sh WORKTREE [browser|light|NAME,...] makes 33 mutants, one site each, runs the named test file in that package and restores the file from a backup. The edits use queue-59-review/r1/mut.mjs. The light 30 ran to 18:39:18Z (mutants-light.txt), the 3 browser ones to 18:38:38Z (mutants-browser.txt). All 33 are killed, and build.patch still hashes the same in the worktree afterwards.

Group Mutants Killed by
Schema (fields.mjs) 15: unknown key, optional key, any writer string, field twice, any field name, unbounded label, any note, verb twice, any call, call twice, unused verb, verbs a subset, any source, no freeze, wrong not-JSON code the schema cases; no-freeze by the load test
Table (fields.json) 8: scope to pm, priority to any, update to any, close to assigned, reassign without its delete, create without the due date, comments by close, done by update the verb walk; done-to-bucket by the schema
Code (verbs.mjs) 3: priority without a writer check, scope as pm, the assignee check gone the verb walk
Route and read 4: any method, the route ignoring fieldsFile, a partial table on error, any message reads.test.mjs
Section (bus.js) 3: a throwing section, the two writers swapped, no note bus-browser.test.mjs

done-to-bucket moves the done write from task.close to task.update.assigned. The verb walk never runs on it: the load refuses first with "verbs.task.close: writes no field", which fails the whole file. That's the schema doing its job, but it means the walk's own check of the done mapping is only shown by comments-by-close.

An earlier light run left 4 survivors: required-key-optional, label-unbounded, verbs-subset and source-any. I added six schema cases: a blank source, a source that isn't a string, a 257-character source, a task verb missing along with the field it writes, an 81-character label and a 257-character note. All 4 died (mutants-light-r2.txt).