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