12 KiB
Row 90 (#1584): The approved requirement ids as data, round 1
Owner: Dewey. Reviewer: Filbert, then Sage's gate. Brief:
docs/plans/2026-10-12_pm-task-flow.md, section "The approved requirement
ids as data" (blob e8a1150e). Also PM fault 10 (#228 c91), covered by
the stop line.
Base 23338c7b (queue rev 798). build.patch is
git diff --cached --binary 23338c7b -- the twenty paths in
candidate-manifest.sha256. Apply it with git apply --index to a detached
worktree of the base; the manifest then checks 20/20 OK. No commit from the
base to refactor 1e4cfa96 touches the twenty paths (checked at
packaging).
| File | What changed |
|---|---|
packages/tasks/requirements.json |
new: the PRD's path and version, and its 24 ids, each with a one-line title |
packages/tasks/src/requirements.mjs |
new: checkRequirements (the file's schema), loadRequirements (checked, frozen) |
packages/tasks/src/verbs.mjs |
task.create refuses unknown-requirement for an id the list doesn't hold; the format check stays |
packages/bus/src/broker.mjs |
the constructor loads the list; a requirements read for role sessions |
packages/harness/src/tools.mjs |
list_requirements for task.create holders with a tracker; task_create's request and requirement, raise_decision's blocking |
packages/harness/src/runner.mjs |
the turn header names a message's request id (outside the file list; see Decisions) |
packages/harness/src/bundle.mjs |
one session-text line: only the human stops a run |
roles/pm.md |
two Duties bullets (the id from list_requirements, the request id; no id fits: a non-blocking prd.approve) and two Authority paragraphs (blocking holds a task; stopping a run) |
packages/tasks/README.md |
requirements.json, the task.create detail, unknown-requirement in Refusals |
packages/bus/README.md |
the requirements row; decision.raise's task-required rule |
packages/tasks/tests/requirements.test.mjs |
new: the list against the PRD, frozen, each schema refusal, the load errors |
packages/tasks/tests/verbs.test.mjs |
two tests: only a listed id creates; a received message's request creates and its id refuses |
packages/tasks/tests/{adapter,fields,shapes,sync}.test.mjs |
REQ-S-1, not in the PRD, becomes REQ-TASK-1 |
packages/bus/tests/requirements.test.mjs |
new: who can read requirements; the PM's non-blocking prd.approve and role.revoke with no task reach the human's Inbox; blocking still refuses task-required |
packages/harness/tests/tools.test.mjs |
the PM's tool list; list_requirements's gating, verb and text; the three descriptions; the row 89 byte count (below) |
packages/harness/tests/runner.test.mjs |
the turn header with and without a request |
packages/harness/tests/bundle.test.mjs |
the stop line |
What the PM sees now
- Which ids exist.
list_requirementsreturns{prd, version, requirements:[{id,title}]}, 24 ids for PRD 1.0. Its text saystask_createtakes only these, and that a request no id fits gets aprd.approvedecision instead of a task.task_createrefuses any other id withunknown-requirement, soREQ-QA-1(P19 #229, #230) now refuses. - The request id. The turn header of a human message reads
Message <id> from jason, class REQUEST, request <event id>:.task_create'srequestsays to take thatrequest <id>, not the message's own id. Before this change the header leftrequestout, so the PM had only the message id, which refusesrequest-not-found. - The Inbox path without a task.
raise_decision'sblockingsays a blocking decision holds a task and needstask_ref, and a request with no task is false.pm.mdsays the same, and that a gated decision with no task still reaches the human's Inbox. The bus test raises bothprd.approveandrole.revokefrom the PM that way and reads them in the human's (and reader's) inbox. - Stopping a run. The session text and
pm.mdsay only the human stops a run, withmosaic stop; a session that thinks a run should stop raisesrole.revoke,blockingfalse, the role instance as target, naming the run and saying why. The bundle line goes to every role session, not only the PM, since every session holdsraise_decision.
Decisions
runner.mjsis outside the brief's file list. The brief says each messagemessage.receivereturns carriesrequest, and the tool should say to use it. The broker does attach it (broker.mjsmessage.receive), butturnRequestbuilt the session's turn text fromid,from_role,class,in_reply_toanddecisiononly. Without one line there, the newrequestdescription would point the PM at a field it never sees. I told Sage before packaging; Sage's ruling: keep it, name it here and in the review request. Row 93 bases itsrunner.mjson this one if row 90 lands first (Sage's note on row 93, rev 800).- The broker loads the list in its constructor, so a bad
requirements.jsonrefuses at bus start, not at the first read. The tasks adapter loads it again inverbs(ctx), at adapter start. Both load the same committed file. requirementsis an agent read (#agent), the same guard astask.create: a human session refusesagent-required, a readerread-only, a role session that doesn't hold its rolenot-holder. The bus doesn't also check that the role holdstask.create. The list is the approved PRD's ids, nothing secret, and the harness gives the tool only to task.create holders. The reader allowlist inrequest()is unchanged.requirements.mjsimports nothing from the bus, so the broker can import it statically without a cycle (tasks already imports the broker). Its errors areRequirementsError, asfields.mjs's areFieldTableError.- Tests use
REQ-TASK-1, a real id, in place ofREQ-S-1, with no seam to inject a test list. Sage agreed. - The titles are mine, one line of 120 characters or fewer, written from each PRD bullet. The test checks ids, order and version against the PRD, not the titles. A reviewer may want different wording; changing a title changes no behaviour.
Limits
- A bad file at bus start shows as
startup-refusedover IPC.process.mjsmaps any error that isn't aBusErrorto that code, so theRequirementsErrormessage (requirements[3].id: not a requirement id) doesn't reach the host. The start still refuses. Making it aBusErrorwould change the boot protocol, outside this row. packages/harness/README.mdisn't in the file list, so it doesn't yet namelist_requirementsor therequestin the turn header. A one-line follow-up if the reviewer wants it.packages/tasks/src/index.mjsexportsloadFieldsbut notloadRequirements. Nothing imports the list through the index; adding the export is outside the file list.- Nothing here reads the PRD at run time. A later PRD version needs a new
requirements.jsonin a reviewed commit, and the test fails until it matches. - #229 and #230 keep their
REQ-QA-1; correcting them is the PM's and Jason's (out of scope in the brief). - Live at the next bus restart and PM launch (decision 109): Mos is notified before landing.
Correction to row 89
Row 89's packages/harness/tests/tools.test.mjs:115 comment and its
build.md (line 183) say 19999 emoji in a JSON string are "80002 bytes".
They are 79998: 19999 × 4 + 2 quotes (checked with Buffer.byteLength).
This row fixes the test comment. Row 89's packet stays as written; this is
the correction entry.
Gate (my run on the candidate)
gate.sh on a fresh detached worktree of 23338c7b with build.patch
applied by git apply --index; manifest 20/20 OK; suites sequential, each
node suite from its package directory. 2026-10-12T22:43:29Z to 22:46:50Z.
Summary in gate-summary.txt.
| Suite | Result |
|---|---|
| tasks node | 79 pass, 0 fail |
| bus node | 111 pass, 0 fail |
| harness node | 66 pass, 0 fail |
| webui node | 86 pass, 0 fail |
| cli node | 115 pass, 0 fail |
| business node | 60 pass, 0 fail |
| seat node | 27 pass, 0 fail |
| control-board node | 124 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 | 27 passed |
| test-release | 14 passed |
| test-task | 26 passed; run with DOCKER_HOST=unix:///nonexistent, so it skips the adapter seam, workspace/capability, live task and live recall cases (docker unavailable) |
Every rc is 0. No commit from the base to refactor 1e4cfa96 touches the
twenty paths.
Mutants
mutants.py swaps one exact string in a scratch copy of the candidate (a
plain directory, its 20 files byte-identical to the candidate when run 2
ended), runs the tasks, bus or harness node tests in their own process
group (killed after 300 s, which counts as a kill), and restores the file.
44 mutants.
- Run 1 (
mutants-run1.txt): 43 killed, 1 survived,empty list allowed. The cause was the test, not the code: the schema table's loop used an edit's return value when it was an array, so(l) => (l.requirements = [])handedcheckRequirementsthe[]itself and tested "not an object" instead of the empty list. The fix: a separate not-an-object loop for[],nulland a string, and the table now edits in place and ignores the return. I also changed "a numeric version" from1to1.5, so it can't pass the version regex if the type check goes. - Run 2 (
mutants-run2.txt): after the fix,list version 1.1and the 20 mutants onrequirements.mjs, which the changed schema and load tests cover. 21 killed. The other 23 were killed in run 1 by tests the fix didn't touch.
| Mutant | Site | What it undoes |
|---|---|---|
| list order swapped, two ids swapped, drops the last id, adds REQ-QA-1, version 1.1 | requirements.json |
the list equals the PRD's ids, order and version |
| top keys open, entry keys open | requirements.mjs |
unknown keys refuse |
| prd pattern dropped, prd takes a parent step | requirements.mjs |
prd is a docs/prd/*.md path with no .. |
| version any type, version 0 major, version minor optional | requirements.mjs |
version is a N.M string, N from 1 |
| empty list allowed | requirements.mjs |
at least one id |
| id numbered 0, id suffix allowed, id twice allowed | requirements.mjs |
the id format, anchored; no id twice |
| title 121, title 119, title newline allowed, blank title allowed | requirements.mjs |
a title is 1 to 120 characters, one line |
| not frozen, frozen shallow, unchecked load | requirements.mjs |
the loaded list is checked and deep-frozen |
| not JSON / ENOENT reported as unreadable | requirements.mjs |
the load error names the cause |
| unknown-requirement dropped, as invalid-request | verbs.mjs |
an unlisted id refuses unknown-requirement |
| only the first id, all but the last id | verbs.mjs |
every listed id creates |
| requirements agent guard dropped, keys dropped, returns the ids only, constructor loads nothing | broker.mjs |
the read's guard, its no-argument rule, its shape, the load at start |
| list_requirements ungated, gated on task.assign, without tracker, wrong verb, no prd.approve | tools.mjs |
who gets the tool, its verb, its text |
| request, requirement, blocking description old | tools.mjs |
the three new descriptions |
| turn request always, turn request dropped | runner.mjs |
the header names request only when there is one |
| stop line dropped | bundle.mjs |
the session text's stop line |