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]>
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.
verbsmaps each of the 8 broker task verbs to the writerverbs.mjschecks first:pm(the pm definition,field-writerotherwise),assigned(a role whose bot is an assignee,not-assignedotherwise) orany(no writer check). Onlytask.scope.changeisany, and onlytask.update.assignedisassigned.fieldslists 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-writerornot-assignedand sends no write; - a role without the verb within-role gets
decision-requiredand 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
PATCHby its body keys, a bucket move intodoneasdone), 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).