tasks, bus, harness, seat: the approved requirement ids as data (#1584)

Row 90, round 2. Dewey built it, Filbert approved it (comment 28068) on
manifest 95611274 (21 paths, base b8539480). The approved PRD's
requirement ids live in packages/tasks/requirements.json. task_create
refuses an id outside it, and the PM session gets a list_requirements
tool. Round 2 adds requirements.mjs to the sandbox's RUNNER_FILES
(decision 127). Sage gate on c047d028 plus the patch, 23:50:16Z to
23:54:26Z: tasks 89, bus 111, harness 66, webui 101, cli 123, seat 33,
business 60, control-board 124, queue 150, conversation 182, discord 178,
ledger 78, runs 43, mosaic 69, all fail 0. Every scripts/test-*.sh
exited 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 18:55:49 -05:00
co-authored by Claude Opus 5.5
parent e4ce090716
commit e918a915a8
21 changed files with 382 additions and 26 deletions
+53
View File
@@ -0,0 +1,53 @@
import { readFileSync } from 'node:fs';
// Row 90 (#1584): the approved PRD's requirement ids as data, in ../requirements.json. The PRD
// (docs/prd/mosaic-stack.md) stays the reviewed source; tests/requirements.test.mjs fails unless
// the list holds its **REQ-...** ids in its order. A later PRD version is a new list in a reviewed
// commit. task.create refuses an id the list doesn't hold, and the broker's `requirements` verb
// returns the list to the sessions that create tasks. This module imports nothing from the bus,
// so the broker can load it. This function is the file's schema: it refuses any other shape.
export const REQUIREMENTS_FILE = new URL('../requirements.json', import.meta.url);
export const REQUIREMENT_ID = /^REQ-[A-Z]+-[1-9][0-9]*$/;
export class RequirementsError 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) {
if (!plain(x)) throw new RequirementsError(`${where}: not an object`);
for (const k of Object.keys(x)) if (!allowed.includes(k)) throw new RequirementsError(`${where}: unknown key ${k}`);
for (const k of allowed) if (!Object.hasOwn(x, k)) throw new RequirementsError(`${where}: missing ${k}`);
}
export function checkRequirements(list) {
exact(list, 'list', ['prd', 'version', 'requirements']);
if (!str(list.prd, 256) || !/^docs\/prd\/[a-z0-9-]+\.md$/.test(list.prd))
throw new RequirementsError('prd: not a docs/prd/*.md path');
if (typeof list.version !== 'string' || !/^[1-9][0-9]*\.[0-9]+$/.test(list.version))
throw new RequirementsError('version: not a version such as 1.0');
if (!Array.isArray(list.requirements) || !list.requirements.length)
throw new RequirementsError('requirements: not a nonempty list');
const seen = new Set();
for (const [i, r] of list.requirements.entries()) {
const at = `requirements[${i}]`;
exact(r, at, ['id', 'title']);
if (typeof r.id !== 'string' || !REQUIREMENT_ID.test(r.id)) throw new RequirementsError(`${at}.id: not a requirement id`);
if (seen.has(r.id)) throw new RequirementsError(`${at}.id: ${r.id} twice`);
seen.add(r.id);
if (!str(r.title, 120) || r.title.includes('\n')) throw new RequirementsError(`${at}.title: not one line of 120 characters or fewer`);
}
return list;
}
function freeze(x) {
if (x && typeof x === 'object') {
for (const v of Object.values(x)) freeze(v);
Object.freeze(x);
}
return x;
}
// The committed list, checked and frozen. A file that fails the check refuses to load.
export function loadRequirements(file = REQUIREMENTS_FILE) {
let list;
try {
list = JSON.parse(readFileSync(file, 'utf8'));
} catch (e) {
throw new RequirementsError(e instanceof SyntaxError ? 'requirements.json: not JSON' : `requirements.json: ${e.code ?? 'unreadable'}`);
}
return freeze(checkRequirements(list));
}
+4
View File
@@ -2,6 +2,7 @@ import { BusError } from '../../bus/src/broker.mjs';
import { outcome, refusal } from './vikunja.mjs';
import { ref, parseRef, checkTask, taskFields, digest } from './digest.mjs';
import { board, normal } from './sync.mjs';
import { loadRequirements } from './requirements.mjs';
// The eight task verbs (addendum B section 4). Each one: field-table writer check, compare-read with
// the sync token, the caller's expected digest, broker authority, the writes with the acting role's
// own token, a final read, then one self snapshot and its event. A write that answers 5xx or not at
@@ -64,6 +65,8 @@ const sorted = (a) => [...a].sort((x, y) => x - y);
const decision = (x) => (x === undefined ? null : text(x, 128, 1));
export function verbs(ctx) {
const fail = (code) => refuse(code);
// Row 90 (#1584): the approved PRD's ids. A bad requirements.json refuses here, at adapter start.
const requirements = new Set(loadRequirements().requirements.map((r) => r.id));
const label = (id) => {
// Second guard after the bot-owner rule (probes.md, L): only label ids in the business file.
if (!ctx.labels.has(id)) fail('label-not-allowed');
@@ -215,6 +218,7 @@ export function verbs(ctx) {
plan.request = text(args.request, 64, 1);
plan.requirement = text(args.requirement, 64);
if (!/^REQ-[A-Z]+-[1-9][0-9]*$/.test(plan.requirement)) fail('invalid-request');
if (!requirements.has(plan.requirement)) fail('unknown-requirement');
return plan;
}
case 'task.assign':