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

162 lines
8.4 KiB
Markdown

# 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`).