diff --git a/docs/plans/2026-10-04_slice-1.md b/docs/plans/2026-10-04_slice-1.md
index bba077263..b845fcdb9 100644
--- a/docs/plans/2026-10-04_slice-1.md
+++ b/docs/plans/2026-10-04_slice-1.md
@@ -63,17 +63,26 @@ The title, description and comments rows were corrected to what the code
enforces on 2026-10-11 (lead decision 90). `message.send` is a bus
message and never writes a tracker comment.
+The title, description, due date, priority, assigned-role and `done` rows
+were corrected to what the code enforces on 2026-10-12 (lead decision
+113). Row 61 holds the table as data in `packages/tasks/fields.json`: the
+writer each verb checks (pm, the assigned role or any role) and the
+Vikunja calls it makes. Whether a write also needs a resolved decision
+comes from the role's authority in `roles/*.json`, and the row's test
+reads it from there.
+
| Field | Writer | Action | Vikunja call |
|---|---|---|---|
-| title, description | pm at create; afterwards a role whose authority lists `task.scope.change` (pm, cto and coder today, each cross-role), with a resolved decision | `task.create`, `task.scope.change` | `POST /projects/{p}/tasks`; `PATCH /tasks/{t}` |
-| due date, priority | pm | `task.schedule`, `task.priority.change` | `PATCH /tasks/{t}` |
-| percent done | the assigned role | `task.update.assigned` | `PATCH /tasks/{t}` |
-| state (bucket) other than `done` | the assigned role | `task.update.assigned` | `PUT /projects/{p}/views/{k}/buckets/{b}/tasks` |
-| `done` | pm, after a review verdict | `task.close` | the same move, into the `done` bucket |
+| title, description | pm at create; afterwards any role through `task.scope.change`, with a resolved decision: from the arbiter for a role that lists it cross-role (pm, cto and coder today), from Jason for any other | `task.create`, `task.scope.change` | `POST /projects/{p}/tasks`; `PATCH /tasks/{t}` |
+| due date | pm | `task.create`, `task.schedule` | `POST /projects/{p}/tasks`; `PATCH /tasks/{t}` |
+| priority | pm; a change after create needs a resolved decision (cross-role) | `task.create`, `task.priority.change` | `POST /projects/{p}/tasks`; `PATCH /tasks/{t}` |
+| percent done | the assigned role; a role without `task.update.assigned` needs Jason's decision | `task.update.assigned` | `PATCH /tasks/{t}` |
+| state (bucket) other than `done` | the assigned role; a role without `task.update.assigned` needs Jason's decision | `task.update.assigned` | `PUT /projects/{p}/views/{k}/buckets/{b}/tasks` |
+| `done` | pm, citing a review verdict (recorded, not checked) | `task.close` | the same move, into the `done` bucket |
| assignee | pm | `task.assign`, `task.reassign` | `POST /tasks/{t}/assignees`; `DELETE /tasks/{t}/assignees/{u}` |
| labels | pm | `task.create`, `task.schedule` | `POST /tasks/{t}/labels`; `DELETE /tasks/{t}/labels/{l}` |
| relations | pm | `task.create`, `task.schedule` | `POST /tasks/{t}/relations`; `DELETE /tasks/{t}/relations/{kind}/{other}` |
-| comments | the assigned role, append only | `task.update.assigned` | `POST /tasks/{t}/comments` |
+| comments | the assigned role, append only; a role without `task.update.assigned` needs Jason's decision | `task.update.assigned` | `POST /tasks/{t}/comments` |
A `PATCH` body carries only `title`, `description`, `due_date`,
`priority` and `percent_done`, and only the keys the action owns. Vikunja
diff --git a/packages/tasks/README.md b/packages/tasks/README.md
index d47f5de6e..ff6753439 100644
--- a/packages/tasks/README.md
+++ b/packages/tasks/README.md
@@ -119,6 +119,16 @@ All eight go through the same steps:
that change something are sent.
7. A final read, then one self snapshot and its event.
+`fields.json` holds the slice 1 field table as data (row 61, lead
+decision 113): the writer each verb checks in step 1 or in
+`task.update.assigned` (pm, the assigned role or any role), and the
+fields each verb writes with their Vikunja calls. `loadFields()` checks
+its shape and refuses any other. Whether a write also needs a resolved
+decision isn't in it. That comes from the role's authority in
+`roles/*.json`, which `authorize` reads in step 5.
+`tests/fields.test.mjs` walks every verb against the fake and checks
+both.
+
A write that answers 5xx or doesn't answer is never retried. The adapter
reads the task again. If the write took effect, it continues. If not,
or the re-read fails too, the verb refuses with `write-uncertain`. A
diff --git a/packages/tasks/fields.json b/packages/tasks/fields.json
new file mode 100644
index 000000000..050ccc3e2
--- /dev/null
+++ b/packages/tasks/fields.json
@@ -0,0 +1,93 @@
+{
+ "source": "docs/plans/2026-10-04_slice-1.md, Field table (lead decisions 90 and 113)",
+ "verbs": {
+ "task.create": "pm",
+ "task.assign": "pm",
+ "task.reassign": "pm",
+ "task.schedule": "pm",
+ "task.priority.change": "pm",
+ "task.scope.change": "any",
+ "task.update.assigned": "assigned",
+ "task.close": "pm"
+ },
+ "fields": [
+ {
+ "field": "title",
+ "label": "Title",
+ "writes": [
+ { "verb": "task.create", "calls": ["POST /projects/{p}/tasks"] },
+ { "verb": "task.scope.change", "calls": ["PATCH /tasks/{t}"] }
+ ]
+ },
+ {
+ "field": "description",
+ "label": "Description",
+ "writes": [
+ { "verb": "task.create", "calls": ["POST /projects/{p}/tasks"] },
+ { "verb": "task.scope.change", "calls": ["PATCH /tasks/{t}"] }
+ ]
+ },
+ {
+ "field": "due_date",
+ "label": "Due date",
+ "writes": [
+ { "verb": "task.create", "calls": ["POST /projects/{p}/tasks"] },
+ { "verb": "task.schedule", "calls": ["PATCH /tasks/{t}"] }
+ ]
+ },
+ {
+ "field": "priority",
+ "label": "Priority",
+ "writes": [
+ { "verb": "task.create", "calls": ["POST /projects/{p}/tasks"] },
+ { "verb": "task.priority.change", "calls": ["PATCH /tasks/{t}"] }
+ ]
+ },
+ {
+ "field": "percent_done",
+ "label": "Percent done",
+ "writes": [{ "verb": "task.update.assigned", "calls": ["PATCH /tasks/{t}"] }]
+ },
+ {
+ "field": "bucket",
+ "label": "State (bucket) other than done",
+ "writes": [{ "verb": "task.update.assigned", "calls": ["PUT /projects/{p}/views/{k}/buckets/{b}/tasks"] }]
+ },
+ {
+ "field": "done",
+ "label": "Done",
+ "note": "Cites a review verdict, which is recorded and not checked.",
+ "writes": [{ "verb": "task.close", "calls": ["PUT /projects/{p}/views/{k}/buckets/{b}/tasks"] }]
+ },
+ {
+ "field": "assignees",
+ "label": "Assignees",
+ "writes": [
+ { "verb": "task.assign", "calls": ["POST /tasks/{t}/assignees"] },
+ { "verb": "task.reassign", "calls": ["POST /tasks/{t}/assignees", "DELETE /tasks/{t}/assignees/{u}"] }
+ ]
+ },
+ {
+ "field": "labels",
+ "label": "Labels",
+ "writes": [
+ { "verb": "task.create", "calls": ["POST /tasks/{t}/labels"] },
+ { "verb": "task.schedule", "calls": ["POST /tasks/{t}/labels", "DELETE /tasks/{t}/labels/{l}"] }
+ ]
+ },
+ {
+ "field": "relations",
+ "label": "Relations",
+ "writes": [
+ { "verb": "task.create", "calls": ["POST /tasks/{t}/relations"] },
+ { "verb": "task.schedule", "calls": ["POST /tasks/{t}/relations", "DELETE /tasks/{t}/relations/{kind}/{other}"] }
+ ]
+ },
+ {
+ "field": "comments",
+ "label": "Comments",
+ "note": "Append only.",
+ "writes": [{ "verb": "task.update.assigned", "calls": ["POST /tasks/{t}/comments"] }]
+ }
+ ]
+}
diff --git a/packages/tasks/src/fields.mjs b/packages/tasks/src/fields.mjs
new file mode 100644
index 000000000..15d8e4f47
--- /dev/null
+++ b/packages/tasks/src/fields.mjs
@@ -0,0 +1,76 @@
+import { readFileSync } from 'node:fs';
+import { TASK_VERBS } from '../../bus/src/broker.mjs';
+// Row 61 (#1550): the slice 1 field table as data, in ../fields.json. The prose in
+// docs/plans/2026-10-04_slice-1.md stays the reviewed source; tests/fields.test.mjs checks both
+// against what the verbs do. Two layers, as lead decision 113 rules:
+// - `verbs`: the writer verbs.mjs checks before anything else. `pm` is the pm definition
+// (field-writer otherwise), `assigned` a role whose bot is an assignee (not-assigned
+// otherwise), `any` no writer check.
+// - Whether a write also needs a resolved decision isn't here. It comes from the role's
+// authority in roles/*.json, which the broker reads.
+// Each field lists the verbs that write it and the Vikunja calls each verb makes for it. This
+// function is the file's schema: it refuses any other shape.
+export const FIELDS_FILE = new URL('../fields.json', import.meta.url);
+export const WRITERS = Object.freeze(['pm', 'assigned', 'any']);
+const CALL = /^(POST|PUT|PATCH|DELETE) (\/(?:[a-z_]+|\{[a-z]+\}))+$/;
+const FIELD = /^[a-z][a-z_]{0,63}$/;
+export class FieldTableError extends Error {}
+const plain = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
+const str = (x, max) => typeof x === 'string' && x.trim() !== '' && x.length <= max;
+function exact(x, where, allowed, required = allowed) {
+ if (!plain(x)) throw new FieldTableError(`${where}: not an object`);
+ for (const k of Object.keys(x)) if (!allowed.includes(k)) throw new FieldTableError(`${where}: unknown key ${k}`);
+ for (const k of required) if (!Object.hasOwn(x, k)) throw new FieldTableError(`${where}: missing ${k}`);
+}
+function list(x, where) {
+ if (!Array.isArray(x) || !x.length) throw new FieldTableError(`${where}: not a nonempty list`);
+ return x;
+}
+export function checkFields(table, verbs = TASK_VERBS) {
+ exact(table, 'table', ['source', 'verbs', 'fields']);
+ if (!str(table.source, 256)) throw new FieldTableError('source: not a nonempty string');
+ exact(table.verbs, 'verbs', verbs);
+ for (const [v, w] of Object.entries(table.verbs))
+ if (!WRITERS.includes(w)) throw new FieldTableError(`verbs.${v}: writer must be ${WRITERS.join(', ')}`);
+ const seen = new Set(), used = new Set();
+ for (const [i, f] of list(table.fields, 'fields').entries()) {
+ const at = `fields[${i}]`;
+ exact(f, at, ['field', 'label', 'note', 'writes'], ['field', 'label', 'writes']);
+ if (typeof f.field !== 'string' || !FIELD.test(f.field)) throw new FieldTableError(`${at}.field: not a field name`);
+ if (seen.has(f.field)) throw new FieldTableError(`${at}.field: ${f.field} twice`);
+ seen.add(f.field);
+ if (!str(f.label, 80)) throw new FieldTableError(`${at}.label: not a nonempty string`);
+ if (f.note !== undefined && !str(f.note, 256)) throw new FieldTableError(`${at}.note: not a nonempty string`);
+ const mine = new Set();
+ for (const [j, w] of list(f.writes, `${at}.writes`).entries()) {
+ const wat = `${at}.writes[${j}]`;
+ exact(w, wat, ['verb', 'calls']);
+ if (!Object.hasOwn(table.verbs, w.verb)) throw new FieldTableError(`${wat}.verb: not a task verb`);
+ if (mine.has(w.verb)) throw new FieldTableError(`${wat}.verb: ${w.verb} twice`);
+ mine.add(w.verb);
+ used.add(w.verb);
+ const calls = list(w.calls, `${wat}.calls`);
+ for (const c of calls) if (typeof c !== 'string' || !CALL.test(c)) throw new FieldTableError(`${wat}.calls: not a Vikunja call`);
+ if (new Set(calls).size !== calls.length) throw new FieldTableError(`${wat}.calls: a call twice`);
+ }
+ }
+ for (const v of Object.keys(table.verbs)) if (!used.has(v)) throw new FieldTableError(`verbs.${v}: writes no field`);
+ return table;
+}
+function freeze(x) {
+ if (x && typeof x === 'object') {
+ for (const v of Object.values(x)) freeze(v);
+ Object.freeze(x);
+ }
+ return x;
+}
+// The committed table, checked and frozen. A file that fails the check refuses to load.
+export function loadFields(file = FIELDS_FILE) {
+ let table;
+ try {
+ table = JSON.parse(readFileSync(file, 'utf8'));
+ } catch (e) {
+ throw new FieldTableError(e instanceof SyntaxError ? 'fields.json: not JSON' : `fields.json: ${e.code ?? 'unreadable'}`);
+ }
+ return freeze(checkFields(table));
+}
diff --git a/packages/tasks/src/index.mjs b/packages/tasks/src/index.mjs
index ecd55e64b..8164461ea 100644
--- a/packages/tasks/src/index.mjs
+++ b/packages/tasks/src/index.mjs
@@ -4,3 +4,4 @@ export { tick, reconcile, WINDOW_MS } from './sync.mjs';
export { verbs } from './verbs.mjs';
export { client, apiRoot, outcome, refusal } from './vikunja.mjs';
export { ref, parseRef, taskFields, digest, tombstone } from './digest.mjs';
+export { loadFields, checkFields, FieldTableError, FIELDS_FILE, WRITERS } from './fields.mjs';
diff --git a/packages/tasks/tests/fields.test.mjs b/packages/tasks/tests/fields.test.mjs
new file mode 100644
index 000000000..597048bdc
--- /dev/null
+++ b/packages/tasks/tests/fields.test.mjs
@@ -0,0 +1,218 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, readFileSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { world, SCOPES } from './world.mjs';
+import { FakeVikunja } from '../src/fake.mjs';
+import { loadFields, checkFields, FieldTableError, FIELDS_FILE } from '../src/fields.mjs';
+import { TASK_VERBS } from '../../bus/src/broker.mjs';
+import { loadRole } from '../../business/src/index.mjs';
+// Row 61 (#1550): fields.json against what the verbs do. The writer layer is read from the table;
+// whether a write needs a decision, and who resolves it, from roles/*.json as the business loader
+// reads them (lead decision 113). A version 1 file, the researcher's, grants nothing.
+// Neither expectation is written out here, so a change to the table, a role file or the verbs
+// that leaves them disagreeing fails this file.
+const table = loadFields();
+const ROLES = fileURLToPath(new URL('../../../roles/', import.meta.url));
+const authority = (r) => loadRole(ROLES, r).authority;
+const raw = () => JSON.parse(readFileSync(FIELDS_FILE, 'utf8'));
+const tableError = (message) => (e) => e instanceof FieldTableError && (message === undefined || e.message === message);
+
+test('the committed table loads frozen; its verbs are the broker task verbs', () => {
+ assert.deepEqual(Object.keys(table.verbs).sort(), [...TASK_VERBS].sort());
+ assert.ok(Object.isFrozen(table) && Object.isFrozen(table.fields[0].writes[0].calls));
+ assert.deepEqual(checkFields(raw()), raw());
+});
+
+test('the schema refuses any other shape', () => {
+ const bad = {
+ 'an unknown top key': (t) => (t.writers = {}),
+ 'no source': (t) => delete t.source,
+ 'a blank source': (t) => (t.source = ' '),
+ 'a source that is not a string': (t) => (t.source = 1),
+ 'a source over 256 characters': (t) => (t.source = 'x'.repeat(257)),
+ 'a task verb missing': (t) => delete t.verbs['task.close'],
+ 'a task verb missing with the field it writes': (t) => {
+ delete t.verbs['task.close'];
+ t.fields = t.fields.filter((f) => f.field !== 'done');
+ },
+ 'a verb the broker lacks': (t) => (t.verbs['task.delete'] = 'pm'),
+ 'an unknown writer': (t) => (t.verbs['task.close'] = 'cto'),
+ 'no fields': (t) => (t.fields = []),
+ 'a field twice': (t) => t.fields.push(structuredClone(t.fields[0])),
+ 'a field name with a space': (t) => (t.fields[0].field = 'due date'),
+ 'an unknown field key': (t) => (t.fields[0].writer = 'pm'),
+ 'no label': (t) => delete t.fields[0].label,
+ 'a label over 80 characters': (t) => (t.fields[0].label = 'x'.repeat(81)),
+ 'a note over 256 characters': (t) => (t.fields[0].note = 'x'.repeat(257)),
+ 'a blank note': (t) => (t.fields[0].note = ' '),
+ 'no writes': (t) => (t.fields[0].writes = []),
+ 'a write to an unknown verb': (t) => (t.fields[0].writes[0].verb = 'task.delete'),
+ 'one verb twice in a field': (t) => t.fields[0].writes.push(structuredClone(t.fields[0].writes[0])),
+ 'an unknown write key': (t) => (t.fields[0].writes[0].when = 'at create'),
+ 'no calls': (t) => (t.fields[0].writes[0].calls = []),
+ 'a read': (t) => (t.fields[0].writes[0].calls = ['GET /tasks/{t}']),
+ 'a call with a query': (t) => (t.fields[0].writes[0].calls = ['PATCH /tasks/{t}?x=1']),
+ 'one call twice': (t) => t.fields[0].writes[0].calls.push(t.fields[0].writes[0].calls[0]),
+ 'a verb that writes no field': (t) => (t.fields = t.fields.filter((f) => f.field !== 'done')),
+ };
+ for (const [what, edit] of Object.entries(bad)) {
+ const t = raw();
+ edit(t);
+ assert.throws(() => checkFields(t), tableError(), what);
+ }
+ const dir = mkdtempSync(join(tmpdir(), 'fields-'));
+ try {
+ writeFileSync(join(dir, 'cut.json'), '{"source":');
+ assert.throws(() => loadFields(join(dir, 'cut.json')), tableError('fields.json: not JSON'));
+ assert.throws(() => loadFields(join(dir, 'absent.json')), tableError('fields.json: ENOENT'));
+ const t = raw();
+ t.verbs['task.close'] = 'any';
+ writeFileSync(join(dir, 'wide.json'), JSON.stringify(t));
+ assert.doesNotThrow(() => loadFields(join(dir, 'wide.json')), 'a wider writer is still a valid shape; the next test refuses it');
+ t.verbs['task.close'] = 'reviewer';
+ writeFileSync(join(dir, 'odd.json'), JSON.stringify(t));
+ assert.throws(() => loadFields(join(dir, 'odd.json')), tableError('verbs.task.close: writer must be pm, assigned, any'));
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+// A Vikunja write, under /api/v2, as the table names it, and the fields it writes. A bucket move
+// writes `done` when it moves into the done bucket and the bucket otherwise.
+const CALLS = [
+ [/^\/projects\/\d+\/tasks$/, '/projects/{p}/tasks', (r) => Object.keys(r.body)],
+ [/^\/tasks\/\d+$/, '/tasks/{t}', (r) => Object.keys(r.body)],
+ [/^\/projects\/\d+\/views\/\d+\/buckets\/(\d+)\/tasks$/, '/projects/{p}/views/{k}/buckets/{b}/tasks', (r, m, done) => [Number(m[1]) === done ? 'done' : 'bucket']],
+ [/^\/tasks\/\d+\/assignees$/, '/tasks/{t}/assignees', () => ['assignees']],
+ [/^\/tasks\/\d+\/assignees\/\d+$/, '/tasks/{t}/assignees/{u}', () => ['assignees']],
+ [/^\/tasks\/\d+\/labels$/, '/tasks/{t}/labels', () => ['labels']],
+ [/^\/tasks\/\d+\/labels\/\d+$/, '/tasks/{t}/labels/{l}', () => ['labels']],
+ [/^\/tasks\/\d+\/relations$/, '/tasks/{t}/relations', () => ['relations']],
+ [/^\/tasks\/\d+\/relations\/[a-z]+\/\d+$/, '/tasks/{t}/relations/{kind}/{other}', () => ['relations']],
+ [/^\/tasks\/\d+\/comments$/, '/tasks/{t}/comments', () => ['comments']],
+];
+function writes(r, done) {
+ for (const [re, path, fields] of CALLS) {
+ const m = re.exec(r.path);
+ if (m) return fields(r, m, done).map((f) => `${f} ${r.method} ${path}`);
+ }
+ assert.fail(`a write the table has no call for: ${r.method} ${r.path}`);
+}
+const expected = (verb) =>
+ table.fields.flatMap((f) => f.writes.filter((w) => w.verb === verb).flatMap((w) => w.calls.map((c) => `${f.field} ${c}`))).sort();
+
+test('each verb refuses, asks for a decision and writes as the table and the role files say', async (t) => {
+ const fake = new FakeVikunja();
+ const sent = [];
+ const fetch = async (url, init = {}) => {
+ if ((init.method ?? 'GET') !== 'GET')
+ sent.push({ method: init.method, path: new URL(url).pathname.slice('/api/v2'.length), body: init.body === undefined ? undefined : JSON.parse(init.body) });
+ return fake.fetch(url, init);
+ };
+ const dir = mkdtempSync(join(tmpdir(), 'fields-'));
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
+ // The role files' authority, and a researcher seat.
+ const w = await world(t, {
+ fake,
+ fetch,
+ business: (b, { owner, project }) => {
+ for (const r of ['pm', 'coder', 'reviewer', 'cto']) b.roles[r].authority = authority(r);
+ const bot = fake.user('bot-demo-researcher', { owner });
+ fake.share(project, bot, 1);
+ const file = join(dir, 'researcher.token');
+ writeFileSync(file, fake.token(bot, SCOPES.worker) + '\n');
+ chmodSync(file, 0o600);
+ b.roles.researcher = {
+ definition: 'researcher',
+ authority: authority('researcher'),
+ tracker: { bot: 'bot-demo-researcher', botId: bot },
+ credentials: { vikunja: { file, expires: '2099-01-01' } },
+ };
+ },
+ });
+ assert.deepEqual(w.businesses.demo.roles.researcher.authority, { withinRole: [], crossRole: [] });
+ await w.adapter.start('demo');
+ assert.equal(w.adapter.status()[0].state, 'ready');
+ const ROLE_NAMES = ['pm', 'coder', 'reviewer', 'researcher'];
+ const caps = Object.fromEntries(ROLE_NAMES.map((r) => [r, w.agent(r)]));
+ const cto = w.agent('cto');
+ const botId = (r) => w.businesses.demo.roles[r].tracker.botId;
+ const done = w.installed.buckets.done;
+ const seen = Object.fromEntries(TASK_VERBS.map((v) => [v, new Set()]));
+ const code = (p) => p.then(() => 'ok', (e) => e.code ?? e.message);
+ // One call, checked layer by layer. Returns the verb's answer, or null when it refused.
+ async function attempt(role, verb, args) {
+ const writer = table.verbs[verb];
+ const before = sent.length;
+ const refused = async (want) => {
+ assert.equal(await code(w.call(caps[role], verb, args)), want, `${role} ${verb}`);
+ assert.equal(sent.length, before, `${role} ${verb} refused but wrote`);
+ return null;
+ };
+ if (writer === 'pm' && w.businesses.demo.roles[role].definition !== 'pm') return refused('field-writer');
+ if (writer === 'assigned' && !fake.task(Number(args.task_ref.split('/')[1])).assignees.includes(botId(role))) return refused('not-assigned');
+ const a = authority(role);
+ let decision;
+ if (!a.withinRole.includes(verb)) {
+ await refused('decision-required');
+ const d = w.broker.request(caps[role], {
+ verb: 'decision.raise',
+ args: {
+ action: verb,
+ domain: 'technical',
+ target: args.task_ref ?? null,
+ ...(args.task_ref ? { task_ref: args.task_ref } : {}),
+ question: `${role} ${verb}?`,
+ options: [
+ { key: 'yes', text: 'yes' },
+ { key: 'no', text: 'no' },
+ ],
+ recommendation: 'yes',
+ blocking: false,
+ },
+ });
+ // Cross-role goes to the technical arbiter; anything the role file doesn't list, to the human.
+ const route = a.crossRole.includes(verb) ? w.businesses.demo.arbiters.technical : 'human';
+ assert.equal(w.store.get('SELECT route_to FROM decisions WHERE id=?', d.id).route_to, route, `${role} ${verb} route`);
+ w.broker.request(route === 'human' ? w.human : cto, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
+ decision = d.id;
+ }
+ const r = await w.call(caps[role], verb, decision ? { ...args, decision } : args);
+ assert.ok(sent.length > before, `${role} ${verb} wrote nothing`);
+ for (const x of sent.slice(before)) for (const s of writes(x, done)) seen[verb].add(s);
+ return r;
+ }
+ const others = ROLE_NAMES.filter((r) => r !== 'pm');
+ const create = { title: 'b', description: 'what', due_date: '2026-11-01T00:00:00.000Z', priority: 2, labels: [w.label] };
+ for (const r of others) await attempt(r, 'task.create', { ...create, request: w.instruction(), requirement: 'REQ-S-1' });
+ const a = await attempt('pm', 'task.create', { title: 'a', request: w.instruction(), requirement: 'REQ-S-1' });
+ const relation = { kind: 'related', task_ref: a.task_ref };
+ const { task_ref } = await attempt('pm', 'task.create', { ...create, relations: [relation], request: w.instruction(), requirement: 'REQ-S-1' });
+ for (const r of others) {
+ await attempt(r, 'task.assign', { task_ref, role: 'coder' });
+ await attempt(r, 'task.reassign', { task_ref, role: 'coder' });
+ await attempt(r, 'task.schedule', { task_ref, due_date: null });
+ await attempt(r, 'task.priority.change', { task_ref, priority: 4 });
+ await attempt(r, 'task.close', { task_ref, verdict: 'row 61 r1' });
+ }
+ for (const r of ROLE_NAMES) await attempt(r, 'task.update.assigned', { task_ref, percent_done: 0.1 });
+ await attempt('pm', 'task.assign', { task_ref, role: 'coder' });
+ await attempt('pm', 'task.reassign', { task_ref, role: 'reviewer' });
+ await attempt('pm', 'task.schedule', { task_ref, due_date: null, labels: { remove: [w.label] }, relations: { remove: [relation] } });
+ await attempt('pm', 'task.schedule', { task_ref, labels: { add: [w.label] }, relations: { add: [relation] } });
+ await attempt('pm', 'task.priority.change', { task_ref, priority: 4 });
+ for (const [i, r] of ROLE_NAMES.entries()) await attempt(r, 'task.scope.change', { task_ref, title: `b${i}`, description: `what ${i}` });
+ for (const r of ['pm', 'coder', 'researcher']) await attempt(r, 'task.update.assigned', { task_ref, state: 'in-progress' });
+ await attempt('reviewer', 'task.update.assigned', { task_ref, percent_done: 0.5, state: 'in-progress', comment: 'started' });
+ // People add the coder and the researcher in Vikunja; each may then write as the assigned role.
+ const id = Number(task_ref.split('/')[1]);
+ for (const r of ['coder', 'researcher']) assert.equal((await w.ui('POST', `/tasks/${id}/assignees`, { user_id: botId(r) })).status, 201);
+ await attempt('coder', 'task.update.assigned', { task_ref, percent_done: 0.7 });
+ await attempt('researcher', 'task.update.assigned', { task_ref, state: 'in-review' });
+ await attempt('pm', 'task.close', { task_ref, verdict: 'row 61 r1' });
+ assert.equal(fake.task(id).done, true);
+ for (const v of TASK_VERBS) assert.deepEqual([...seen[v]].sort(), expected(v), v);
+});
diff --git a/packages/webui/README.md b/packages/webui/README.md
index 4a7cf535b..48aeb90b3 100644
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -267,6 +267,11 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
since the list has no trail. The task page reads the trail and tells a task
the stack never created from one changed in Vikunja after the stack wrote it.
A stale poll read is shown in the snapshots and never as current.
+- The task page's "Who may write" section lists each field with the verbs
+ that write it and the writer each verb checks: pm, the assigned role or any
+ role (row 61, lead decision 113). Whether a write also needs a resolved
+ decision is the role's authority, on the Business page. If the table read
+ fails, the section says why and the rest of the page still shows.
- The Tasks table has Requirement and Priority columns, from the `tasks` row's
`requirement` and `priority` (row 58, #1547). Either one shows "none" when
the row has none. A tombstone's page shows the priority of the last snapshot
@@ -434,6 +439,13 @@ for a row that isn't there, 400 for a bad address and 502 otherwise. Settings
answers 200 even without a system config, so appearance always works; its
notifier part then says `not-configured`.
+GET `/api/task-fields` is row 61's read (#1550): the task field table,
+`packages/tasks/fields.json`, loaded and checked by `loadFields()` on each
+request. It is repository data, so it answers without `--business` or a
+system config. The answer is the table itself, with no `at`. A table that
+fails its check is refused whole with 503 `read-failed` and the loader's
+message, which names the file and never a path. Any other method is 405.
+
Console's shared CSS, Console CSS, brand.js and local Manrope fonts were copied
unchanged from `agents/dewey/work/wui/`. Font license and source URLs accompany
the files under `src/public/assets/fonts/`. `live.css` contains the live-page
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
index ffcc7cf56..a5e447a93 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -349,6 +349,20 @@
const missingHtml = missing.length ? `No longer readable ${missing.length}
${missing.map(t => `
Edit tasks in Vikunja; Console only reads them. ${gap('Bucket names, bot names and the Vikunja address')}
${groups || empty('No open tasks.', 'Open tasks are read from Vikunja; done and unreadable ones are listed below.')}${doneHtml}${missingHtml}`; } + // Row 61 (#1550, lead decision 113): who may write each field, from the field table + // (packages/tasks/fields.json, GET /api/task-fields). The table gives the writer each verb + // checks; whether a write also needs a decision is each role's authority, on Business. A + // failed read leaves the rest of the task page as it is and says why here. + const WRITER = { pm: 'pm', assigned: 'the assigned role', any: 'any role' }; + async function writers(get) { + let doc; + try { doc = await get.api('task-fields'); } catch (err) { return `${why(err)}
`; } + const fields = Array.isArray(doc.fields) ? doc.fields : [], verbs = doc.verbs && typeof doc.verbs === 'object' ? doc.verbs : {}; + if (!fields.length) return `${why(Object.assign(new ReadError('invalid-response', ''), { module: true }))}
`; + const by = f => (Array.isArray(f.writes) ? f.writes : []).map(w => `${txt(WRITER[verbs[w.verb]] ?? 'unknown')}${txt(w.verb)}`).join('| Field | Writer and verb |
|---|
${txt(t.ref)} ${gap('The Vikunja address')}${txt(t.description)}
` : ''}The writer each verb checks, from the slice 1 field table. Whether a write also needs a resolved decision comes from the role's authority, on Business. Edits made in Vikunja aren't checked against this table.
${await writers(get)}