diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 84c08f78a..14c0948bb 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6013,3 +6013,42 @@ Owner directed static-only fallback after actual nativepriority drift: no new pr 2026-11-04T03:48:47Z | GPT-6.1 Sol implementation sub-agent, t3/pm-intake-tools | #797 / Gitea #1839 lifecycle Task 6a before/after | Assigned base 6dd4f92b0; harness had no intake tools and staff.request required a task. Added intake_open/request.open and intake_propose/request.propose only for PM holding message.send, plus intake_status/request.get and intake_projects/request.projects in PM and CTO pre-task manifests, independent of tracker/checkouts. Existing broker authority is unchanged: its request reads still refuse CTO (request-access-refused); tested and retained rather than widening authority. intake_question omitted because no backing request.question verb exists. staff.request/request_staff takes exactly one of request or task_ref with roles/note/decision unchanged, PM-only message.send and chief routing preserved; original request provenance/reachability uses the existing host check without a fabricated task or opened journal. Exported the existing runner toolServer for acceptance tests against production dispatch, including MCP; no new imports or sandbox/process mounts needed (import closure 1/1). PM duty prose and the generated PM staffing prompt describe pre-task intake and staffing. New tests cover actual PM/CTO/coder/reviewer/assistant bundles for pi and claude-code, assistant -> chief -> PM relays, exact user-mode decision with null task/requirement, duplicate open, wrong roles, invalid/unreachable input, exclusive staff references and assistant coordination restrictions. RED on assigned parent: 6 pass/12 fail (dispatch not exported); after exporting only the existing dispatch seam 7 pass/11 fail for missing tools/staff intake. First GREEN attempt 17/18 exposed a test fixture treating message.receive's array as an object, corrected without weakening assertions. Existing required tests first 53/55 and full harness first 222/223 failed only outdated manifest/schema/prompt expectations introduced by this slice; original parent reruns required 55/55 and bundle 22/22 passed. A further generated staffing prompt RED 21/22 proved the stale task-only instruction before updating it. Final gates: request-tools 18/18, required command 55/55, full harness 223/223, bus 1477/1477, business 305/305, sandbox import closure 1/1, all zero failures/skips/cancellations. Full logs, baseline extraction and red evidence: /mnt/storage/scratch/tmp/JHTDYm/; every run used that TMPDIR with pipefail. Local commit only; no push, no role JSON/contract/migration/schema/config/credential changes, no runtime/bootstrap execution, no continuation/PRD/advance/recovery work. Controller owns review, integration and queue/tracker coordination; linked-worktree queue command refused as designed. 2026-11-04T04:06:34Z | GPT-6.1 Sol implementation sub-agent, t3/lifecycle-task-5a-create-recovery | #797 / Gitea #1839 lifecycle Task 5a before/after | Before: 6dd4f92b0 rejects operation keys and cannot recover a lost tracker create response. After: optional op reserves an immutable action.allowed tracker.create.prepared event, complete POST body/digest, target board and original PM/bot identity before any create-path network I/O; optional project_key and requirement_snapshot are opaque evidence and visible marker fields. Reusing an op cannot POST again, even after a complete negative scan or a restart before POST. Sync-only id-keyed full scans include closed tasks and require one marker, unchanged create fields, original board and creator; edited/missing/duplicate markers and partial/failed scans retain a deduplicated action.refused tracker.create.wait reconciliation receipt. Host-only recordRecoveredCreate validates the prepared receipt, body, creator and board bucket, atomically records the missing self snapshot/task.created once with original attribution, and has no socket route. Adapter exposes reconcileCreates and inspectProject, startup recovers; live PM retries resume labels/relations through read-before-write and current authority checks, assignment uses the existing separate verb. Legacy calls without new fields preserve their existing behavior; lifecycle callers must reuse a stable op for safe retries. Scope-only calls receive a generated op recorded in evidence. No migration, schema-pin edits, role/contract changes or runtime.mjs edits; packages/tasks/requirements.json and its consistency test unchanged. RED: new recovery suite 0/16 on parent; final regressions 0/21 on parent, expected invalid-request for op. Authority and forged-bucket checks also observed RED before their fixes. Corrections: event kind/subject guards required existing action event kinds instead of inventing task/ tracker kinds; restart fixtures needed historical launch cleanup before a successor claim; initial npm workspace invocation refused before tests and was replaced with per-package npm test. First full bus 1473/1477 exposed the added broker import outside the sandbox source closure, including three managed-launch failures; moved pure identity helpers into already-mounted broker.mjs, with parent project-runtime 3/3 and model-hire-ownership 18/18, then focused 42/42. Second bus 1476/1477 encountered unchanged row 280 sshd fixture cleanup ENOTEMPTY and hung; terminated only that run's owned host-run test and sshd, then waited for completion. Parent host-run was 25/26 with T27 failure (row 280 passed); local isolated host-run 26/26. Final GREEN: recovery 21/21, required verbs/adapter/tree/requirements 61/61, full tasks npm test 121/121, full bus npm test 1477/1477, zero failures/skips, diff check clean. Full logs and parent archive: /mnt/storage/scratch/tmp/CkjqAm/ (gate-recovery.log, gate-required.log, gate-tasks.log, gate-bus.log plus diagnostic history). Queue command refused this linked worktree, so no queue mutation; lead owns integration/queue. Task 5b catalogue validation, requirementsFor, built-in binding, task scope filtering and push product binding remain deferred as directed, pending Task 4. One local commit only; no push or live tracker/network/instance changes. 2026-11-04T04:09:35Z | Codex implementation sub-agent, t3/lifecycle-prd-approval-activation | #797 / Gitea #1839 lifecycle Task 4 core | Before: parent 6dd4f92b0 has intake/bootstrap and schema 3p, with no exact product PRD activation authority. After: bounded committed candidate reads (64 requirements, 32 KiB PRD, 16 KiB requirement text, 256 KiB aggregate), strict records, server-derived blob IDs, selected-path HEAD equality and current owner spec; PM-only record.write submission raises the existing nonblocking prd.approve action. Autonomous routing requires a distinct authorized technical arbiter instance and definition; unusable/self routing records setup waits. Guided approval uses the same action without a routing citation, while retaining its spec/mode binding. Host-only catalog activation rereads candidate/spec/mode/current role authority and the exact approving resolution, independent of run liveness; snapshot, activation event, continuation and receipt share one SQLite transaction, with deterministic idempotent replay. Broker.requirementsFor keeps the agreed authenticated consumer signature; no tasks are manufactured. Mode/spec changes invalidate admissions and revoke snapshots; owner-only prd.revoke.console is revision/op checked and replayable, and request.draft.console reads only the bound committed candidate. Runtime recovers from journal/records alone and drains queued lifecycle work on close. New append-only migration 3p to 3q widens request event kinds, preserving rows, sequences and all guards; current version/fixture pins updated, table/guard counts stay 30/114. RED on 6dd4f92b0 with the final new tests: 0/27 pass, 27 expected missing-feature failures (baseline-new-tests-red.log). Corrections: the first fixture tried to initialize an already bootstrapped records repo, fixed before feature RED; journal composition initially attempted nested transactions, now composed inside one host transaction; new automatic closure continuation needed shutdown draining; one revoke assertion needed awaited rejection. First full bus run 1496/1498: the old two-verb whitelist pin was updated to the authorized lifecycle verbs, and unchanged #656 FIFO child measured niceness 16 rather than 19. Both files pass 38/38 on exact parent 6dd4f92b0 and on this branch; the FIFO failure did not reproduce in isolation. Final GREEN: new suites 27/27, requested kept-green trio 21/21, bus 1498/1498, project 50/50, business 305/305, all zero skips; import-closure checks 3/3, git diff --check clean. Full logs under /mnt/storage/scratch/tmp/wZ4am1 (focused-final.log, kept-green-final.log, bus-final.log, project.log, business.log; earlier runs retained). Boundaries: Console HTTP/browser files, revoke route and request-panel UI deferred until Task 3 integration; tracker/task consumer wiring belongs to Task 5; built-in legacy binding is deliberately unset here. The Stack PRD amendment remains proposed. Queue command refused this linked worktree, so queue movement stays with the canonical coordinator. One local commit authorized, no push, merge, deployment, live instance changes, role/contract edits or secret access. + +### 2026-11-04 — Lifecycle Task 5b: scoped requirements and Git admission (#797, #1839) + +Before: Task 5a create recovery was already on `origin/refactor` at `103168614`. +Task 4 (`76207fe48`, still under review) was cherry-picked as +`1627e24d79da00a5a6c393fd0b15cea4362bdb1d`; both append-only log sides were kept. +Task creation still used a private Stack requirement ID set, and Git admission did +not bind a task to its product. The queue command refused this linked worktree; +this session followed the owner's explicit Task 5b implementation assignment. + +After: broker discovery and host create admission use Task 4's catalogue, with +checks during planning and immediately before admission. Product creates require +request, requirement, project key, exact approved snapshot and stable op, resolve +the board from the confirmed request, and refresh tracker membership/installation +and role/session authority. Creation evidence retains TaskScope, PRD version and +records/spec commits, including recovered creates under original attribution. +Filters/trails distinguish colliding requirement IDs. Explicit host startup binds +the baked catalogue only to the committed Stack identity with foundation ancestry +and exact repository PRD/catalogue bytes; derived products cannot borrow it. Legacy +unscoped discovery keeps its response, while intake never falls back. Host injection +preserves Broker's sandbox import closure. Git socket/process/runtime pass the code +project before bundle bytes; push recording, review and export recheck the binding. +PM tool schemas carry the scope fields. No migration; scope uses existing JSON +evidence. `requirements.json` and its consistency test are unchanged. + +Verification: new scope/recovery/Git tests **35/35**; required compatibility command +**107/107**; `npm test` in tasks **132/132**, in bus **1501/1501**; additional CLI +configuration/PM tool tests **32/32**. Every final run has zero skips, failures or +cancellations, using a fresh `TMPDIR` under `/mnt/storage/scratch/tmp/XXXXXX`. +Logs: `/mnt/storage/scratch/tmp/task5b-{newtests,regression,tasks,bus,tools}.log`. +Red tests caught missing scope validation, stale tree admission, derived-product +builtin binding and missing PM schema fields. A preliminary full bus run exposed +an import-closure regression, tester refusal ordering and three managed-launch +timeouts; host injection/refusal ordering fixes passed the targeted 35-test run +and the final full suite. A subsequent full run stalled in the existing scratch +SSH fixture; only its owned test child and scratch daemon were terminated, the +interrupted evidence was kept at `/mnt/storage/scratch/tmp/task5b-bus-interrupted.log`, +and the isolated full rerun passed. All started runs were joined before completion. +Local commit only, no push, no live instance or credential-file access. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index fc9a7b54e..6217c349b 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2002,3 +2002,4 @@ are never rewritten or removed; corrections are new entries. 2026-11-03 | GPT-6.1 Sol implementation sub-agent, t3/lifecycle-task-5a-create-recovery | #797 / Gitea #1839 lifecycle Task 5a | Optional journaled create operations, sync-only complete-scan adoption, host-only recovery with original attribution and PM follow-up resume; no migration. Recovery 21/21, required 61/61, tasks 121/121, bus 1477/1477, zero skips; diagnostics and parent reruns in /mnt/storage/scratch/tmp/CkjqAm/. Task 5b deferred, queue left to lead after linked-worktree refusal; one local commit only, no push or live changes. 2026-11-04 | Codex implementation sub-agent, t3/lifecycle-prd-approval-activation | #797 / Gitea #1839 lifecycle Task 4 core | Exact committed product PRD approval/activation, authenticated requirementsFor, owner revoke/mode/draft broker controls, atomic append-only snapshots/receipts and shutdown-safe recovery; migration 3p to 3q. New 27/27, kept-green 21/21, bus 1498/1498, project 50/50, business 305/305, zero skips; logs /mnt/storage/scratch/tmp/wZ4am1. Console HTTP/browser slice deferred for Task 3 integration. Local commit only, no push. +- 2026-11-04 — Codex implementation sub-agent — Lifecycle Task 5b (#797/#1839): shared scoped catalogue admission, explicit validated Stack binding, TaskScope/provenance and Git project guards; based on 103168614 plus cherry-picked Task 4 1627e24d7. Final gates 35/107/132/1501 (+32 configuration/tools), zero skips; logs /mnt/storage/scratch/tmp/task5b-{newtests,regression,tasks,bus,tools}.log. No migration, local commit only, never pushed; no live instance touched. diff --git a/packages/bus/README.md b/packages/bus/README.md index 7d5ae43e7..519ccff0c 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -237,7 +237,7 @@ an operator inspect content and delivery evidence after an uncertain result. Each of the three leaves one receipt in the organization's `changes.jsonl`: the actor and a `detail` of `{verb,agent,kind,date or reason}`, never a value. A change to the store alone writes a receipt with empty `changes`. A refusal is `action.refused` with its code alone. -| `requirements` | No args; role sessions only, as `task.create` is (row 90, #1584). The approved PRD's requirement ids, `{prd,version,requirements:[{id,title}]}`, from `packages/tasks/requirements.json`, loaded when the broker starts; a bad file refuses the start. A human session refuses `agent-required`, a reader `read-only`. | +| `requirements` | Role sessions only, as `task.create` is (row 90, #1584). With `{project_key,requirement_snapshot?}`, returns the exact current approved product snapshot through the shared host `requirementsFor` catalog validator; stale/revoked/foreign snapshots refuse and product reads never fall back. With no args, preserves the legacy response. The approved PRD's requirement ids, `{prd,version,requirements:[{id,title}]}`, from `packages/tasks/requirements.json`, loaded when the broker starts; a bad file refuses the start. A human session refuses `agent-required`, a reader `read-only`. | | `state` | No args; role, human and reader sessions (row 109, #1603). `{launch:{state,at,event},tasks:{open:[{task_ref,title}],total,cap}}` for the capability's business. `launch` is the latest `launch.revoked` or `launch.restored` event: `allowed` with `at` and `event` null when none exists. `open` is the tracker snapshot's open tasks in `task_ref` order, at most `cap` (the `tasks` page size, 50); `total` counts every open task. The harness runner reads it before each turn. Writes nothing. | | `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. | | `project.create` | `{name,repo?}`; human sessions only (row 121, #1615). `repo` must name one of the business's `projects` (`unknown-repo`); a second project with the same name refuses `project-exists`. Returns `{id}`. | @@ -759,7 +759,11 @@ must not pretend a missing/inaccessible task is open or completed. Each row adds computed at each read and never stored. `priority` is the current row's `fields.priority`, null for a tombstone or a read with none (Vikunja's 0 stays 0). `requirement` is the requirement of the first `task.created` for the same Vikunja task id in the business, so a task moved to another -project keeps it; null for a task the stack didn't create. +project keeps it; null for a task the stack didn't create. Scoped rows also carry +`project_key` and `requirement_snapshot` from that immutable creation evidence; +these are optional filters on `tasks` and appear in the task's trail. No scope is +invented for older tasks. `checkPush(cap,task_ref,project)` checks that binding +before bundle admission; `recordPush` repeats it, as do review/export consumers. A `tasks` read with filters (row 89, #1583) is what a session's `list_tasks` makes; the full projection is too large for a tool reply on a real board. It leaves out tombstones and pages in diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 08d64c6db..da18d53e1 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -95,7 +95,7 @@ export const REPLIES = 50; // Row 80 (#1574): how many stranded messages `agents` lists for each role, oldest first. export const STRANDED = 50; // Row 89 (#1583): the arguments `tasks` takes, and its page size by default and at most. -const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref']; +const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref', 'project_key', 'requirement_snapshot']; export const TASK_PAGE = 50; export const TASK_PAGE_MAX = 200; // Row 134 (REQ-HARN-2): the web verbs a role holding the "web" tool grant may call, and the label @@ -267,6 +267,7 @@ const checkEventId = (mid) => { }; const text = (x, max = 4096) => typeof x === 'string' && x.length > 0 && x.length <= max && !x.includes('\0'); const id = (x) => text(x, 160) && /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/.test(x); +const projectKey = x => typeof x === 'string' && ID_PATTERN.test(x); function keys(x, allowed, required = []) { if (!object(x) || Object.keys(x).some((k) => !allowed.includes(k)) || required.some((k) => !(k in x))) fail('invalid-request'); @@ -445,6 +446,10 @@ export class Broker { #secretCheck; #credentialStatus; #requirements; + #catalog; + #requestJournal; + #builtin; + #builtinBinding; #organization; #credentialVerbs; #proxyInit; @@ -507,7 +512,7 @@ export class Broker { #clock = 0; #processing; // credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use(). - constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null }) { + constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null }) { if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business'); // Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store. if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function'); @@ -560,6 +565,12 @@ export class Broker { this.#roleFile = roleFile; // Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here. this.#requirements = loadRequirements(); + // These are host capabilities, like project/library; no filesystem module enters the client closure. + this.#requestJournal = requirementsHost?.journal; + this.#builtinBinding = requirementsHost?.binding; + this.#builtin = requirementsHost?.builtin; + this.#catalog = requirementsHost?.catalog; + const builtinFor = requirementsHost?.builtinFor; for (const table of [ 'events', 'role_claims', @@ -589,6 +600,7 @@ export class Broker { if (typeof requestLifecycle !== 'function') throw new TypeError('requestLifecycle must be a host factory'); this.#requests = requestLifecycle({ dataRoot, + builtinBinding: builtinFor, origin: (identity, request) => this.#requestOrigin(identity, request), authorize: (identity, state, operation) => this.#requestAuthorize(identity, state, operation), project: (business, project) => { @@ -671,19 +683,59 @@ export class Broker { this.#store.get("SELECT 1 FROM thread_claims c JOIN threads t ON t.id=c.thread WHERE c.business=? AND t.instance=? AND c.holder_run=? AND c.op='claim'", state.business, route.instance, r.by))) fail('prd-authority-refused'); return { decision: d.id, resolution_seq: r.seq, by: r.by, via: r.via }; } + // Host-only request binding. A numeric tracker board is distinct from the code project key. + taskCreateTarget(cap, args) { + const s = this.#session(cap); this.#agent(s); + return this.#createTarget(s, args); + } + #createTarget(s, args) { + if (!this.taskView(s.business, 'input', args.request)) fail('request-not-found'); + if (!this.#requestJournal) fail('requirement-snapshot-required'); + const state = this.#requestJournal.readRequest(s.business, args.request); + if (state || args.project_key !== undefined || args.requirement_snapshot !== undefined) { + if (!args.project_key || !args.requirement_snapshot || !args.op) fail('task-scope-required'); + if (!state || !state.project || state.project !== args.project_key || state.pm !== s.role) fail('request-project-refused'); + const bindings = state.selection?.bindings.filter(b => b.digest === state.plan_digest) ?? []; + if (bindings.length !== 1 || bindings[0].plan.project !== state.project || state.phase === 'cancelled') fail('request-project-refused'); + if (state.chat_project) { + const chat = this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', state.chat_project, s.business); + if (!chat || chat.repo !== state.project) fail('request-project-refused'); + } + const board = bindings[0].plan.tracker.project; + if (args.project !== undefined && args.project !== board) fail('request-tracker-refused'); + return board; + } + const binding = this.#builtinBinding; + if (!binding || binding.business !== s.business) fail('requirement-snapshot-required'); + // A chat-associated product is a conflicting product binding even without an intake row. + const origin = this.#requestOrigin(s, args.request); + const chat = origin.thread && this.#store.get('SELECT project FROM threads WHERE id=? AND business=?', origin.thread, s.business); + const associated = chat?.project && this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', chat.project, s.business); + if (associated?.repo && associated.repo !== binding.project) fail('request-project-refused'); + if (args.project !== undefined && args.project !== binding.tracker_project) fail('request-tracker-refused'); + return binding.tracker_project; + } async requirementsFor(cap, args) { const s = this.#session(cap); - try { - keys(args, ['project_key','requirement_snapshot','requirement'], ['project_key']); - this.#agent(s); this.#processingGate(s, 'requirements'); - if (!this.#business(s.business).projects?.[args.project_key]) fail('request-project-refused'); - if (!this.#requests?.catalog) fail('requirement-snapshot-required'); - const result = await this.#requests.catalog.resolve({ business: s.business, project: args.project_key, - ...(args.requirement_snapshot === undefined ? {} : { snapshot: args.requirement_snapshot }) }); - this.#agent(s); this.#processingGate(s, 'requirements'); - if (args.requirement !== undefined && !result.requirements.some(r => r.id === args.requirement)) fail('unknown-requirement'); - return result; - } catch (e) { throw this.#refused(s, new BusError(e.code ?? 'requirement-snapshot-refused'), { args }); } + try { return await this.#requirementsFor(s, args); } + catch (e) { throw this.#refused(s, new BusError(e.code ?? 'requirement-snapshot-refused'), { args }); } + } + async #requirementsFor(s, args) { + keys(args, ['project_key','requirement_snapshot','requirement','request','project','op']); + this.#agent(s); this.#processingGate(s, 'requirements'); + if (args.request !== undefined) this.#createTarget(s, args); + const project = args.project_key ?? (args.request !== undefined ? this.#builtinBinding?.project : undefined); + if (!projectKey(project) || !this.#business(s.business).projects?.[project] || this.#business(s.business).projects[project].kind === 'library') fail('request-project-refused'); + if (args.requirement_snapshot !== undefined && !id(args.requirement_snapshot)) fail('invalid-request'); + const catalog = this.#requests?.catalog ?? this.#catalog; + if (!catalog) fail('requirement-snapshot-required'); + const snapshot = args.requirement_snapshot ?? (args.project_key === undefined ? this.#builtin?.id : undefined); + const result = await catalog.resolve({ business: s.business, project, ...(snapshot === undefined ? {} : { snapshot }) }); + this.#agent(s); this.#processingGate(s, 'requirements'); + if (args.request !== undefined) this.#createTarget(s, args); + if (args.project_key !== undefined && result.origin !== 'product') fail('requirement-snapshot-required'); + if (args.requirement !== undefined && !result.requirements.some(r => r.id === args.requirement)) fail('unknown-requirement'); + return result; } #requestOrigin(s, request) { const b = this.#business(s.business); @@ -4218,18 +4270,22 @@ export class Broker { // didn't create. Both come from append-only rows at each read; nothing is stored. #taskRows(business) { const taskId = (ref) => ref.slice(ref.indexOf('/') + 1); - const requirement = new Map(); + const requirement = new Map(), scopes = new Map(); for (const e of this.#store.all( - "SELECT subject,json_extract(body,'$.requirement') AS requirement FROM events WHERE business=? AND kind='task.created' ORDER BY seq", + "SELECT subject,body,json_extract(body,'$.requirement') AS requirement FROM events WHERE business=? AND kind='task.created' ORDER BY seq", business, )) - if (!requirement.has(taskId(e.subject))) requirement.set(taskId(e.subject), e.requirement); + if (!requirement.has(taskId(e.subject))) { + requirement.set(taskId(e.subject), e.requirement); + const b = JSON.parse(e.body); + if (b.scope) scopes.set(taskId(e.subject), b.scope); + } return this.#store .all('SELECT * FROM task_current WHERE business=? ORDER BY task_ref', business) .map((r) => { const fields = JSON.parse(r.fields); const priority = fields.priority ?? null; - return { ...r, fields, priority, requirement: requirement.get(taskId(r.task_ref)) ?? null }; + return { ...r, fields, priority, requirement: requirement.get(taskId(r.task_ref)) ?? null, ...(scopes.get(taskId(r.task_ref)) ?? {}) }; }); } // Row 89 (#1583): with no arguments, every row as #taskRows gives it, which the Console and @@ -4251,6 +4307,8 @@ export class Broker { ('assignee' in a && !(typeof a.assignee === 'string' && Object.hasOwn(roles, a.assignee))) || ('label' in a && !positive(a.label)) || ('requirement' in a && !(typeof a.requirement === 'string' && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(a.requirement))) || + ('project_key' in a && !projectKey(a.project_key)) || + ('requirement_snapshot' in a && !id(a.requirement_snapshot)) || ('limit' in a && !positive(a.limit, TASK_PAGE_MAX)) || ('after' in a && !taskRef(a.after)) ) @@ -4262,7 +4320,7 @@ export class Broker { ); const matched = rows .filter((r) => !r.fields.gone) - .map(({ task_ref, fields: f, priority, requirement, at }) => ({ + .map(({ task_ref, fields: f, priority, requirement, project_key, requirement_snapshot, at }) => ({ task_ref, title: f.title ?? null, done: Boolean(f.done), @@ -4271,6 +4329,7 @@ export class Broker { labels: f.labels ?? [], priority, requirement, + ...(project_key === undefined ? {} : { project_key, requirement_snapshot }), changed_at: at, })) .filter( @@ -4278,7 +4337,9 @@ export class Broker { (!('done' in a) || t.done === a.done) && (!('assignee' in a) || t.assignees.includes(a.assignee)) && (!('label' in a) || t.labels.includes(a.label)) && - (!('requirement' in a) || t.requirement === a.requirement), + (!('requirement' in a) || t.requirement === a.requirement) && + (!('project_key' in a) || t.project_key === a.project_key) && + (!('requirement_snapshot' in a) || t.requirement_snapshot === a.requirement_snapshot), ); const rest = 'after' in a ? matched.filter((t) => t.task_ref > a.after) : matched; const limit = a.limit ?? TASK_PAGE; @@ -4409,16 +4470,26 @@ export class Broker { business, task, ); - return r ? { ...JSON.parse(r.body), role: r.actor_role } : null; + if (!r) return null; + const push = { ...JSON.parse(r.body), role: r.actor_role }; + this.#taskProject(business, task, push.project); + return push; } // Row 93: the bus host's git socket asks before it takes a push's bytes. Nothing is recorded // unless it refuses: the push is recorded, and its authority consumed, once the ref moved. - checkPush(cap, task_ref) { + #taskProject(business, task_ref, project) { + const taskId = task_ref.slice(task_ref.indexOf('/') + 1); + const row = this.#store.get("SELECT body FROM events WHERE business=? AND kind='task.created' AND substr(subject,instr(subject,'/')+1)=? ORDER BY seq LIMIT 1", business, taskId); + const body = row && JSON.parse(row.body), scope = body?.scope ?? (body?.project_key ? body : null); + if (scope && (scope.project_key !== project || !Object.hasOwn(this.#business(business).projects ?? {}, project))) fail('task-project-refused'); + } + checkPush(cap, task_ref, project) { this.#processingWork(); const s = this.#session(cap); return this.#store.transaction(() => { this.#agent(s); if (!taskRef(task_ref)) fail('invalid-request'); + this.#taskProject(s.business, task_ref, project); return this.#checkAuthority(s, 'git.push.working', { target: task_ref }); }); } @@ -4439,6 +4510,7 @@ export class Broker { string(push.branch, 200); string(push.ref, 200); const { task_ref, project, branch, ref, commit, previous } = push; + this.#taskProject(s.business, task_ref, project); this.#consumeAuthority(s, 'git.push.working', { target: task_ref }, { project, branch, ref, commit, previous }); return { task_ref, commit }; }); @@ -4561,6 +4633,7 @@ export class Broker { string(args.branch, 200); this.#agent(s); const { task_ref, project, branch } = args; + this.#taskProject(s.business, task_ref, project); const decision = args.decision ?? null; const result = this.#checkAuthority(s, 'git.push.remote', { decision, target: task_ref }); const facts = this.#publishFacts(s, task_ref, project, result); @@ -4576,6 +4649,7 @@ export class Broker { // Steps 2 and 3, checked again at recordPublish. After routing a within-role publish has no // decision, so the commit is the latest push's (N9); the git socket still checks the work ref tip. #publishFacts(s, task_ref, project, result) { + this.#taskProject(s.business, task_ref, project); if (result.decision && this.#spent(s.business, result.decision)) fail('decision-consumed'); const b = this.#business(s.business); const role = b.roles?.[s.role]; @@ -4868,6 +4942,7 @@ export class Broker { ); const body = r ? JSON.parse(r.body) : null; if (!body || body.to !== s.role) fail('request-not-found'); + this.#taskProject(s.business, task, body.project); return { ...body, from: r.actor_role }; } // Trusted adapters only. No agent socket route reaches this method. @@ -4947,6 +5022,7 @@ export class Broker { this.#snapshot(s, kept); this.#event(s, 'task.created', { request: p.request, requirement: p.requirement, op: p.op, create_receipt: r.id, create_body_digest: p.body_digest, + ...(p.evidence ?? {}), ...(p.project_key === undefined ? {} : { project_key: p.project_key }), ...(p.requirement_snapshot === undefined ? {} : { requirement_snapshot: p.requirement_snapshot }), }, task_ref); @@ -5137,6 +5213,15 @@ export class Broker { ? this.#requests.catalog.revoke(s, structuredClone(args)) : this.#requests.modeConsole(s, structuredClone(args))) .catch(e => { throw this.#refused(s, new BusError(e.code ?? 'request-failed'), request); }); } + // Scoped discovery performs committed Git reads, outside the synchronous SQL dispatch. + if (request.verb === 'requirements' && Object.keys(args).length) { + if (s.reader) fail('read-only'); + if (s.tester) fail('tester-refused'); + keys(args, ['project_key','requirement_snapshot'], ['project_key']); + return this.#requirementsFor(s, structuredClone(args)) + .then(result => { this.#secretCheck(result); return result; }) + .catch(e => { throw this.#refused(s, new BusError(e.code ?? 'requirement-snapshot-refused'), request); }); + } if (['request.open','request.get','request.projects','request.propose','prd.submit'].includes(request.verb)) { if (s.reader) fail('read-only'); if (s.tester) fail('tester-refused'); diff --git a/packages/bus/src/process.mjs b/packages/bus/src/process.mjs index 2ad612a1f..07b8ab95a 100644 --- a/packages/bus/src/process.mjs +++ b/packages/bus/src/process.mjs @@ -46,7 +46,7 @@ function launchOp(m) { if (m.op === 'endLaunch') return runtime.endLaunch(m.record); if (m.op === 'recoverLaunch') return runtime.recoverLaunch(m.record); // Row 93 (#1587): the git socket's ops. - if (m.op === 'checkPush') return runtime.checkPush(m.cap, m.task_ref); + if (m.op === 'checkPush') return runtime.checkPush(m.cap, m.task_ref, m.project); if (m.op === 'recordPush') return runtime.recordPush(m.cap, m.push); if (m.op === 'reviewFetch') return runtime.reviewFetch(m.cap, m.args); // Row 137 (#1631): the git socket's land op, and the launcher's question whether to bind it. diff --git a/packages/bus/src/requirement-catalog.mjs b/packages/bus/src/requirement-catalog.mjs index 4ad310453..3ddf9f0b7 100644 --- a/packages/bus/src/requirement-catalog.mjs +++ b/packages/bus/src/requirement-catalog.mjs @@ -19,10 +19,11 @@ export function createRequirementCatalog({ journal, readSpec, builtinBinding }) exact(args, ['business','project'], ['snapshot']); if (!key(args.business) || !key(args.project) || (args.snapshot !== undefined && !id(args.snapshot))) refuse('invalid-request'); const { business, project } = args; + const builtin = await builtinBinding?.({ business, project }); + if (args.snapshot && builtin?.id === args.snapshot) return structuredClone(builtin); const events = journal.snapshotEvents(business, project); const snapshot = args.snapshot ?? [...events].reverse().find(e => e.kind === 'activated')?.snapshot; if (!snapshot) { - const builtin = await builtinBinding?.({ business, project }); if (builtin && builtin.origin === 'builtin' && builtin.business === business && builtin.project === project) return builtin; refuse('requirement-snapshot-required'); } diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 447501726..94100d4c2 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -1,3 +1,4 @@ +import { createRequirementsHost } from '../../tasks/src/builtin.mjs'; import { createRequirementCatalog } from './requirement-catalog.mjs'; import { readPrdMaterial } from '../../project/src/prd.mjs'; import { lstatSync, readdirSync, readFileSync } from 'node:fs'; @@ -267,6 +268,7 @@ export async function startBroker({ // The boot config's plain-data tracker settings, { business: { project, ... } }. The tasks adapter reads its own copy; // the host's new-project preparation reads each business's tracker root board from this one. trackers = null, + builtinBinding = null, }) { if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) || Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512))) @@ -565,7 +567,9 @@ export async function startBroker({ if (Object.hasOwn(businesses[id].projects ?? {}, project)) return { ready: true, revision, code: null }; return no(declared ? 'project-pending' : 'project-unknown'); } + const requirementsHost = createRequirementsHost({ store, businesses, builtinBinding, journal: requestJournal }); broker = new Broker({ + requirementsHost, requestLifecycle: services => { const readCandidate = (candidate, business) => { const { request, project, prd_version, records_commit, prd_path, mode_revision } = candidate; @@ -574,7 +578,7 @@ export async function startBroker({ }; const catalog = createRequirementCatalog({ journal: { ...requestJournal, readCandidate, authority: services.prdAuthority, approval: services.prdApproval }, readSpec: ({ business, project }) => projectModule.readSpec({ business: services.prdBusiness(business), project, dataRoot }), - builtinBinding: null }); + builtinBinding: services.builtinBinding }); return (requestLifecycle = createRequestLifecycle({ journal: requestJournal, services: { catalog, readCandidate, ...(writer ? hostPreparation({ dataRoot, organization: writer, trackers }) : {}), ...requestServices, ...services, // The new-project executor needs the organization writer; without one a confirmed selection stays at its setup wait. ...(writer ? { bootstrap: ({ binding, state }) => processing.work(() => executeBootstrap({ binding, owner: state.owner, journal: requestJournal, services: { @@ -834,7 +838,7 @@ export async function startBroker({ refusing(cap, () => broker.authorize(cap, 'role.launch', { target: instance })); // Row 93 (#1587): the host's git socket. A push is checked before the host takes the bundle // and recorded once the ref moved; a reviewer's fetch is checked against its review.requested. - const checkPush = (cap, task_ref) => refusing(cap, () => broker.checkPush(cap, task_ref), task_ref); + const checkPush = (cap, task_ref, project) => refusing(cap, () => broker.checkPush(cap, task_ref, project), task_ref); const reviewFetch = (cap, args) => refusing(cap, () => broker.reviewFetch(cap, args), args?.task_ref); // Row 137 (#1631): the git socket's land op. checkLand's refusal is the run's, like checkPush's. // A landing then ends in recordLand or landRefused under its ticket, which outlive the run's cap. diff --git a/packages/bus/tests/scoped-push.test.mjs b/packages/bus/tests/scoped-push.test.mjs new file mode 100644 index 000000000..7e6aa0b4c --- /dev/null +++ b/packages/bus/tests/scoped-push.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { scopedFixture } from '../../tasks/tests/scoped-fixture.mjs'; +const A = 'a'.repeat(40); +const push = (f, task, project) => ({ task_ref: task.task_ref, project, branch: 'feat', ref: 'refs/heads/work', commit: A, previous: null }); + +test('checkPush binds a product before bytes and recordPush repeats the project check', async t => { + const f = await scopedFixture(t), p = await f.product('timer'), task = await f.create(p.args); + const b = f.bus.broker; + for (const project of ['stack', undefined, 'missing']) assert.throws(() => b.checkPush(f.caps.coder, task.task_ref, project), { code: 'task-project-refused' }); + b.checkPush(f.caps.coder, task.task_ref, 'timer'); + assert.throws(() => b.recordPush(f.caps.coder, push(f, task, 'stack')), { code: 'task-project-refused' }); + b.recordPush(f.caps.coder, push(f, task, 'timer')); + const review = f.call('coder', 'review.request', { task_ref: task.task_ref, commit: A, to: 'reviewer' }); + assert.equal(b.reviewFetch(f.caps.reviewer, { task_ref: task.task_ref, request: review.request }).project, 'timer'); + await f.restart(); + assert.throws(() => f.bus.checkPush(f.caps.coder, task.task_ref, 'stack'), { code: 'task-project-refused' }); + f.bus.checkPush(f.caps.coder, task.task_ref, 'timer'); +}); + +test('review, review export and remote export reject old cross-project evidence for a scoped task', async t => { + const f = await scopedFixture(t), p = await f.product('timer'), task = await f.create(p.args), b = f.bus.broker; + // Host-written historical evidence can predate scope admission; consumers must still check it. + const { DatabaseSync } = await import('node:sqlite'); + const db = new DatabaseSync(f.f.dataRoot + '/bus/bus.sqlite'); + db.prepare("INSERT INTO events(id,at,business,actor_role,actor_run,kind,subject,body) VALUES('old-push',?,'demo','coder','coder-run','action.allowed',?,?)").run(new Date().toISOString(), task.task_ref, JSON.stringify({ action: 'git.push.working', project: 'stack', commit: A, ref: 'refs/heads/work' })); + db.prepare("INSERT INTO events(id,at,business,actor_role,actor_run,kind,subject,body) VALUES('old-review',?,'demo','coder','coder-run','review.requested',?,?)").run(new Date().toISOString(), task.task_ref, JSON.stringify({ to: 'reviewer', project: 'stack', commit: A, ref: 'refs/heads/work' })); + db.close(); + assert.throws(() => f.call('coder', 'review.request', { task_ref: task.task_ref, commit: A, to: 'reviewer' }), { code: 'task-project-refused' }); + assert.throws(() => b.reviewFetch(f.caps.reviewer, { task_ref: task.task_ref, request: 'old-review' }), { code: 'task-project-refused' }); + assert.throws(() => b.checkPublish(f.caps.coder, { task_ref: task.task_ref, project: 'stack', branch: 'feat' }), { code: 'task-project-refused' }); +}); + +test('legacy recorded tasks still admit a push without inventing scope', async t => { + const f = await scopedFixture(t), task = { task_ref: 'vikunja:1/789' }, b = f.bus.broker; + b.recordTask({ cap: f.caps.pm, business: 'demo', events: [{ kind: 'task.created', subject: task.task_ref, body: { request: f.instruction(), requirement: 'REQ-TASK-1' } }] }); + b.checkPush(f.caps.coder, task.task_ref); + b.recordPush(f.caps.coder, push(f, task, 'stack')); + assert.equal(f.call('coder', 'trail', { subject: task.task_ref }).find(e => e.body?.action === 'git.push.working').body.scope, undefined); +}); diff --git a/packages/cli/src/config.mjs b/packages/cli/src/config.mjs index 782567e44..95dc8274f 100644 --- a/packages/cli/src/config.mjs +++ b/packages/cli/src/config.mjs @@ -3,6 +3,7 @@ // does), the business file, and the boot message for the broker process. // Everything here reads and refuses; nothing writes. +import { stackBinding } from "../../tasks/src/builtin.mjs"; import { spawnSync } from "node:child_process"; import { existsSync, lstatSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; @@ -81,13 +82,13 @@ function trackerFor(system, b, warn, dataRoot) { return null; } const v = named[0].vars; - return { + return { tracker: { baseUrl: v["tracker.baseUrl"], project: v["tracker.project"], pollSeconds: v["tracker.pollSeconds"], reconcileMinutes: v["tracker.reconcileMinutes"], descendants: v["tracker.descendants"], - }; + }, builtinBinding: stackBinding({ business: b.id, project: named[0].id, root: b.projects[named[0].id].root, tracker_project: v["tracker.project"] }) }; } // FED R6 (#1800, sage-3142): the runtime's `access`, { file, context }. The broker reads the file @@ -142,7 +143,10 @@ export function bootConfig({ system, businessId, env = process.env, warn = () => readers: [b.id], }; const tracker = trackerFor(vars, b, warn, system.dataRoot); - if (tracker) config.trackers = { [b.id]: tracker }; + if (tracker) { + config.trackers = { [b.id]: tracker.tracker }; + if (tracker.builtinBinding) config.builtinBinding = tracker.builtinBinding; + } const access = accessFor(system, env, warn); if (access) config.access = access; // Row 252 (#1753, Webby N1): every console.sso subject is an owner, by provider id, issuer and sub. diff --git a/packages/cli/src/git-socket.mjs b/packages/cli/src/git-socket.mjs index 1bc38f462..1c547292d 100644 --- a/packages/cli/src/git-socket.mjs +++ b/packages/cli/src/git-socket.mjs @@ -324,7 +324,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env if (size > bundleMax) throw new Refusal("bundle-too-large", `${size} bytes, over ${bundleMax}`); // The runner waits for "send"; bytes before it break the protocol. if (rest.length) throw new Refusal("invalid-request", "bundle bytes before send"); - await op({ op: "checkPush", cap, task_ref }); + await op({ op: "checkPush", cap, task_ref, project: req.project }); socket.write(`${JSON.stringify({ ok: true, send: size })}\n`); await readBody(socket, file, size, deadlineOf(size), bodyIdle); const result = await push({ diff --git a/packages/cli/tests/git-socket.test.mjs b/packages/cli/tests/git-socket.test.mjs index 988b82dc4..6150c1d81 100644 --- a/packages/cli/tests/git-socket.test.mjs +++ b/packages/cli/tests/git-socket.test.mjs @@ -233,6 +233,20 @@ test("refusals: no action, non-fast-forward, an unknown commit or request, and a assert.ok(refused.includes(r), `${r} in ${refused.join(", ")}`); }); +test("a scoped task refuses the wrong code project before the socket asks for bundle bytes", async (t) => { + const { DatabaseSync } = await import("node:sqlite"); + let admission; + const w = await setup(t, { wrap: host => ({ op: m => { if (m.op === "checkPush") admission = m; return host.op(m); } }) }); + const db = new DatabaseSync(join(w.f.dataRoot, "bus/bus.sqlite")); + db.prepare("INSERT INTO events(id,at,business,kind,body) VALUES('fixture',?,'acme','human.input','{}')").run(new Date().toISOString()); + db.prepare("INSERT INTO events(id,at,business,kind,subject,body) VALUES('scoped-create',?,'acme','task.created',?,?)") + .run(new Date().toISOString(), TASK, JSON.stringify({ request: "fixture", requirement: "REQ-POMO-1", scope: { project_key: "timer", requirement_snapshot: "prd-fixture", requirement: "REQ-POMO-1" } })); + db.close(); + const answer = await raw(w.sock.path, JSON.stringify({ op: "push", cap: w.roles.coder.cap, task_ref: TASK, project: "stack", branch: "feat", size: 100 }) + "\n"); + assert.equal(answer.ok, false); assert.equal(answer.error, "task-project-refused"); assert.equal(answer.send, undefined); + assert.equal(admission.project, "stack"); assert.equal(w.refs(), ""); +}); + test("the size cap refuses a push before any byte", async (t) => { const w = await setup(t, { bundleMax: 64 }); gitIn(w.roles.coder.clone, "checkout", "-q", "-b", "feat"); diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs index 1e4262df8..6f1dca5a5 100644 --- a/packages/harness/src/tools.mjs +++ b/packages/harness/src/tools.mjs @@ -48,7 +48,7 @@ const PRIORITY = { type: "integer", minimum: 0, maximum: 5, description: "Priori const TASK_TOOLS = { "task.create": ["task_create", "Create a tracker task. Every task cites the request it came from and a requirement id.", { title: str("Task title, one line"), - project: { type: "integer", minimum: 1, description: "Tracker project id, an installed project in the business's tree; the root project when left out" }, + project: { type: "integer", minimum: 1, description: "Optional tracker board assertion; must match the confirmed request binding" }, description: str("Task description"), due_date: DUE, priority: PRIORITY, @@ -56,6 +56,9 @@ const TASK_TOOLS = { relations: { type: "array", items: RELATION, maxItems: 50 }, request: str("The request id of the human message this task answers, shown as \"request \" in that message's header; not the message's own id"), requirement: str("Requirement id from list_requirements, such as REQ-CRED-2", { pattern: "^REQ-[A-Z]+-[1-9][0-9]*$" }), + project_key: str("Product code project key; required for every intake request"), + requirement_snapshot: str("Exact approved snapshot returned by list_requirements; required for product tasks"), + op: str("Stable operation key; required for product tasks and reused for all retries of the same create"), decision: COMMON.decision, }, ["title", "request", "requirement"]], "task.assign": ["task_assign", "Assign an unassigned task to a role.", { ...COMMON, role: str("Role instance to assign") }, ["task_ref", "role"]], @@ -332,13 +335,18 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project assignee: str("Only tasks assigned to this role"), label: { type: "integer", minimum: 1, description: "Only tasks with this label id" }, requirement: str("Only tasks created for this requirement id", { pattern: "^REQ-[A-Z]+-[1-9][0-9]*$" }), + project_key: str("Only tasks bound to this code project, including when requirement IDs collide"), + requirement_snapshot: str("Only tasks created from this exact requirement snapshot"), limit: { type: "integer", minimum: 1, maximum: TASK_PAGE_MAX, description: `Tasks per page, ${TASK_PAGE} by default` }, after: str("The next value of the previous page"), })); add("get_task", "tasks", null, "Read one tracker task in full, description included.", obj({ task_ref: TASK_REF }, ["task_ref"])); // Row 90 (#1584): the ids task_create takes, for the sessions that hold it. if (held.has("task.create")) - add("list_requirements", "requirements", null, "List the approved PRD's requirement ids, each with a one-line title. task_create takes only these. When none fits a request, raise a prd.approve decision instead of creating the task.", obj({})); + add("list_requirements", "requirements", null, "Read the exact approved product snapshot by project_key, optionally requirement_snapshot; task_create takes only these requirements and must cite that snapshot. With no args, list the legacy Stack catalogue. Product intake always needs an approved product snapshot.", obj({ + project_key: str("Product code project key"), + requirement_snapshot: str("Exact approved snapshot ID; omit to read the active snapshot"), + })); } // mos-1308 / M3: no generic spec, evidence, tracker, decision or write tools. The // C2-08 web grant still supplies broker web reads to companion and research only. diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs index 48a48df6b..de538190f 100644 --- a/packages/harness/tests/tools.test.mjs +++ b/packages/harness/tests/tools.test.mjs @@ -232,9 +232,10 @@ test("list_requirements reads the requirement ids, for a task.create holder with const tools = typedTools({ resolved: pm, arbiter: true, tracker: true }); const list = tools.find((x) => x.name === "list_requirements"); assert.deepEqual([list.verb, list.action], ["requirements", null]); - assert.deepEqual(list.parameters.properties, {}); + assert.deepEqual(Object.keys(list.parameters.properties), ["project_key", "requirement_snapshot"]); + assert.deepEqual(list.parameters.required, []); assert.match(list.description, /task_create takes only these/); - assert.match(list.description, /raise a prd\.approve decision instead of creating the task/); + assert.match(list.description, /Product intake always needs an approved product snapshot/); assert.ok(!names(typedTools({ resolved: pm, arbiter: true })).includes("list_requirements")); const noCreate = resolvedFor(t, "pm", (d) => (d.roles.pm.vars["limits.authority"] = ["message.send", "task.assign"])); const without = names(typedTools({ resolved: noCreate, tracker: true })); @@ -359,7 +360,7 @@ test("list_tasks takes the broker's filters and pages; get_task takes a task_ref const get = tools.find((x) => x.name === "get_task"); assert.deepEqual([list.verb, list.action, get.verb, get.action], ["tasks", null, "tasks", null]); const p = list.parameters; - assert.deepEqual(Object.keys(p.properties), ["done", "assignee", "label", "requirement", "limit", "after"]); + assert.deepEqual(Object.keys(p.properties), ["done", "assignee", "label", "requirement", "project_key", "requirement_snapshot", "limit", "after"]); assert.deepEqual(p.required, []); assert.equal(p.properties.done.type, "boolean"); assert.equal(p.properties.assignee.type, "string"); @@ -411,3 +412,14 @@ test("web_search's query maxLength is the web layer's SEARCH_MAX_QUERY_CHARS", a const search = typedTools({ resolved: granted }).find((x) => x.name === "web_search"); assert.equal(search.parameters.properties.query.maxLength, SEARCH_MAX_QUERY_CHARS); }); + +test("PM tools carry the product snapshot and stable create operation, and filter colliding requirements by project", t => { + const tools = typedTools({ resolved: resolvedFor(t, "pm"), tracker: true }); + const params = name => tools.find(t => t.name === name).parameters; + for (const field of ["project_key", "requirement_snapshot", "op"]) assert.equal(params("task_create").properties[field]?.type, "string"); + for (const name of ["list_requirements", "list_tasks"]) { + assert.equal(params(name).properties.project_key?.type, "string"); + assert.equal(params(name).properties.requirement_snapshot?.type, "string"); + } + assert.deepEqual(params("task_create").required, ["title", "request", "requirement"]); +}); diff --git a/packages/tasks/README.md b/packages/tasks/README.md index 6bb87b15f..5334e5a6c 100644 --- a/packages/tasks/README.md +++ b/packages/tasks/README.md @@ -141,8 +141,24 @@ the reviewed source. `tests/requirements.test.mjs` reads its holds the same ids in the same order at the same version. A later PRD version is a new list in a reviewed commit. `loadRequirements()` (`src/requirements.mjs`) checks the file's shape, refuses any other and -returns it frozen; the adapter loads it when it starts, and the bus -loads it for its `requirements` read. Neither starts on a bad file. +returns it frozen; the broker loads it at startup and preserves the unscoped +`requirements` response. A bad catalogue refuses startup. `bootConfig` passes an +explicit `{business,project,tracker_project}` builtin binding only after checking +the code project's committed identity, Stack foundation ancestry, and exact +repository-owned PRD/catalogue bytes. A name, requirement prefix or board number +cannot establish that binding. New legacy creates use its baked version/hash; +existing recorded tasks gain no scope retroactively. + +Product reads and creates use `Broker.requirementsFor` and the Task 4 catalogue. +`requirements({project_key,requirement_snapshot?})` returns the exact approved +snapshot, including PRD version, records commit and spec commit. A product call +never falls back to the baked Stack catalogue. Creation checks that snapshot +while planning and immediately before admission, with current session/role +authority and refreshed tracker membership/installation. `task.created` records +`scope: {project_key,requirement_snapshot,requirement}`, source version/commits +and `op`. Task filters accept `project_key` and `requirement_snapshot` so identical +requirement IDs in different products remain distinct in task lists and trails. +Scoped pushes, reviews and exports must use the task's bound code project. 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, @@ -199,11 +215,15 @@ Details that aren't obvious from the table: - `task.create` needs `request`, the id of a `human.input` event of this business (`request-not-found` otherwise), and a `requirement` from - `requirements.json`, like `REQ-TASK-3`. An id in the right form that - the list doesn't hold refuses `unknown-requirement`. The `request` is + its approved catalogue snapshot, like `REQ-TASK-3`. An id in the right form + that the snapshot does not hold refuses `unknown-requirement`. The `request` is the `request` field `message.receive` gives a human's message, not the - message's own id. `project` is the tracker project id to create the - task in, the root when left out. Any other project than an installed + message's own id. Every intake request, including selection and discovery, + requires `project_key`, `requirement_snapshot` and a stable `op`; missing fields + refuse `task-scope-required`. The request's unique confirmed binding resolves + the tracker board. `project`, when supplied, asserts that numeric board; + a mismatch refuses `request-tracker-refused`. Unscoped legacy calls require + the explicit Stack binding and no conflicting source product association. Any other project than an installed one of the tree refuses with `project-not-installed`, and a value that isn't a positive integer with `invalid-request`. The decision a create may need is bound to no target, so it doesn't name the project. @@ -238,13 +258,12 @@ so a poll never reads between two writes of a verb. ## Recoverable creates (lifecycle Task 5a) -A lifecycle caller supplies a stable `op` with `task.create` and reuses it for -all retries of that exact request and body. Existing calls without the new -fields keep their previous behavior. Optional `project_key` and -`requirement_snapshot` are opaque strings carried into evidence; Task 5b will -validate their catalogue binding. If either is supplied without `op`, the -adapter generates a key, recorded in `task.created` evidence. Supply your own -key to retry safely after a lost response. +A product caller supplies `project_key`, `requirement_snapshot` and a stable +`op` with `task.create`, reusing all three for retries of that exact request and +body. The adapter validates the approved snapshot and request binding; it never +generates an omitted product operation key. Unscoped legacy calls retain the +existing response/body behavior through the explicit Stack binding and can opt +into recovery by supplying `op`. The adapter adds this visible suffix to the description, omitting absent fields: @@ -436,6 +455,10 @@ on a live read with the sync key, and a switch is a follow-up row. | `tracker-unavailable` | 5xx or no answer on a read | | `task-create-uncertain` | reserved operation requires reconciliation; never authorizes another POST | | `task-create-conflict` | an operation key or recovery receipt has a different identity, body or target | +| `task-scope-required` | an intake/product create omitted its code project, snapshot or operation | +| `requirement-snapshot-required`, `requirement-snapshot-refused`, `requirement-snapshot-revoked` | no approved catalogue binding, or an invalid/foreign/stale snapshot | +| `request-project-refused`, `request-tracker-refused` | the request's confirmed product or tracker board does not match the create | +| `task-project-refused` | a scoped task's Git operation names another code project | | `write-uncertain` | 5xx or no answer on a write, not settled by a re-read | | `tracker-shape`, `tracker-paging`, `tracker-unexpected` | Vikunja sent something the probes didn't show | | `task-moved`, `task-placement`, `task-project` | the task is in another project, or on no bucket | diff --git a/packages/tasks/src/builtin.mjs b/packages/tasks/src/builtin.mjs new file mode 100644 index 000000000..72517a075 --- /dev/null +++ b/packages/tasks/src/builtin.mjs @@ -0,0 +1,48 @@ +// Host startup only: bind the baked Stack catalogue to a proven Stack code project, never a name or board heuristic. +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { loadRequirements, REQUIREMENTS_FILE } from './requirements.mjs'; +import { createRequestStore, exact, key, refuse } from '../../bus/src/request-store.mjs'; +import { createRequirementCatalog } from '../../bus/src/requirement-catalog.mjs'; +import { MOSAIC_ROOT_COMMIT } from '../../seat/src/landing.mjs'; + +export function stackBinding({ business, project, root, tracker_project }) { + try { + if (!Number.isSafeInteger(tracker_project) || tracker_project < 1) return null; + const git = (...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root, ...args], { + encoding: 'utf8', maxBuffer: 1024 * 1024, timeout: 10000, stdio: ['ignore','pipe','pipe'], + env: { PATH: process.env.PATH, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_NO_REPLACE_OBJECTS: '1' }, + }); + git('merge-base', '--is-ancestor', MOSAIC_ROOT_COMMIT, 'HEAD'); + const identity = JSON.parse(git('show', 'HEAD:.mosaic/project.json')); + // Factory-derived products retain Stack history and files; the committed Stack identity must also match. + if (identity.projectVersion !== 1 || identity.id !== 'stack' || identity.id !== project) return null; + const catalog = loadRequirements(); + for (const [path, baked] of [['packages/tasks/requirements.json', readFileSync(REQUIREMENTS_FILE, 'utf8')], + [catalog.prd, readFileSync(new URL('../../../' + catalog.prd, import.meta.url), 'utf8')]]) { + if (git('ls-tree', 'HEAD', '--', path).split(' ')[0] !== '100644' || git('show', `HEAD:${path}`) !== baked) return null; + } + return { business, project, tracker_project }; + } catch { return null; } +} +export function builtinSnapshot(binding) { + const catalog = loadRequirements(); + const catalog_sha256 = createHash('sha256').update(readFileSync(REQUIREMENTS_FILE)).digest('hex'); + return { origin: 'builtin', id: `builtin-${catalog_sha256}`, business: binding.business, project: binding.project, ...catalog, catalog_sha256 }; +} + +// Host injection keeps Git/filesystem catalogue dependencies out of Broker's sandbox import closure. +export function createRequirementsHost({ store, businesses, builtinBinding = null, journal = createRequestStore(store) }) { + let builtin = null; + if (builtinBinding !== null) { + exact(builtinBinding, ['business','project','tracker_project']); + const entry = businesses[builtinBinding.business]?.projects?.[builtinBinding.project]; + if (!key(builtinBinding.business) || !key(builtinBinding.project) || !entry || entry.kind === 'library' || + !stackBinding({ ...builtinBinding, root: entry.root })) refuse('builtin-binding-refused'); + builtin = builtinSnapshot(builtinBinding); + } + const builtinFor = ({ business, project }) => builtin?.business === business && builtin.project === project ? structuredClone(builtin) : null; + return { journal, builtin, binding: builtinBinding && structuredClone(builtinBinding), builtinFor, + catalog: createRequirementCatalog({ journal, builtinBinding: builtinFor, readSpec: () => refuse('requirement-snapshot-required') }) }; +} diff --git a/packages/tasks/src/verbs.mjs b/packages/tasks/src/verbs.mjs index 8d1ac10d4..8990068ac 100644 --- a/packages/tasks/src/verbs.mjs +++ b/packages/tasks/src/verbs.mjs @@ -1,10 +1,9 @@ -import { randomUUID } from 'node:crypto'; import { reconcileCreate } from './create-recovery.mjs'; import { BusError, createMarker, createDigest } 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'; +import { discover, inspect } from './startup.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 @@ -67,8 +66,9 @@ 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 scopeArgs = plan => ({ request: plan.request, requirement: plan.requirement, + ...(plan.project === undefined ? {} : { project: plan.project }), + ...Object.fromEntries(['project_key','requirement_snapshot','op'].filter(k => plan[k] !== undefined).map(k => [k, plan[k]])) }); 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'); @@ -203,12 +203,13 @@ export function verbs(ctx) { switch (verb) { case 'task.create': { keys(args, ['title', 'description', 'due_date', 'priority', 'labels', 'relations', 'request', 'requirement', 'decision', 'project', 'op', 'project_key', 'requirement_snapshot'], ['title', 'request', 'requirement']); - // Mos's ruling: an optional project, which must be an installed project of the tree. Without - // it the task goes to the root. - const project = args.project === undefined ? ctx.project : args.project; - if (!Number.isSafeInteger(project) || project < 1) fail('invalid-request'); - if (!ctx.tree.get(project)) fail('project-not-installed'); - plan.at = { project, view: ctx.tree.get(project) }; + // The optional numeric project is only a board assertion. The broker resolves the target + // from the confirmed request (or the explicitly bound legacy Stack catalogue). + if (args.project !== undefined) { + if (!Number.isSafeInteger(args.project) || args.project < 1) fail('invalid-request'); + if (!ctx.tree.get(args.project)) fail('project-not-installed'); + } + plan.project = args.project; const body = { title: text(args.title, 250, 1) }; if (!body.title.trim()) fail('invalid-request'); if (args.description !== undefined) body.description = text(args.description, 65536); @@ -222,18 +223,11 @@ 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'); for (const field of ['op', 'project_key', 'requirement_snapshot']) if (args[field] !== undefined) { plan[field] = text(args[field], 256, 1); if (/[;\r\n]/.test(plan[field])) fail('invalid-request'); } - // Existing unscoped calls retain their established body and evidence. The new lifecycle - // opts into recovery with an op; scope-only callers get a generated one. - if (plan.op !== undefined || plan.project_key !== undefined || plan.requirement_snapshot !== undefined) { - plan.op ??= randomUUID(); - plan.body.description = (body.description ? body.description + '\n\n' : '') + createMarker(plan); - } return plan; } case 'task.assign': @@ -406,6 +400,7 @@ export function verbs(ctx) { op: plan.op, request: plan.request, requirement: plan.requirement, decision: plan.decision, project: plan.at.project, view: plan.at.view, creator: ctx.roles[me.role].botId, body: plan.body, body_digest: createDigest(plan.body), labels: plan.labels, relations: plan.relations, + evidence: plan.evidence, ...(plan.project_key === undefined ? {} : { project_key: plan.project_key }), ...(plan.requirement_snapshot === undefined ? {} : { requirement_snapshot: plan.requirement_snapshot }), }; @@ -413,6 +408,7 @@ export function verbs(ctx) { let taskRef; if (fresh) { await located(plan.others); + await admit(cap, plan); ctx.broker.prepareTaskCreate(cap, prepared); const r = await ctx.as(me, 'POST', `/projects/${plan.at.project}/tasks`, { body: plan.body }); if (outcome(r) !== 'ok' || ![200, 201].includes(r.status)) { @@ -441,11 +437,13 @@ export function verbs(ctx) { if (!after.task.done) { await located(plan.others); for (const l of plan.labels) if (!after.fields.labels.includes(l)) { + await admit(cap, plan); ctx.broker.prepareTaskCreate(cap, prepared); await step(me, 'POST', `/tasks/${at.id}/labels`, { label_id: l }, at, a => a.fields.labels.includes(l)); after = await read(at); } for (const x of plan.relations) if (!relatedTo(after.task, x.kind, x.other)) { + await admit(cap, plan); ctx.broker.prepareTaskCreate(cap, prepared); await step(me, 'POST', `/tasks/${at.id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at, a => relatedTo(a.task, x.kind, x.other)); @@ -457,10 +455,36 @@ export function verbs(ctx) { }] }); return { task_ref: taskRef, digest: after.digest, updated: after.updated }; } + async function admit(cap, plan) { + // Refresh tracker membership and installation after planning; a cached tree cannot admit a + // reparented/archived board. Reservation already precedes these reads on the recovery path. + const boardProject = ctx.broker.taskCreateTarget(cap, scopeArgs(plan)); + if (ctx.descendants) await discover(ctx); + installed(boardProject); + const control = await ctx.sync('GET', `/projects/${boardProject}`); + if (outcome(control) !== 'ok') fail(refusal(control)); + if (control.json?.id !== boardProject || control.json?.is_archived !== false) fail('task-project'); + const liveView = await inspect(ctx, boardProject); + if (createDigest(liveView) !== createDigest(plan.at.view)) fail('project-not-installed'); + // The catalog and session are the last slow check before the PM's authority admission. + const snapshot = await ctx.broker.requirementsFor(cap, scopeArgs(plan)); + const project = ctx.broker.taskCreateTarget(cap, scopeArgs(plan)); + const view = installed(project); + if (createDigest(snapshot) !== createDigest(plan.catalog) || project !== plan.at.project || createDigest(view) !== createDigest(plan.at.view)) fail('requirement-snapshot-refused'); + ctx.broker.identity(cap); + } async function create(cap, me, plan) { - if (!ctx.broker.taskView(ctx.business, 'input', plan.request)) fail('request-not-found'); + const project = ctx.broker.taskCreateTarget(cap, scopeArgs(plan)); + plan.at = { project, view: installed(project) }; + plan.catalog = await ctx.broker.requirementsFor(cap, scopeArgs(plan)); + const snap = plan.catalog; + plan.evidence = { scope: { project_key: snap.project, requirement_snapshot: snap.id, requirement: plan.requirement }, + prd_version: snap.prd_version ?? snap.version, + ...(snap.origin === 'product' ? { records_commit: snap.records_commit, spec_commit: snap.spec_commit } : { prd: snap.prd, catalog_sha256: snap.catalog_sha256 }) }; + if (plan.op !== undefined) plan.body.description = (plan.body.description ? plan.body.description + '\n\n' : '') + createMarker(plan); if (plan.op !== undefined) return recoverableCreate(cap, me, plan); await located(plan.others); + await admit(cap, plan); ctx.broker.authorize(cap, 'task.create', { decision: plan.decision, target: null }); // No re-read can find a create whose answer was lost, so an uncertain create is reported as such. const r = await step(me, 'POST', `/projects/${plan.at.project}/tasks`, plan.body, null, null); @@ -499,7 +523,7 @@ export function verbs(ctx) { snapshots: after ? [{ task_ref: taskRef, updated: after.updated, etag: after.etag ?? null, digest: after.digest, fields: after.fields }] : [], - events: [{ kind: 'task.created', subject: taskRef, body: { request: plan.request, requirement: plan.requirement } }], + events: [{ kind: 'task.created', subject: taskRef, body: { request: plan.request, requirement: plan.requirement, ...plan.evidence } }], }); if (failure) throw failure; return { task_ref: taskRef, digest: after.digest, updated: after.updated }; diff --git a/packages/tasks/tests/create-recovery.test.mjs b/packages/tasks/tests/create-recovery.test.mjs index f5631050d..8813e733e 100644 --- a/packages/tasks/tests/create-recovery.test.mjs +++ b/packages/tasks/tests/create-recovery.test.mjs @@ -22,25 +22,24 @@ async function fixture(t, edit = {}) { return { w, pm, args, create, posts }; } -test('lost response after tracker create is adopted once with original attribution and optional scope', async t => { +test('lost response after tracker create is adopted once with original attribution and bound Stack catalogue', async t => { const { w, args, create, posts } = await fixture(t); w.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 0, { apply: true }); - const scope = { project_key: 'demo/product', requirement_snapshot: 'snapshot-v7' }; - await assert.rejects(create(scope), uncertain); + await assert.rejects(create(), uncertain); assert.equal(posts(), 1); assert.equal(w.events('task.created').length, 0); - assert.equal(w.fake.task(1).description, `original body\n\nMosaic operation: create-5a; request: ${args.request}; scope: demo/product; snapshot: snapshot-v7`); + assert.equal(w.fake.task(1).description, `original body\n\nMosaic operation: create-5a; request: ${args.request}`); await w.stop(); await w.restart(); const [created] = w.events('task.created'); assert.equal(created.subject, `vikunja:${w.project}/1`); assert.equal(created.actor_role, 'pm'); assert.equal(created.actor_run, 'pm-run'); - assert.equal(created.body.project_key, 'demo/product'); - assert.equal(created.body.requirement_snapshot, 'snapshot-v7'); + assert.equal(created.body.scope.project_key, 'stack'); + assert.match(created.body.scope.requirement_snapshot, /^builtin-/); assert.equal(created.body.op, args.op); await w.adapter.reconcileCreates('demo'); - const made = await create(scope, resumePm(w)); + const made = await create({}, resumePm(w)); assert.equal(made.task_ref, created.subject); assert.equal(w.events('task.created').length, 1); assert.equal(posts(), 1); diff --git a/packages/tasks/tests/scoped-fixture.mjs b/packages/tasks/tests/scoped-fixture.mjs new file mode 100644 index 000000000..3833ff7cd --- /dev/null +++ b/packages/tasks/tests/scoped-fixture.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { startBroker } from '../../bus/src/runtime.mjs'; +import { bootConfig, loadSystem } from '../../cli/src/config.mjs'; +import { fixture, tmp } from '../../cli/tests/helpers.mjs'; +import { prdFixture, git } from '../../project/tests/prd-fixture.mjs'; +import { FakeVikunja } from '../src/fake.mjs'; +import { tasksAdapter } from '../src/adapter.mjs'; +import { SCOPES } from './world.mjs'; + +// Real product repositories, approval decisions and file-backed bus; only tracker HTTP is fake. +export async function scopedFixture(t) { + const root = tmp(t, 'scoped-'), f = fixture(root, 'demo'); + const source = join(root, 'project'); mkdirSync(join(source, '.mosaic'), { recursive: true }); + writeFileSync(join(source, '.mosaic/project.json'), JSON.stringify({ projectVersion: 1, id: 'stack', vars: { 'tracker.project': 1, 'tracker.descendants': true } })); + git(source, 'init', '-qb', 'main'); git(source, 'add', '-A'); git(source, 'commit', '-qm', 'Factory'); + let config = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'demo', env: f.env }); + const fake = new FakeVikunja(), owner = fake.user('owner'), bots = {}; + for (let i = 2; i <= 9; i++) { + const role = ({3:'pm',4:'cto',5:'coder',6:'reviewer',9:'sync'})[i]; + const id = fake.user(role ?? `unused${i}`, { owner }); if (role) bots[role] = id; + } + const board = fake.project('Root', owner), child = fake.project('Products', owner, { parent: board }); + for (const p of [board, child]) { fake.install(p); for (const [role, bot] of Object.entries(bots)) fake.share(p, bot, role === 'sync' ? 0 : 1); } + const tokens = Object.fromEntries(Object.entries(bots).map(([role, bot]) => [role, fake.token(bot, role === 'sync' + ? { ...SCOPES.sync, projects: [...SCOPES.sync.projects, 'read_all'] } : role === 'pm' ? SCOPES.pm : SCOPES.worker)])); + const credentials = { status: () => Object.keys(bots).map(role => ({ service: 'vikunja', instance: `demo/${role === 'sync' ? '@sync' : role}`, state: 'valid' })), + use: (instance, service, fn) => fn(tokens[instance.split('/')[1].replace('@','')]), useFor: (s, service, fn) => fn(tokens[s.role]) }; + const factory = tasksAdapter({ trackers: { demo: { baseUrl: 'http://vikunja.test', project: board, descendants: true } }, fetch: fake.fetch, autostart: false, log: () => {} }); + let bus, adapter, generation = 0; + const caps = {}; + const boot = async () => { + config = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'demo', env: f.env }); + bus = await startBroker(config); generation++; + adapter = await factory({ broker: bus.broker, credentials, businesses: config.businesses }); + await adapter.start('demo'); assert.equal(adapter.status()[0].state, 'ready'); + caps.human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + caps.console = bus.broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } }); + for (const role of ['pm','cto','coder','reviewer']) { + if (generation > 1) bus.broker.recoverLaunch({ business: 'demo', role, run: `${role}-run-${generation - 1}`, harness: 'pi' }); + caps[role] = bus.broker.bindLaunch({ business: 'demo', role, run: `${role}-run-${generation}`, harness: 'pi' }); + bus.broker.request(caps[role], { verb: 'role.claim' }); + } + }; + const close = async () => { await adapter?.close(); await bus?.close(); }; + t.after(close); await boot(); + const call = (role, verb, args = {}) => bus.broker.request(caps[role], { verb, args }); + const instruction = () => call('human', 'message.send', { to: 'pm', body: 'Build a product.' }).request; + const product = async (project, { approve = true, activate = true, tracker_project = child } = {}) => { + const request = instruction(); let state = await call('pm', 'request.open', { request }); + state = await call('pm', 'request.propose', { request, revision: state.revision, op: 'plan', source_project: 'stack', project, name: project, tracker_project }); + call('human', 'decision.resolve', { id: state.selection.decision, choice: 'autonomous' }); + state = await bus.requestLifecycle.resume('demo', request); + const p = await prdFixture(t, { dataRoot: f.dataRoot, business: { id: 'demo', human: 'jason', projects: { [project]: { root: join(f.dataRoot, 'products/demo', project, 'code') } } }, project }); + const requirement = 'REQ-POMO-1'; + const records_commit = p.save(`requirements/${requirement}.json`, JSON.stringify({ recordVersion: 1, id: requirement, title: 'Timer', text: `${project} timer behavior.`, status: 'draft' }), 'pm.pm'); + const submission = await call('pm', 'prd.submit', { request, revision: state.revision, op: 'prd', prd_version: '1.0', records_commit, prd_path: p.args.prd_path, requirement_ids: [requirement] }); + if (approve) call('cto', 'decision.resolve', { id: submission.decision.id, choice: 'yes' }); + const snapshot = approve && activate ? await bus.requirementCatalog.activate({ request, decision: submission.decision.id }) : null; + return { ...p, request, submission, snapshot, args: { title: project, request, requirement, project_key: project, requirement_snapshot: snapshot?.id ?? 'unapproved', op: 'create-' + project } }; + }; + const create = args => bus.broker.requestTask(caps.pm, { verb: 'task.create', args }, adapter.handle); + const posts = () => fake.requests.filter(r => r.method === 'POST' && /^\/projects\/\d+\/tasks$/.test(r.path)).length; + return { f, get config() { return config; }, fake, board, child, caps, call, product, create, instruction, posts, + get bus() { return bus; }, get adapter() { return adapter; }, + restart: async () => { await close(); await boot(); } }; +} diff --git a/packages/tasks/tests/scoped-requirements.test.mjs b/packages/tasks/tests/scoped-requirements.test.mjs new file mode 100644 index 000000000..542dbd2f9 --- /dev/null +++ b/packages/tasks/tests/scoped-requirements.test.mjs @@ -0,0 +1,159 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { scopedFixture } from './scoped-fixture.mjs'; +import { world } from './world.mjs'; +import { loadRequirements } from '../src/requirements.mjs'; + +const scope = p => ({ project_key: p.args.project_key, requirement_snapshot: p.snapshot.id }); +const created = (f, task) => f.call('pm', 'trail', { subject: task.task_ref }).find(e => e.kind === 'task.created').body; + +test('two products on one board share an ID but use exact approved snapshots in discovery, create, filtering and trail', async t => { + const f = await scopedFixture(t), a = await f.product('timer'), b = await f.product('clock'); + const tasks = []; + for (const p of [a,b]) { + assert.deepEqual(await f.call('pm', 'requirements', scope(p)), p.snapshot); + assert.deepEqual(await f.bus.broker.requirementsFor(f.caps.pm, { ...scope(p), requirement: p.args.requirement }), p.snapshot); + const task = await f.create(p.args); tasks.push(task); + const body = created(f, task); + assert.deepEqual(body.scope, { ...scope(p), requirement: p.args.requirement }); + for (const k of ['prd_version','records_commit','spec_commit']) assert.equal(body[k], p.snapshot[k]); + assert.equal(body.op, p.args.op); + assert.equal(f.fake.task(Number(task.task_ref.split('/')[1])).project, f.child); + const filtered = f.call('pm', 'tasks', { project_key: p.args.project_key, requirement: p.args.requirement }); + assert.deepEqual(filtered.tasks.map(t => t.task_ref), [task.task_ref]); + assert.equal(filtered.tasks[0].requirement_snapshot, p.snapshot.id); + } + assert.notEqual(a.snapshot.id, b.snapshot.id); + assert.notEqual(a.snapshot.records_commit, b.snapshot.records_commit); + assert.notEqual(a.snapshot.requirements[0].text, b.snapshot.requirements[0].text); assert.equal(f.posts(), 2); + await f.restart(); + assert.deepEqual(await f.call('pm', 'requirements', scope(a)), a.snapshot); + assert.equal((await f.create(a.args)).task_ref, tasks[0].task_ref); assert.equal(f.posts(), 2); +}); + +test('product calls require all scope fields and cannot borrow another project, board, draft or revoked snapshot', async t => { + const f = await scopedFixture(t), p = await f.product('timer'), other = await f.product('clock'), draft = await f.product('draft', { approve: false }); + for (const field of ['project_key','requirement_snapshot','op']) { + const args = { ...p.args }; delete args[field]; await assert.rejects(f.create(args), { code: 'task-scope-required' }); + } + await assert.rejects(f.create({ ...p.args, project_key: 'clock' }), { code: 'request-project-refused' }); + await assert.rejects(f.create({ ...p.args, requirement_snapshot: other.snapshot.id })); + await assert.rejects(f.create({ ...p.args, project: f.board }), { code: 'request-tracker-refused' }); + await assert.rejects(f.create(draft.args)); + const state = await f.call('pm', 'request.get', { request: p.request }); + await f.call('console', 'prd.revoke.console', { project: 'timer', snapshot: p.snapshot.id, revision: 1, reason: 'Replace', op: 'revoke' }); + await assert.rejects(f.create(p.args)); assert.equal(f.posts(), 0); + assert.equal(state.phase, 'planning'); +}); + +test('selection, discovery and ambiguous requests cannot evade product approval by omitting scope', async t => { + const f = await scopedFixture(t); + const request = f.instruction(); await f.call('pm', 'request.open', { request }); + await assert.rejects(f.create({ title: 'selection', request, requirement: 'REQ-TASK-1' }), { code: 'task-scope-required' }); + const draft = await f.product('draft', { approve: false }); + f.call('cto', 'decision.resolve', { id: draft.submission.decision.id, choice: 'no' }); + await f.bus.requestLifecycle.resume('demo', draft.request); + await assert.rejects(f.create({ title: 'discovery', request: draft.request, requirement: 'REQ-TASK-1' }), { code: 'task-scope-required' }); + await assert.rejects(f.create({ title: 'ambiguous', request: f.instruction(), requirement: 'REQ-TASK-1', project_key: 'draft', requirement_snapshot: 'unknown', op: 'ambiguous' })); + assert.equal(f.posts(), 0); +}); + +test('admission repeats the catalog and session/authority checks after planning and relation reads', async t => { + const f = await scopedFixture(t), p = await f.product('timer'); + const original = f.bus.broker.requirementsFor.bind(f.bus.broker); let calls = 0; + f.bus.broker.requirementsFor = async (...args) => { + calls++; const snapshot = await original(...args); + if (calls === 1) await f.call('console', 'prd.revoke.console', { project: 'timer', snapshot: p.snapshot.id, revision: 1, reason: 'Revoke during planning', op: 'race' }); + return snapshot; + }; + await assert.rejects(f.create(p.args)); assert.ok(calls >= 2); assert.equal(f.posts(), 0); +}); + +test('legacy response is unchanged but creation requires an explicitly bound validated Stack project', async t => { + const w = await world(t); await w.adapter.start('demo'); const pm = w.agent('pm'); + assert.deepEqual(w.broker.request(pm, { verb: 'requirements' }), loadRequirements()); + const result = await w.call(pm, 'task.create', { title: 'legacy', request: w.instruction(), requirement: 'REQ-TASK-1' }); + const body = w.events('task.created')[0].body; + assert.equal(body.scope.project_key, 'stack'); assert.equal(body.prd_version, loadRequirements().version); + assert.match(body.catalog_sha256, /^[a-f0-9]{64}$/); assert.equal(body.approval, undefined); + assert.ok(result.task_ref); + const f = await scopedFixture(t); + await assert.rejects(f.create({ title: 'wrong Stack identity', request: f.instruction(), requirement: 'REQ-TASK-1' }), { code: 'requirement-snapshot-required' }); +}); + +test('a product removed from the installed tree after planning cannot create on the old board', async t => { + const f = await scopedFixture(t), p = await f.product('timer'); + const original = f.bus.broker.requirementsFor.bind(f.bus.broker); let planned = false; + f.bus.broker.requirementsFor = async (...args) => { + const snapshot = await original(...args); + if (!planned) { planned = true; f.fake.reparent(f.child, 0); } + return snapshot; + }; + await assert.rejects(f.create(p.args), { code: 'task-project' }); assert.equal(f.posts(), 0); +}); + +for (const change of ['authority','session']) test(`create admission rechecks ${change} after planning`, async t => { + const f = await scopedFixture(t), p = await f.product('timer'); + const original = f.bus.broker.requirementsFor.bind(f.bus.broker); let planned = false; + f.bus.broker.requirementsFor = async (...args) => { + const snapshot = await original(...args); + if (!planned) { + planned = true; + if (change === 'session') f.call('pm', 'role.release'); + else { + const b = structuredClone(f.config.businesses.demo); + b.projects.timer = { root: p.context.business.projects.timer.root }; + b.roles.pm.authority.withinRole = b.roles.pm.authority.withinRole.filter(a => a !== 'task.create'); + f.bus.broker.reloadBusiness('demo', b); + } + } + return snapshot; + }; + await assert.rejects(f.create(p.args), { code: change === 'authority' ? 'decision-required' : 'not-holder' }); + assert.equal(f.posts(), 0); +}); + +test('bootConfig binds only the proven Stack checkout and a scoped caller never borrows its builtin snapshot', async t => { + const { fileURLToPath } = await import('node:url'); + const { writeFileSync } = await import('node:fs'); + const { fixture, tmp } = await import('../../cli/tests/helpers.mjs'); + const { bootConfig, loadSystem } = await import('../../cli/src/config.mjs'); + const f = fixture(tmp(t, 'builtin-'), 'demo'); + f.doc.projects.stack.root = fileURLToPath(new URL('../../../', import.meta.url)); + writeFileSync(f.env.MOSAIC_CONFIG.replace('/config.json','/businesses/demo.json'), JSON.stringify(f.doc)); + const config = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'demo', env: f.env }); + assert.deepEqual(config.builtinBinding, { business: 'demo', project: 'stack', tracker_project: 19 }); + const w = await world(t); await w.adapter.start('demo'); const pm = w.agent('pm'); + const builtin = await w.broker.requirementsFor(pm, { request: w.instruction(), requirement: 'REQ-TASK-1' }); + await assert.rejects(w.broker.requirementsFor(pm, { project_key: 'stack', requirement_snapshot: builtin.id }), { code: 'requirement-snapshot-required' }); + const unbound = await world(t, { builtinBinding: false }); await unbound.adapter.start('demo'); + await assert.rejects(unbound.call(unbound.agent('pm'), 'task.create', { title: 'unbound', request: unbound.instruction(), requirement: 'REQ-TASK-1' }), { code: 'requirement-snapshot-required' }); +}); + + +test('lost product create response survives Store reopen with exact scope and original attribution', async t => { + const f = await scopedFixture(t), p = await f.product('timer'); + f.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 0, { apply: true }); + await assert.rejects(f.create(p.args), { code: 'task-create-uncertain' }); assert.equal(f.posts(), 1); + assert.ok(f.fake.task(1).description.endsWith(`Mosaic operation: ${p.args.op}; request: ${p.request}; scope: timer; snapshot: ${p.snapshot.id}`)); + await f.restart(); + const made = await f.create(p.args); + const trail = f.call('pm', 'trail', { subject: made.task_ref }).filter(e => e.kind === 'task.created'); + assert.equal(trail.length, 1); assert.equal(trail[0].actor_run, 'pm-run-1'); + assert.deepEqual(trail[0].body.scope, { ...scope(p), requirement: p.args.requirement }); + assert.equal(trail[0].body.records_commit, p.snapshot.records_commit); assert.equal(f.posts(), 1); +}); + +test('a product derived from Stack history cannot claim the builtin catalogue as its own', async t => { + const { tmp } = await import('../../cli/tests/helpers.mjs'); + const { git } = await import('../../project/tests/prd-fixture.mjs'); + const { fileURLToPath } = await import('node:url'); + const { writeFileSync } = await import('node:fs'); + const { stackBinding } = await import('../src/builtin.mjs'); + const root = tmp(t, 'derived-'), clone = root + '/code'; + git(root, 'clone', '-q', '--shared', '--no-checkout', fileURLToPath(new URL('../../../', import.meta.url)), clone); + git(clone, 'checkout', 'HEAD', '--', '.mosaic/project.json', 'packages/tasks/requirements.json', 'docs/prd/mosaic-stack.md'); + writeFileSync(clone + '/.mosaic/project.json', JSON.stringify({ projectVersion: 1, id: 'timer', vars: { 'tracker.project': 19 } })); + git(clone, 'add', '.mosaic/project.json'); git(clone, 'commit', '-qm', 'Product identity'); + assert.equal(stackBinding({ business: 'demo', project: 'timer', root: clone, tracker_project: 19 }), null); +}); diff --git a/packages/tasks/tests/verbs.test.mjs b/packages/tasks/tests/verbs.test.mjs index 91b26ec8b..f0ad58857 100644 --- a/packages/tasks/tests/verbs.test.mjs +++ b/packages/tasks/tests/verbs.test.mjs @@ -29,7 +29,7 @@ test('task.create needs a recorded human request and a requirement id', async (t assert.equal(r.task_ref, `vikunja:${w.project}/1`); const created = w.events('task.created'); assert.equal(created.length, 1); - assert.deepEqual(created[0].body, { request: args.request, requirement: 'REQ-TASK-1' }); + assert.deepEqual({ request: created[0].body.request, requirement: created[0].body.requirement }, { request: args.request, requirement: 'REQ-TASK-1' }); assert.equal(created[0].actor_role, 'pm'); const [s] = w.snapshots(r.task_ref); assert.equal(s.source, 'self'); @@ -78,7 +78,7 @@ test("task.create cites the request a received message carries, not the message' const args = { title: 'the thing', requirement: 'REQ-TASK-1' }; await assert.rejects(w.call(pm, 'task.create', { ...args, request: m.id }), refusedWith('request-not-found')); const r = await w.call(pm, 'task.create', { ...args, request: m.request }); - assert.deepEqual(w.events('task.created').map((e) => [e.subject, e.body]), [[r.task_ref, { request: m.request, requirement: 'REQ-TASK-1' }]]); + assert.deepEqual(w.events('task.created').map((e) => [e.subject, { request: e.body.request, requirement: e.body.requirement }]), [[r.task_ref, { request: m.request, requirement: 'REQ-TASK-1' }]]); }); test('only labels named in the business file can be written', async (t) => { const w = await ready(t); @@ -239,13 +239,13 @@ test('task.close needs a verdict; after it every verb refuses with task-done', a }); test('a task with pushed work closes only citing an approve verdict for its latest push, and a refusal writes nothing', async (t) => { // Row 93 (#1587): the verb hands its verdict to the broker's check. - const w = await ready(t, { business: (b) => (b.projects = { stack: { root: '/nonexistent' } }) }); + const w = await ready(t); const pm = w.agent('pm'); const coder = w.agent('coder'); const reviewer = w.agent('reviewer'); const { task_ref } = await task(w); const push = (commit, previous) => { - w.broker.checkPush(coder, task_ref); + w.broker.checkPush(coder, task_ref, 'stack'); w.broker.recordPush(coder, { task_ref, project: 'stack', branch: 'feat', ref: 'work/vikunja-1-1', commit, previous }); }; const verdict = (commit, v) => { @@ -479,11 +479,17 @@ const TREE = { tracker: { descendants: true }, scopes: { sync: { ...SCOPES.sync, projects: [...SCOPES.sync.projects, 'read_all'] } }, }; -const tree = async (t, edit = {}) => { - const w = await world(t, { ...TREE, ...edit }); - const child = w.fake.project('Child', w.owner, { parent: w.project }); - const board = w.fake.install(child); - const grandchild = w.fake.project('Grandchild', w.owner, { parent: child }); +const tree = async (t, edit = {}, boundChild = false) => { + let child, board, grandchild; + const w = await world(t, { ...TREE, ...edit, + ...(boundChild ? { builtinBinding: () => child } : {}), + business: (b, { fake, owner, project }) => { + edit.business?.(b, { fake, owner, project }); + child = fake.project('Child', owner, { parent: project }); + board = fake.install(child); + grandchild = fake.project('Grandchild', owner, { parent: child }); + }, + }); const watched = (await w.ui('POST', `/projects/${grandchild}/tasks`, { title: 'watched' })).json.id; await w.adapter.start('demo'); assert.equal(w.adapter.status()[0].state, 'ready', w.adapter.status()[0].refused); @@ -491,11 +497,12 @@ const tree = async (t, edit = {}) => { }; const writes = (w) => w.fake.requests.filter((r) => r.method !== 'GET').length; test('every verb acts in an installed child, on the child\'s own board', async (t) => { - const { w, child, board } = await tree(t); + const { w, child, board } = await tree(t, {}, true); const pm = w.agent('pm'); const coder = w.agent('coder'); const cto = w.agent('cto'); - const root = await task(w); + // Stack is explicitly bound to the installed child for this fixture. The root task belongs to the tracker UI. + const root = { task_ref: `vikunja:${w.project}/${(await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'root' })).json.id}` }; assert.equal(root.task_ref, `vikunja:${w.project}/2`); const made = await task(w, { project: child, relations: [{ kind: 'blocking', task_ref: root.task_ref }] }); const task_ref = `vikunja:${child}/3`; @@ -528,8 +535,8 @@ test('every verb acts in an installed child, on the child\'s own board', async ( assert.equal(w.snapshots(task_ref).at(-1).fields.project_id, child); assert.equal(w.snapshots(task_ref).at(-1).fields.bucket, board.buckets.todo); assert.equal(w.events('task.created').at(-1).subject, task_ref); - // An explicit root project is the default spelled out. - assert.equal((await task(w, { project: w.project })).task_ref, `vikunja:${w.project}/4`); + // Unscoped Stack calls cannot select another board by number. + await assert.rejects(task(w, { project: w.project }), refusedWith('request-tracker-refused')); await w.call(pm, 'task.assign', { task_ref, role: 'coder' }); await assert.rejects(w.call(pm, 'task.reassign', { task_ref, role: 'coder' }), refusedWith('task-assigned')); assert.deepEqual(w.fake.task(3).assignees, [w.bots.coder]); @@ -608,12 +615,12 @@ test('a related task is read before authority: in another project than its ref n w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { remove: [{ kind: 'related', task_ref: claimed }] } }), refusedWith('task-moved'), ); - await assert.rejects(task(w, { project: child, relations: [{ kind: 'related', task_ref: claimed }] }), refusedWith('task-moved')); + await assert.rejects(task(w, { relations: [{ kind: 'related', task_ref: claimed }] }), refusedWith('task-moved')); await assert.rejects(task(w, { relations: [{ kind: 'related', task_ref: `vikunja:${child}/77` }] }), refusedWith('task-not-found')); assert.equal(writes(w), before); assert.equal(allowed(w, 'task.schedule').length + allowed(w, 'task.create').length, 1); // The same relation with the right ref goes through. - const other = await task(w, { project: child }); + const other = { task_ref: `vikunja:${child}/${(await w.ui('POST', `/projects/${child}/tasks`, { title: 'child UI task' })).json.id}` }; await w.call(pm, 'task.schedule', { task_ref: root.task_ref, relations: { add: [{ kind: 'related', task_ref: other.task_ref }] } }); assert.deepEqual(w.fake.task(id).relations, [{ kind: 'related', other: Number(other.task_ref.split('/')[1]) }]); }); @@ -633,7 +640,7 @@ test('a task moved to another project of the tree refuses its old ref with task- // The child versions of the re-read and fallback paths the root's tests above cover (Dewey's // and Filbert's round 1 reviews): each fails if its path goes back to the root's project or view. test('a verb and a create in the child record the final read of the child\'s board', async (t) => { - const { w, child } = await tree(t); + const { w, child } = await tree(t, {}, true); const pm = w.agent('pm'); const made = await task(w, { project: child }); assert.notEqual(w.snapshots(made.task_ref).at(-1).etag, null); @@ -643,7 +650,7 @@ test('a verb and a create in the child record the final read of the child\'s boa test('a create in the child whose final read fails records the child\'s todo bucket', async (t) => { // The grandchild's task is 1, so the create makes task 2. const f = thenFailRead((m, p) => m === 'POST' && /\/projects\/\d+\/tasks$/.test(p), 2); - const { w, child, board } = await tree(t, f.edit); + const { w, child, board } = await tree(t, f.edit, true); w.agent('pm'); f.arm(); const r = await task(w, { project: child }); @@ -659,7 +666,7 @@ test('a create in the child whose final read fails records the child\'s todo buc assert.deepEqual(w.events('task.changed.external').map((e) => e.subject), [`vikunja:${w.fake.task(1).project}/1`]); }); test('a lost answer in the child is settled by a re-read of the child\'s board', async (t) => { - const { w, child, board } = await tree(t); + const { w, child, board } = await tree(t, {}, true); const pm = w.agent('pm'); const coder = w.agent('coder'); const { task_ref } = await task(w, { project: child }); diff --git a/packages/tasks/tests/world.mjs b/packages/tasks/tests/world.mjs index 3fa6c68e3..9d7b13746 100644 --- a/packages/tasks/tests/world.mjs +++ b/packages/tasks/tests/world.mjs @@ -1,7 +1,9 @@ import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs'; import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; import { join } from 'node:path'; import { Store } from '../../bus/src/store.mjs'; +import { createRequirementsHost } from '../src/builtin.mjs'; import { Broker } from '../../bus/src/broker.mjs'; import { Credentials } from '../../bus/src/credentials.mjs'; import { FakeVikunja } from '../src/fake.mjs'; @@ -93,6 +95,7 @@ export async function world(t, edit = {}) { human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles, + projects: { stack: { root: fileURLToPath(new URL('../../../', import.meta.url)) } }, tracker: { sync: { bot: 'bot-demo-sync', botId: bots.sync, credentials: { vikunja: ref('sync') } }, labels: { 'slice-1': label, ...edit.labels }, @@ -104,8 +107,10 @@ export async function world(t, edit = {}) { for (const [r, x] of Object.entries(roles)) if (x.credentials) references['demo/' + r] = x.credentials; if (businesses.demo.tracker.sync.credentials) references['demo/@sync'] = businesses.demo.tracker.sync.credentials; const credentials = new Credentials({ references, env, dataRoot: root }); + const builtinBinding = edit.builtinBinding === false ? null : { business: 'demo', project: 'stack', + tracker_project: typeof edit.builtinBinding === 'function' ? edit.builtinBinding() : project }; let store = new Store(root); - let broker = new Broker({ store, businesses, secretCheck: (v) => credentials.assertClean(v) }); + let broker = new Broker({ store, businesses, requirementsHost: createRequirementsHost({ store, businesses, builtinBinding }), secretCheck: (v) => credentials.assertClean(v) }); const log = []; const factory = tasksAdapter({ trackers: { demo: { baseUrl: 'http://vikunja.test', project, ...edit.tracker } }, @@ -151,7 +156,7 @@ export async function world(t, edit = {}) { }, async restart() { store = new Store(root); - broker = new Broker({ store, businesses, secretCheck: (v) => credentials.assertClean(v) }); + broker = new Broker({ store, businesses, requirementsHost: createRequirementsHost({ store, businesses, builtinBinding }), secretCheck: (v) => credentials.assertClean(v) }); adapter = await factory({ broker, credentials, businesses }); await adapter.start('demo'); if (adapter.status()[0].state !== 'ready') throw Error(adapter.status()[0].refused);