bus, project: file-backed north-star specs with spec.read and record.write (row 138, #1632)

Row 138 round 2, Rocko's candidate e3f9a400 (43 paths) on 26a0b0fab.
Dewey approved (comment 28754) after round 1 changes (comment 28752):
D1-D5 mutants killed, the 17 touched test files run singly passed
124/124. Rocko's own run: 753 tests and 59/59 mutants killed.

The bus gains the project adapter through runtime.mjs, the way it
already injects the organization, so broker.mjs takes no new imports
and refuses with project-not-configured when no adapter is set. The
project socket timeout is 120s. Roles gain record.write. The schema
moves from 3e to 3f; the live migration follows MIGRATION.md and
needs Mos's window.

Sage's landing gate ran on c842686d5 plus the candidate: 25 steps,
all rc 0 (15 packages, 10 script suites), network sage-r138_default
removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-15 20:49:44 -05:00
co-authored by Claude Opus 5.5
parent 9cdf444c5a
commit 3190ad762b
43 changed files with 1234 additions and 60 deletions
+45 -3
View File
@@ -590,10 +590,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
## Schema and verification
`schema.sql` is v3e, SHA-256
`21e6c091f14f818396f4d7836f381cc1389d45b2680ecaa1a00646b652c45050`.
`schema.sql` is v3f, SHA-256
`472cbcd0d4548848873f3029e8250dc07bba4fb8db2a93241aae5b8e7e3771e2`.
It has 14 tables, 64 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3e`, WAL,
sqlite_master digest are checked at open, alongside schema version `3f`, WAL,
quick_check and foreign-key integrity. Missing metadata refuses; it never
reinitializes an existing file. Full synchronous WAL transactions either commit
or roll back. SQL UPDATE, DELETE and INSERT OR REPLACE refuse on evidence tables.
@@ -658,3 +658,45 @@ core; an indexed/insert-scoped optimization requires equivalent checks and tests
The schema itself remains byte-identical to v3b. S3 must normalize service read
start times to this form before storing snapshots. External service `updated`
is not a broker timestamp; its interpretation remains S3's responsibility.
## Project records and file-backed north-star specs (row 138)
The business's declared project resolves to its separate local records Git
repository (packages/project). `spec.read {project,commit?}` is available to a
held role instance, scoped to its business. It returns the committed
spec/north-star.md, its last file-changing commit, confirmation flag and up to
100 path-history entries with truncated=true if more exist. Optional commit is
a full reachable SHA-1; arbitrary revision expressions refuse. Uncommitted bytes
are ignored. Only author.name equal to business.human confirms a spec; a latest
agent-authored change is visible but unconfirmed, with no fallback to an older
human version. A later docs commit does not change the spec's file commit.
`decision.raise` optionally takes project, spec_path (spec/north-star.md) and
spec_commit together. The broker reads the current committed snapshot and
refuses a different commit (stale-spec), unconfirmed file (spec-unconfirmed),
or incomplete citation (invalid-spec-citation). Missing files and unsafe
repositories retain the project reader's refusal codes. Uncited raises keep the
existing synchronous validity and routing rules. Decisions store spec_path and
spec_commit; later Git commits never reopen or rewrite settled decisions.
`record.write {project,path,record,decision?}` uses the shared package writer.
It checks record.write authority, validates data and binds the author to the
session's definition.instance. No caller author/business identity is accepted.
Only docs Markdown and requirement/decision JSON are writable. spec/ always
refuses project-spec-human-only. The repository's exclusive writer lock refuses
concurrent writes with project-busy before they contend for Git's index lock.
All eleven v2 role definitions grant record.write within-role. Legacy researcher
(v1) and conductor-policy are unchanged. Narrowing limits still apply.
Git awaits run outside Store transactions. File commits and SQL receipts are
two effects: a commit followed by failure to record its authority receipt refuses
outcome-unknown and must be inspected, never blindly retried. The unchanged
socket request envelope is 64 KiB; package file validation remains 1 MiB. The server
awaits only spec.read, record.write and cited decision.raise and holds any
transient proof until completion. Other request paths stay synchronous.
The 3e→3f offline step adds nullable spec_path/spec_commit columns to decisions,
with nulls on existing rows. It changes no event or resolution and creates no
spec/confirmation tables. The 3e fixture is pinned from 26a0b0fab's tree; no live
store is used. Mos runs this step only in an approved stopped-bus/re-pin window,
as described in the candidate packet's MIGRATION.md.
+1 -1
View File
@@ -58,7 +58,7 @@ CREATE TABLE decisions (
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
supersedes TEXT REFERENCES decisions(id)
) STRICT;
, spec_path TEXT, spec_commit TEXT) STRICT;
CREATE TABLE decision_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
decision TEXT NOT NULL REFERENCES decisions(id),
+62 -3
View File
@@ -16,6 +16,7 @@ export const ACTIONS = Object.freeze([
'git.merge.protected',
'review.request',
'review.verdict',
'record.write',
'message.send',
'message.external',
'role.launch',
@@ -139,15 +140,17 @@ export class Broker {
#credentialStatus;
#requirements;
#organization;
#project;
#clock = 0;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
this.#businesses = structuredClone(businesses);
this.#store = store;
this.#secretCheck = secretCheck;
this.#credentialStatus = credentialStatus;
this.#organization = organization;
this.#project = project;
// Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here.
this.#requirements = loadRequirements();
for (const table of [
@@ -881,6 +884,9 @@ export class Broker {
const args = request.args ?? {};
if (!object(args)) fail('invalid-request');
this.#secretCheck(args);
if (request.verb === 'spec.read' || request.verb === 'record.write' ||
(request.verb === 'decision.raise' && ('spec_path' in args || 'spec_commit' in args)))
return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) });
if (["limits", "limits.set", "organization.set", "plugins", "plugins.set"].includes(request.verb)) {
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
// file lists organization.change. It takes the same keys, runs the same write path and
@@ -929,6 +935,54 @@ export class Broker {
throw this.#refused(s, e, request);
}
}
// Git and the record writer are async effects: never hold a SQL transaction across an await.
async #requestProject(s, request) {
const { verb, args } = request;
let ProjectFailure;
try {
this.#store.transaction(() => this.#agent(s));
if (!this.#project) fail('project-not-configured');
const { readSpec, writeRecord, ProjectError } = this.#project;
ProjectFailure = ProjectError;
const business = this.#business(s.business);
const context = { business, project: args.project };
if (verb === 'spec.read') {
keys(args, ['project', 'commit'], ['project']);
const result = await readSpec({ ...context, ...(args.commit === undefined ? {} : { commit: args.commit }) });
this.#store.transaction(() => this.#agent(s));
this.#secretCheck(result);
return result;
}
if (verb === 'record.write') {
keys(args, ['project', 'path', 'record', 'decision'], ['project', 'path', 'record']);
const authority = { decision: args.decision ?? null, target: args.project };
this.#store.transaction(() => this.#checkAuthority(s, 'record.write', authority));
const author = { definition: business.roles[s.role].definition, instance: s.role };
// writeRecord takes the repository's exclusive lock before its first await. A concurrent
// write to this repository refuses project-busy, including from another bus request.
const result = await writeRecord({ ...context, path: args.path, record: args.record, author });
try {
this.#store.transaction(() => this.#consumeAuthority(s, 'record.write', authority,
{ project: args.project, path: result.path, commit: result.commit }));
} catch { fail('outcome-unknown'); }
this.#secretCheck(result);
return result;
}
if (typeof args.project !== 'string' || args.spec_path !== 'spec/north-star.md' ||
typeof args.spec_commit !== 'string' || !/^[0-9a-f]{40}$/.test(args.spec_commit))
fail('invalid-spec-citation');
const spec = await readSpec(context);
if (!spec.confirmed) fail('spec-unconfirmed');
if (args.spec_commit !== spec.commit) fail('stale-spec');
return this.#store.transaction(() => {
const result = this.#dispatch(s, verb, args, spec);
this.#secretCheck(result);
return result;
});
} catch (e) {
throw this.#refused(s, ProjectFailure && e instanceof ProjectFailure ? new BusError(e.code) : e, request);
}
}
// The eight task verbs reach the trusted S3 handler here. It runs outside any transaction, gets the
// cap to authorize and record through authorize() and recordTask(); refusals are recorded as in request().
async requestTask(cap, request, handler) {
@@ -1007,7 +1061,7 @@ export class Broker {
complete(out.summary);
return { ...out.result, untrusted: true, notice: WEB_UNTRUSTED, source: verb };
}
#dispatch(s, verb, a) {
#dispatch(s, verb, a, spec = null) {
const b = this.#business(s.business),
by = s.human || s.run;
switch (verb) {
@@ -1101,10 +1155,13 @@ export class Broker {
'supersedes',
'choice',
'approvalChoice',
'spec_path',
'spec_commit',
],
['action', 'question', 'options', 'recommendation', 'blocking'],
);
this.#agent(s);
if ((a.spec_path !== undefined || a.spec_commit !== undefined) && !spec) fail('invalid-spec-citation');
const cls = this.#classification(s, a.action);
string(a.question);
if (typeof a.blocking !== 'boolean') fail('invalid-request');
@@ -1147,7 +1204,7 @@ export class Broker {
}
const did = randomUUID();
this.#store.run(
'INSERT INTO decisions(id,at,business,project,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,task_ref,requirement_ref,blocking,supersedes) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
'INSERT INTO decisions(id,at,business,project,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,task_ref,requirement_ref,blocking,supersedes,spec_path,spec_commit) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
did,
this.#now(),
s.business,
@@ -1164,6 +1221,8 @@ export class Broker {
a.requirement_ref ?? null,
Number(a.blocking),
a.supersedes ?? null,
spec?.path ?? null,
spec?.commit ?? null,
);
this.#event(
s,
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE decisions ADD COLUMN spec_path TEXT;
ALTER TABLE decisions ADD COLUMN spec_commit TEXT;
+6
View File
@@ -1,6 +1,7 @@
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from './store.mjs';
import * as projectModule from '../../project/src/index.mjs';
import { Broker, BusError } from './broker.mjs';
import { Credentials } from './credentials.mjs';
import { serve } from './server.mjs';
@@ -66,6 +67,11 @@ export async function startBroker({
const broker = new Broker({
store,
businesses,
project: {
readSpec: context => projectModule.readSpec({ ...context, dataRoot }),
writeRecord: context => projectModule.writeRecord({ ...context, dataRoot }),
ProjectError: projectModule.ProjectError,
},
organization:settings?(id,verb,args,actor)=>verb==='limits'?settings.read(id):verb==='limits.set'?settings.set(id,args,actor):verb==='plugins'?plugins.read(id):plugins.set(id,args,actor):null,
secretCheck: (value) => credentials.assertClean(value),
credentialStatus: () => credentials.status(),
+13
View File
@@ -96,6 +96,19 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
});
return;
}
// Row138: only project Git effects need an await; preserve every other sync path below.
if (r.verb === 'spec.read' || r.verb === 'record.write' ||
(r.verb === 'decision.raise' && (Object.hasOwn(r.args ?? {}, 'spec_path') || Object.hasOwn(r.args ?? {}, 'spec_commit')))) {
const own = transient;
transient = null;
// Local Git may take 100 seconds; keep its result beyond the ordinary idle deadline.
socket.setTimeout(120_000);
Promise.resolve().then(() => broker.request(cap, { verb: r.verb, args: r.args ?? {} })).then(
result => { if (!socket.destroyed) finish({ ok: true, result }); },
e => { if (!socket.destroyed) finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' }); },
).finally(() => { if (own) broker.disconnect(own); });
return;
}
const result = broker.request(cap, { verb: r.verb, args: r.args ?? {} });
finish({ ok: true, result });
} catch (e) {
+6 -2
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = '21e6c091f14f818396f4d7836f381cc1389d45b2680ecaa1a00646b652c45050';
export const SCHEMA_VERSION = '3e';
export const SCHEMA_SHA256 = '472cbcd0d4548848873f3029e8250dc07bba4fb8db2a93241aae5b8e7e3771e2';
export const SCHEMA_VERSION = '3f';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -38,6 +38,10 @@ const migrations = [{
report: (store) => store.all(
"SELECT business, to_role, count(*) AS n FROM messages m WHERE NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read')) GROUP BY business, to_role ORDER BY business, to_role",
).map((r) => `kept ${r.n} undelivered message(s) for ${r.business}/${r.to_role} without a thread; if that role is a thread instance, its thread runs never receive them, so the human re-sends them with thread.send`),
}, {
from: '3e',
to: '3f',
sql: readFileSync(new URL('./migrations/3e-3f.sql', import.meta.url), 'utf8'),
}];
const timedTables = [
'events',
+2 -2
View File
@@ -367,7 +367,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3e';", "export const SCHEMA_VERSION = '3c';")
.replace("export const SCHEMA_VERSION = '3f';", "export const SCHEMA_VERSION = '3c';")
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source);
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
@@ -391,7 +391,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3e');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3f');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
const b = new Broker({ store, businesses: demo(), organization: null });
@@ -28,12 +28,12 @@ function legacy(t) {
return { root, source, path };
}
test('explicit 3b copy migration preserves evidence and opens as 3e; normal startup never migrates', t => {
test('explicit 3b copy migration preserves evidence and opens as 3f; normal startup never migrates', t => {
const { root, source } = legacy(t);
const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3e');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3f');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
@@ -62,7 +62,7 @@ test('migration refuses active writers and drifted legacy schemas, with no parti
assert.throws(() => new Store(other.root, { migrateSchema: true }), /writer-locked/);
});
test('named migration registry chains 3b through 3e to a fixture-only future version in order', async t => {
test('named migration registry chains 3b through 3f to a fixture-only future version in order', async t => {
const { root } = legacy(t);
const { writeFileSync } = await import('node:fs');
const { pathToFileURL } = await import('node:url');
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3e to a fixture-only future ver
const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
.replace("export const SCHEMA_VERSION = '3e';", "export const SCHEMA_VERSION = '3f';")
.replace('}];\nconst timedTables', `}, { from: '3e', to: '3f', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
.replace("export const SCHEMA_VERSION = '3f';", "export const SCHEMA_VERSION = '3g';")
.replace('}];\nconst timedTables', `}, { from: '3f', to: '3g', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql'])
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3f');
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3g');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3e ready/);
assert.match(migrated.stdout, /bus schema 3f ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
+321
View File
@@ -0,0 +1,321 @@
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
CREATE TABLE events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN (
'session.launched',
'session.ended',
'action.allowed',
'action.refused',
'task.created',
'task.assigned',
'task.state',
'task.closed',
'task.changed.external',
'task.conflict',
'task.missing',
'review.requested',
'review.verdict',
'human.input',
'config.refused',
'credential.expiring',
'credential.expired',
'credential.changed',
'launch.revoked',
'launch.restored',
'digest.sent')),
actor_role TEXT, actor_run TEXT,
subject TEXT,
corrects TEXT REFERENCES events(id),
body TEXT NOT NULL CHECK (json_valid(body))
) STRICT;
CREATE TABLE role_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL, role TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE decisions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL, project TEXT,
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
action TEXT NOT NULL,
route_to TEXT NOT NULL,
question TEXT NOT NULL,
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
recommendation TEXT NOT NULL,
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
supersedes TEXT REFERENCES decisions(id)
) STRICT;
CREATE TABLE decision_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
decision TEXT NOT NULL REFERENCES decisions(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
by TEXT NOT NULL,
choice TEXT, note TEXT, via TEXT
) STRICT;
CREATE TABLE messages (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
to_role TEXT NOT NULL,
class TEXT NOT NULL,
in_reply_to TEXT REFERENCES messages(id),
decision TEXT REFERENCES decisions(id),
corrects TEXT REFERENCES messages(id),
body TEXT NOT NULL
) STRICT;
CREATE TABLE deliveries (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
message TEXT NOT NULL REFERENCES messages(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
) STRICT;
CREATE TABLE task_snapshots (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
updated TEXT NOT NULL,
etag TEXT,
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
fields TEXT NOT NULL CHECK (json_valid(fields)),
source TEXT NOT NULL CHECK (source IN ('self','poll')),
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
read_at TEXT,
role TEXT, run TEXT,
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
CHECK (json_type(fields, '$.bucket') IS 'integer'
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
) STRICT;
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
WHEN NEW.kind GLOB 'credential.*' AND (
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
OR json_extract(NEW.body, '$.instance') IS NULL)
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.missing' AND (
json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
CREATE TRIGGER events_task_subject BEFORE INSERT ON events
WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.created' AND (
json_type(NEW.body, '$.request') IS NOT 'text'
OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
AND kind = 'human.input' AND business = NEW.business)
OR json_type(NEW.body, '$.requirement') IS NOT 'text'
OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
CREATE VIEW launch_state AS
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
CREATE VIEW task_external_changes AS
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
FROM task_snapshots p
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
WHERE p.source = 'poll'
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
CREATE VIEW task_current AS
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
FROM task_snapshots s
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
WHERE c.business = s.business AND c.task_ref = s.task_ref
AND (c.source = 'self'
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
ORDER BY ls.seq DESC LIMIT 1), '')));
CREATE VIEW tasks_open AS
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
FROM task_current s
WHERE json_type(s.fields, '$.gone') IS NULL
AND json_extract(s.fields, '$.done') = 0;
CREATE VIEW urgent_inbox AS
SELECT d.id, d.business, d.task_ref, d.question, d.at
FROM decisions d
WHERE d.class = 'gated' AND d.blocking = 1
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
CREATE TABLE task_comment_cursors (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
business TEXT NOT NULL,
task_id INTEGER NOT NULL CHECK (task_id > 0),
last_comment INTEGER NOT NULL CHECK (last_comment > 0)
) STRICT;
CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
CREATE TABLE projects (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
name TEXT NOT NULL,
repo TEXT,
created_by TEXT NOT NULL,
UNIQUE (business, name)
) STRICT;
CREATE TABLE threads (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
UNIQUE (project, name)
) STRICT;
CREATE TABLE thread_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE message_threads (
message TEXT PRIMARY KEY REFERENCES messages(id),
thread TEXT NOT NULL REFERENCES threads(id)
) STRICT;
CREATE TABLE thread_opens (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
CREATE INDEX message_threads_thread ON message_threads (thread);
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'thread already held'); END;
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = c.thread) AND c.op = 'claim')
BEGIN SELECT RAISE(ABORT, 'run already holds a thread'); END;
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'thread is not held'); END;
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
CREATE TRIGGER delivery_same_thread BEFORE INSERT ON deliveries
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
WHERE mt.message = NEW.message AND m.to_role = t.instance
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = t.id)
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its thread''s holder'); END;
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
+61
View File
@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker, BusError } from '../src/broker.mjs';
function fixture(t, project, secretCheck = () => {}) {
const root = mkdtempSync(join(tmpdir(), 'project-await-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: {
coder: { definition: 'coder', authority: { withinRole: ['record.write'], crossRole: [] } },
} };
const broker = new Broker({ store, businesses: { demo: business }, project, secretCheck });
const cap = broker.bindLaunch({ business: 'demo', role: 'coder', run: 'run', harness: 'pi', address: 'coder' });
const call = (verb, args = {}) => broker.request(cap, { verb, args });
call('role.claim');
return { broker, cap, call, store };
}
test('D1: committed write with a failed receipt refuses outcome-unknown without action.allowed', async t => {
let f, committed = false;
f = fixture(t, { async writeRecord() {
committed = true;
f.call('role.release');
return { path: 'docs/result.md', commit: 'a'.repeat(40) };
} });
let returned;
await assert.rejects(async () => {
returned = await f.call('record.write', { project: 'stack', path: 'docs/result.md', record: '# Result' });
}, { code: 'outcome-unknown' });
assert.equal(committed, true);
assert.equal(returned, undefined);
assert.deepEqual(f.store.all("SELECT body FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='record.write'"), []);
});
test('D2: spec.read refuses without content if its role is released during the read', async t => {
let f;
f = fixture(t, { async readSpec() {
f.call('role.release');
return { content: 'CONTENT-MUST-NOT-RETURN' };
} });
let returned;
await assert.rejects(async () => { returned = await f.call('spec.read', { project: 'stack' }); }, { code: 'not-holder' });
assert.equal(returned, undefined);
assert.ok(!JSON.stringify(f.store.all('SELECT body FROM events')).includes('CONTENT-MUST-NOT-RETURN'));
});
for (const field of ['content', 'subject']) test(`D3: spec.read secret check refuses flagged ${field} without content`, async t => {
const marker = 'FLAGGED-SPEC-VALUE';
const result = { content: field === 'content' ? marker : '# Safe', log: [{ subject: field === 'subject' ? marker : 'Safe' }] };
const f = fixture(t, { async readSpec() { return result; } }, value => {
if (JSON.stringify(value).includes(marker)) throw new BusError('secret-refused');
});
let returned;
await assert.rejects(async () => { returned = await f.call('spec.read', { project: 'stack' }); }, { code: 'secret-refused' });
assert.equal(returned, undefined);
assert.ok(!JSON.stringify(f.store.all('SELECT body FROM events')).includes(marker));
});
@@ -0,0 +1,52 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { Client } from '../src/client.mjs';
import { startTimeOf } from '../../seat/src/proc.mjs';
import { initProject } from '../../project/src/index.mjs';
import { fixture } from './spec-fixtures.mjs';
test('Broker keeps the existing sandbox client import closure: no new imports or lazy project import', () => {
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [
"import { randomUUID, randomBytes } from 'node:crypto';",
"import { loadRequirements } from '../../tasks/src/requirements.mjs';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
});
test('without a trusted project adapter all three async paths refuse project-not-configured', async t => {
const f = await fixture(t, { adapter: false });
for (const [verb, args] of [
['spec.read', { project: 'stack' }],
['record.write', { project: 'stack', path: 'docs/a.md', record: '# A' }],
['decision.raise', { project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit }],
]) await assert.rejects(f.call(f.coder, verb, args), { code: 'project-not-configured' });
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
});
test('real startBroker injects the project adapter for socket reads and bound-author record writes', async t => {
const root = mkdtempSync(join(tmpdir(), 'project-runtime-'));
let runtime;
t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', projects: { stack: { root: join(root, 'code') } },
arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: {
definition: 'coder', authority: { withinRole: ['record.write'], crossRole: [] },
} } };
const initialized = await initProject({ business, project: 'stack', dataRoot: root });
runtime = await startBroker({ dataRoot: root, businesses: { demo: business }, launches: [{
business: 'demo', role: 'coder', run: 'runtime-run', harness: 'pi',
pid: process.pid, startTime: startTimeOf(process.pid),
}] });
const client = new Client({ path: runtime.path, cap: runtime.launches[0].cap });
await client.call('role.claim');
const spec = await client.call('spec.read', { project: 'stack' });
assert.equal(spec.commit, initialized.commit);
assert.equal(spec.confirmed, true);
const record = await client.call('record.write', { project: 'stack', path: 'docs/runtime.md', record: '# Runtime\n' });
assert.match(record.commit, /^[0-9a-f]{40}$/);
assert.equal(readFileSync(join(initialized.repo, record.path), 'utf8'), '# Runtime\n');
});
+42
View File
@@ -0,0 +1,42 @@
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { initProject, readSpec, writeRecord, ProjectError } from '../../project/src/index.mjs';
export const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8' }).trim();
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
export async function fixture(t, { adapter = true } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-spec-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const authority = grants => ({ withinRole: grants, crossRole: [] });
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'reviewer', delivery: 'reviewer' },
projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') } }, roles: {
coder: { definition: 'coder', authority: authority(['message.send', 'record.write']) },
reviewer: { definition: 'reviewer', authority: authority(['message.send']) },
} };
const { repo, commit } = await initProject({ business, project: 'stack', dataRoot: root });
const broker = new Broker({ store, businesses: { demo: business }, project: adapter ? {
readSpec: context => readSpec({ ...context, dataRoot: root }),
writeRecord: context => writeRecord({ ...context, dataRoot: root }),
ProjectError,
} : null });
const agent = role => {
const cap = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
broker.request(cap, { verb: 'role.claim' }); return cap;
};
const coder = agent('coder'), reviewer = agent('reviewer');
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const call = (cap, verb, args = {}) => broker.request(cap, { verb, args });
const raise = args => call(coder, 'decision.raise', { action: 'deploy', question: 'Ship?', options,
recommendation: 'yes', blocking: false, ...args });
const save = (content, author = 'jason') => {
writeFileSync(join(repo, 'spec/north-star.md'), content);
git(repo, 'add', '--', 'spec/north-star.md');
git(repo, '-c', `user.name=${author}`, '-c', '[email protected]', 'commit', '-m', 'Spec');
return git(repo, 'rev-parse', 'HEAD');
};
return { root, store, business, broker, repo, commit, coder, reviewer, human, call, raise, save };
}
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { DatabaseSync } from 'node:sqlite';
import { Store, schemaDigest } from '../src/store.mjs';
const AT = '2026-10-15T00:00:00.000Z';
function source3e(t) {
const root = mkdtempSync(join(tmpdir(), 'spec-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
const ddl = readFileSync(new URL('./fixtures/schema-3e.sql', import.meta.url), 'utf8');
assert.equal(createHash('sha256').update(ddl).digest('hex'), '21e6c091f14f818396f4d7836f381cc1389d45b2680ecaa1a00646b652c45050');
db.exec(ddl);
const run = (sql, ...args) => db.prepare(sql).run(...args);
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3e');
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
run('INSERT INTO events(id,at,business,kind,body) VALUES(?,?,?,?,?)', 'event', AT, 'demo', 'action.allowed', '{}');
run('INSERT INTO role_claims(at,business,role,op,holder_run,harness,by) VALUES(?,?,?,?,?,?,?)', AT, 'demo', 'coder', 'claim', 'run', 'pi', 'run');
run('INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
'decision', AT, 'demo', 'coder', 'run', 'gated', 'deploy', 'human', '?', '[{"key":"yes","text":"Yes"},{"key":"no","text":"No"}]', 'yes', 0);
run('INSERT INTO decision_events(decision,at,op,by,choice) VALUES(?,?,?,?,?)', 'decision', AT, 'resolved', 'jason', 'yes');
run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)', 'message', AT, 'demo', 'human', 'human', 'assistant', 'INFO', 'kept');
run('INSERT INTO deliveries(message,at,op) VALUES(?,?,?)', 'message', AT, 'routed');
run('INSERT INTO task_snapshots(at,business,task_ref,updated,digest,fields,source,role,run) VALUES(?,?,?,?,?,?,?,?,?)', AT, 'demo', 'vikunja:1/1', AT, 'a'.repeat(64), '{"bucket":1,"done":false}', 'self', 'coder', 'run');
run('INSERT INTO task_comment_cursors(business,task_id,last_comment) VALUES(?,?,?)', 'demo', 1, 8);
run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'project', AT, 'demo', 'Work', 'stack', 'jason');
run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', 'thread', AT, 'demo', 'project', 'assistant', 'jason', 'One');
run('INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,by) VALUES(?,?,?,?,?,?,?)', AT, 'demo', 'thread', 'claim', 'assistant-run', 'pi', 'assistant-run');
run('INSERT INTO message_threads VALUES(?,?)', 'message', 'thread');
run('INSERT INTO thread_opens(at,thread,by) VALUES(?,?,?)', AT, 'thread', 'jason');
db.close(); chmodSync(path, 0o600);
const copy = join(root, 'untouched-3e.sqlite'); copyFileSync(path, copy);
return { root, path, copy };
}
function snapshot(path, columns) {
const db = new DatabaseSync(path, { readOnly: true });
try {
columns ??= Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all()
.map(({ name }) => [name, db.prepare(`PRAGMA table_info(${name})`).all().map(c => c.name)]));
const rows = Object.fromEntries(Object.entries(columns).map(([table, keys]) => [table,
db.prepare(`SELECT ${keys.join(',')} FROM ${table} ORDER BY rowid`).all()
.filter(r => table !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key))]));
return { columns, rows };
} finally { db.close(); }
}
test('3e copy migrates to 3f preserving every old column/row and adding null spec citations', t => {
const { root, path, copy } = source3e(t), before = snapshot(path), untouched = readFileSync(copy);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3f');
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
} finally { store.close(); }
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
assert.deepEqual(readFileSync(copy), untouched);
assert.deepEqual(snapshot(copy).rows, before.rows);
new Store(root).close();
});
+96
View File
@@ -0,0 +1,96 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { connect } from 'node:net';
import { join } from 'node:path';
import { fixture } from './spec-fixtures.mjs';
import { serve } from '../src/server.mjs';
import { Client } from '../src/client.mjs';
async function socketFixture(t, options = {}) {
const f = await fixture(t), path = join(f.root, 'bus.sock');
const server = await serve({ broker: f.broker, path, ...options });
t.after(() => server.close());
return { ...f, path, client: new Client({ path, cap: f.coder }) };
}
test('real socket awaits spec reads, record writes and cited raises; other requests still return normally', async t => {
const f = await socketFixture(t);
const spec = await f.client.call('spec.read', { project: 'stack' });
assert.equal(spec.commit, f.commit); assert.match(spec.content, /North.star/i);
const result = await f.client.call('record.write', { project: 'stack', path: 'docs/socket.md', record: '# Socket\n' });
assert.match(result.commit, /^[0-9a-f]{40}$/);
const d = await f.client.call('decision.raise', { action: 'deploy', question: 'Ship?',
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
project: 'stack', spec_path: spec.path, spec_commit: spec.commit });
assert.equal(f.store.get('SELECT spec_commit FROM decisions WHERE id=?', d.id).spec_commit, spec.commit);
assert.ok(Array.isArray(await f.client.call('agents')));
});
test('real socket preserves asynchronous refusal codes and disconnects its transient proof', async t => {
let transient;
const f = await fixture(t), path = join(f.root, 'bus.sock');
const server = await serve({ broker: f.broker, path, authenticateHuman: () => {
transient = f.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
return transient;
} });
t.after(() => server.close());
const client = new Client({ path, cap: f.coder });
await assert.rejects(client.call('spec.read', { project: 'missing' }), { code: 'project-not-declared' });
await assert.rejects(new Client({ path, human: { fixture: true } }).call('spec.read', { project: 'stack' }), { code: 'agent-required' });
assert.throws(() => f.broker.identity(transient), /unauthenticated/);
assert.ok(Array.isArray(await client.call('agents')));
});
test('two real socket writes to one repository yield one commit and one project-busy refusal', async t => {
const f = await socketFixture(t);
const results = await Promise.allSettled(['one', 'two'].map(name =>
new Client({ path: f.path, cap: f.coder }).call('record.write', {
project: 'stack', path: `docs/${name}.md`, record: `# ${name}\n`,
})));
assert.equal(results.filter(r => r.status === 'fulfilled').length, 1);
const refused = results.find(r => r.status === 'rejected');
assert.equal(refused.reason.code, 'project-busy');
assert.equal(f.store.all("SELECT body FROM events WHERE json_extract(body,'$.action')='record.write'").length, 1);
});
for (const rejects of [false, true]) test(`client disconnect during await tolerates late ${rejects ? 'refusal' : 'reply'}`, async t => {
const f = await fixture(t), path = join(f.root, 'bus.sock');
let release, admitted, finished;
const holding = new Promise(resolve => { release = resolve; });
const started = new Promise(resolve => { admitted = resolve; });
const completed = new Promise(resolve => { finished = resolve; });
const broker = { request(cap, request) {
if (request.verb !== 'spec.read') return f.broker.request(cap, request);
admitted();
return holding.then(() => f.broker.request(cap, { verb: 'spec.read',
args: { project: rejects ? 'missing' : 'stack' } })).finally(finished);
} };
const server = await serve({ broker, path });
t.after(() => server.close());
const socket = connect(path);
socket.on('error', () => {});
socket.write(JSON.stringify({ cap: f.coder, verb: 'spec.read', args: { project: 'stack' } }) + '\n');
await started;
const closed = new Promise(resolve => socket.once('close', resolve));
socket.destroy(); await closed;
release(); await completed;
// A further socket round-trip proves the server remains usable after the late completion.
assert.ok(Array.isArray(await new Client({ path, cap: f.coder }).call('agents')));
});
test('N-a: slow admitted record.write returns its committed result after the idle deadline', async t => {
const f = await fixture(t), path = join(f.root, 'bus.sock');
const broker = { async request(cap, request) {
if (request.verb === 'record.write') await new Promise(resolve => setTimeout(resolve, 150));
return f.broker.request(cap, request);
} };
const server = await serve({ broker, path, timeout: 40 });
t.after(() => server.close());
const written = await new Client({ path, cap: f.coder }).call('record.write', {
project: 'stack', path: 'docs/slow.md', record: '# Slow write\n',
});
assert.match(written.commit, /^[0-9a-f]{40}$/);
const receipts = f.store.all("SELECT body FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='record.write'");
assert.equal(receipts.length, 1);
assert.equal(JSON.parse(receipts[0].body).commit, written.commit);
});
+61
View File
@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { fixture, git } from './spec-fixtures.mjs';
test('current citation is recorded; docs commits do not stale it and a new spec never reopens settlement', async t => {
const f = await fixture(t);
const d = await f.raise({ project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit });
const stored = f.store.get('SELECT spec_path,spec_commit FROM decisions WHERE id=?', d.id);
assert.equal(stored.spec_path, 'spec/north-star.md'); assert.equal(stored.spec_commit, f.commit);
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
await f.call(f.coder, 'record.write', { project: 'stack', path: 'docs/design.md', record: '# Design\n' });
await f.raise({ project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit });
f.save('# Updated\n');
assert.equal(f.store.get('SELECT op FROM decision_events WHERE decision=? ORDER BY seq DESC', d.id).op, 'resolved');
assert.equal(f.store.get('SELECT spec_commit FROM decisions WHERE id=?', d.id).spec_commit, f.commit);
assert.throws(() => f.call(f.human, 'decision.resolve', { id: d.id, choice: 'no' }), /decision-closed/);
});
test('stale, unconfirmed, missing and partial spec citations refuse without inserting a decision', async t => {
const f = await fixture(t), citation = { project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit };
const current = f.save('# Current\n');
await assert.rejects(f.raise(citation), { code: 'stale-spec' });
f.save('# Agent\n', 'coder.coder');
await assert.rejects(f.raise({ ...citation, spec_commit: git(f.repo, 'rev-parse', 'HEAD') }), { code: 'spec-unconfirmed' });
for (const args of [{ project: 'stack', spec_path: citation.spec_path }, { project: 'stack', spec_commit: current },
{ spec_path: citation.spec_path, spec_commit: current }, { ...citation, spec_path: 'docs/design.md' }])
await assert.rejects(f.raise(args), { code: 'invalid-spec-citation' });
git(f.repo, 'rm', '--', 'spec/north-star.md');
git(f.repo, '-c', 'user.name=jason', '-c', '[email protected]', 'commit', '-m', 'Missing');
await assert.rejects(f.raise(citation), { code: 'project-read-missing' });
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
});
test('uncited raises retain their synchronous path and spec read is available to any held role in its business', async t => {
const f = await fixture(t);
const d = f.raise({});
assert.equal(typeof d.then, 'undefined');
assert.equal(f.store.get('SELECT spec_commit FROM decisions WHERE id=?', d.id).spec_commit, null);
const spec = await f.call(f.reviewer, 'spec.read', { project: 'stack', commit: f.commit });
assert.equal(spec.commit, f.commit); assert.equal(spec.confirmed, true);
assert.equal(spec.content, readFileSync(join(f.repo, 'spec/north-star.md'), 'utf8'));
await assert.rejects(f.call(f.reviewer, 'spec.read', { project: 'other' }), { code: 'project-not-declared' });
await assert.rejects(f.call(f.reviewer, 'spec.read', { project: 'stack', business: 'other' }), { code: 'invalid-request' });
await assert.rejects(f.call(f.human, 'spec.read', { project: 'stack' }), { code: 'agent-required' });
});
test('record writes bind the session author, require authority and refuse spec and impersonation paths', async t => {
const f = await fixture(t);
const written = await f.call(f.coder, 'record.write', { project: 'stack', path: 'docs/result.md', record: '# Result\n' });
assert.equal(git(f.repo, 'show', '-s', '--format=%an', written.commit), 'coder.coder');
assert.equal(git(f.repo, 'show', '--format=', '--name-only', written.commit), 'docs/result.md');
await assert.rejects(f.call(f.coder, 'record.write', { project: 'stack', path: 'spec/north-star.md', record: '# Bad' }), { code: 'project-spec-human-only' });
await assert.rejects(f.call(f.coder, 'record.write', { project: 'stack', path: 'docs/x.md', record: '# Bad', author: { definition: 'human', instance: 'jason' } }), { code: 'invalid-request' });
await assert.rejects(f.call(f.reviewer, 'record.write', { project: 'stack', path: 'docs/x.md', record: '# Bad' }), { code: 'decision-required' });
const event = f.store.all("SELECT body FROM events WHERE kind='action.allowed'").map(r => JSON.parse(r.body)).find(r => r.action === 'record.write');
assert.equal(event.commit, written.commit); assert.equal(event.path, 'docs/result.md');
});
+3 -3
View File
@@ -49,7 +49,7 @@ async function database3d(t) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3e';", "export const SCHEMA_VERSION = '3d';")
.replace("export const SCHEMA_VERSION = '3f';", "export const SCHEMA_VERSION = '3d';")
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
@@ -95,7 +95,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3e');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3f');
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
@@ -155,5 +155,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3e ready', ''].join('\n'));
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3f ready', ''].join('\n'));
});
+1
View File
@@ -22,6 +22,7 @@ export const ACTIONS = Object.freeze([
"git.merge.protected",
"review.request",
"review.verdict",
"record.write",
"message.send",
"message.external",
"role.launch",
+6 -6
View File
@@ -1,5 +1,5 @@
// The assistant role (REQ-ASSIST-1, row 119). The role file is policy: it
// holds message.send within role and nothing else, no credentials, and tools
// holds message.send and record.write within role, no credentials, and tools
// as read-only as the chief of staff's, plus the web grant (REQ-HARN-2, vikunja:19/295). A widened file fails here.
import { test } from "node:test";
@@ -12,7 +12,7 @@ import { businessDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mj
const APPROVED = Object.freeze({
tools: ["read", "grep", "find", "ls", "web"],
network: "api-only",
authority: Object.freeze({ withinRole: Object.freeze(["message.send"]), crossRole: Object.freeze([]) }),
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "record.write"]), crossRole: Object.freeze([]) }),
credentials: Object.freeze([]),
// REQ-ASSIST-2: the user and the chief of staff, both ways. The broker enforces these lists (row 120).
messaging: Object.freeze({
@@ -67,15 +67,15 @@ test("the shipped file is exactly the approved list", () => {
assert.deepEqual(rest, { roleVersion: 2, name: "assistant", contract: "assistant.md", ...APPROVED });
});
test("the assistant's authority is message.send within role and nothing else", () => {
test("the assistant's authority is message.send and record.write within role", () => {
const role = loadRole(REPO_ROLES, "assistant");
assert.deepEqual(role.authority, { withinRole: ["message.send"], crossRole: [] });
assert.deepEqual(role.authority, { withinRole: ["message.send", "record.write"], crossRole: [] });
assert.deepEqual(role.credentials, []);
const record = resolved(loadFixture());
assert.deepEqual(record.limits.authority, { withinRole: ["message.send"], crossRole: [] });
assert.deepEqual(record.limits.authority, { withinRole: ["message.send", "record.write"], crossRole: [] });
assert.deepEqual(record.credentials, {});
for (const action of ACTIONS) {
assert.equal(classify(record, action), action === "message.send" ? "within" : "gated", action);
assert.equal(classify(record, action), ["message.send", "record.write"].includes(action) ? "within" : "gated", action);
}
for (const action of ["task.create", "task.assign", "role.launch"]) assert.equal(classify(record, action), "gated", action);
});
@@ -13,11 +13,11 @@ import { THREAD_IDLE_SECONDS } from "../src/business.mjs";
import { businessDoc, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs";
// Decision 104's list plus role.launch (decision 139) plus role.end and
// organization.change (row 123, REQ-COS-1). A role file that differs from it fails.
// organization.change (row 123, REQ-COS-1) and record.write (row 138). A role file that differs from it fails.
const APPROVED = Object.freeze({
tools: ["read", "grep", "find", "ls"],
network: "api-only",
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change"]), crossRole: Object.freeze([]) }),
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write"]), crossRole: Object.freeze([]) }),
credentials: Object.freeze([]),
});
@@ -84,9 +84,9 @@ test("the resolved record matches the approved list, so a widened file fails", (
assert.deepEqual(record.credentials, {});
});
test("the chief of staff holds role.launch, message.send, role.end and organization.change; every other action is gated", () => {
test("the chief of staff holds role.launch, message.send, role.end, organization.change and record.write; every other action is gated", () => {
const record = resolved(loadFixture());
const held = ["message.send", "role.launch", "role.end", "organization.change"];
const held = ["message.send", "role.launch", "role.end", "organization.change", "record.write"];
for (const action of ACTIONS) {
assert.equal(classify(record, action), held.includes(action) ? "within" : "gated", action);
}
+3 -3
View File
@@ -1,5 +1,5 @@
// The five C-suite roles (REQ-ROLE-4, REQ-ROLE-1, vikunja:19/301; Mos Q123). Each role file is
// policy at least privilege: read-only tools, message.send within role and nothing else, no
// policy at least privilege: read-only tools, message.send and record.write within role, no
// credentials. Only the chief of staff launches them (REQ-COS-1, REQ-LAUNCH-1). A widened file
// fails here. Fixtures live in a temp directory and nothing reads the real config.
import { test } from "node:test";
@@ -13,7 +13,7 @@ const CSUITE = ["chief-executive-officer", "chief-financial-officer", "chief-inf
const APPROVED = Object.freeze({
tools: ["read", "grep", "find", "ls"],
network: "api-only",
authority: { withinRole: ["message.send"], crossRole: [] },
authority: { withinRole: ["message.send", "record.write"], crossRole: [] },
credentials: [],
// Least privilege (vikunja:19/306): an advisory role talks to the user, the chief of staff, the pm
// and the cto, so it can't send work to the coder or reviewer around the pm.
@@ -57,7 +57,7 @@ test("an organization file that declares them passes, and each resolves to least
assert.deepEqual(record.limits, { tools: APPROVED.tools, network: "api-only", authority: APPROVED.authority });
assert.deepEqual(record.credentials, {});
assert.equal(record.launch, null);
for (const action of ACTIONS) assert.equal(classify(record, action), action === "message.send" ? "within" : "gated", `${name} ${action}`);
for (const action of ACTIONS) assert.equal(classify(record, action), ["message.send", "record.write"].includes(action) ? "within" : "gated", `${name} ${action}`);
assert.ok(!record.limits.tools.includes("web") && !record.limits.tools.includes("bash") && !record.limits.tools.includes("write"), name);
}
});
@@ -0,0 +1,18 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ACTIONS, GATED_ONLY } from '../src/vocabulary.mjs';
import { loadRole } from '../src/index.mjs';
import { REPO_ROLES } from './helpers.mjs';
const ROLES = ['assistant', 'chief-executive-officer', 'chief-financial-officer',
'chief-information-officer', 'chief-information-security-officer', 'chief-marketing-officer',
'chief-of-staff', 'coder', 'cto', 'pm', 'reviewer'];
test('record.write is within-role for the eleven approved v2 agents, never gated-only', () => {
assert.ok(ACTIONS.includes('record.write'));
assert.ok(!GATED_ONLY.includes('record.write'));
for (const name of ROLES) {
const role = loadRole(REPO_ROLES, name);
assert.equal(role.roleVersion, 2);
assert.ok(role.authority.withinRole.includes('record.write'), name);
assert.ok(!role.authority.crossRole.includes('record.write'), name);
}
});
+14
View File
@@ -307,3 +307,17 @@ These are limits, not bugs, and nothing in this package claims otherwise.
hides it from other sessions. It holds no secret.
- **Pi adapter paths** with spaces break its unquoted `-e` and skill
splitting; the launcher's paths don't contain spaces.
## File-backed project records (row138)
Every agent gets read_spec(project,commit?), which returns the committed
north-star text and path history. A role holding record.write also gets
write_record(project,path,record,decision?), for validated docs/requirements/
decisions committed as its bound instance. The broker supplies author metadata;
there is no author argument and no agent spec writer. A direct attempt to write
spec/ refuses even if a caller bypasses the offered tool schema.
raise_decision accepts optional project, spec_path and spec_commit. Cite both
spec fields together, using the current confirmed file commit returned by
read_spec, not the repository HEAD. Uncited raises remain valid. Later human
spec commits do not reopen decisions already settled.
+17
View File
@@ -178,6 +178,20 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
url: str("The https url to fetch", { maxLength: 2048 }),
}, ["url"]));
}
add("read_spec", "spec.read", null, "Read a project's committed north-star spec and its file history. commit is an optional full reachable commit ID. The file commit, rather than HEAD, is what a decision cites. confirmed says the file commit was authored by the business's human.", obj({
project: str("Project ID from the business file"),
commit: str("Optional full historical commit ID", { pattern: "^[0-9a-f]{40}$" }),
}, ["project"]));
if (held.has("record.write")) {
add("write_record", "record.write", "record.write", "Validate and commit one project document, requirement or decision as your bound role instance. The broker supplies the author. Agents never write spec/; pending human edits or another writer cause a refusal.", obj({
project: str("Project ID from the business file"),
path: str("Named docs Markdown or requirement/decision JSON path", {
pattern: "^(docs/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\\.md|(requirements|decisions)/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\\.json)$",
}),
record: { type: ["string", "object"], description: "Markdown string or a JSON record matching the project's package-owned schema" },
decision: COMMON.decision,
}, ["project", "path", "record"]));
}
add("raise_decision", "decision.raise", null, "Ask for a decision before a gated or cross-role action, or when you need a choice made. The answer arrives as a message.", obj({
action: str("The action the decision is about", { enum: [...ACTIONS] }),
question: str("The question, with enough context to answer it and the reason for your recommendation"),
@@ -188,6 +202,9 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
approvalChoice: str("The option key that approves the action. It defaults to yes, so pass it whenever no option key is yes"),
task_ref: TASK_REF,
requirement_ref: str("Requirement id"),
project: str("Project ID from the business file; required with a spec citation"),
spec_path: str("Cite only this north-star path; send spec_commit with it", { const: "spec/north-star.md" }),
spec_commit: str("The current confirmed file commit from read_spec; send spec_path with it", { pattern: "^[0-9a-f]{40}$" }),
}, ["action", "question", "options", "recommendation", "blocking"]));
if (arbiter) {
add("resolve_decision", "decision.resolve", null, "Resolve a decision routed to you.", obj({
+3 -3
View File
@@ -77,7 +77,7 @@ test("a chief-of-staff bundle lists launch among the bus tools and the manifest'
const b = build(t, r);
const prompt = readFileSync(b.files.prompt, "utf8");
assert.match(prompt, /Role instance: chief \(definition chief-of-staff\)\. Run: run-1\./);
assert.match(prompt, /Bus tools: send_message, launch, end_run, change_organization, raise_decision, /);
assert.match(prompt, /Bus tools: send_message, launch, end_run, change_organization, read_spec, write_record, raise_decision, /);
assert.ok(b.manifest.typed.includes("launch"));
assert.deepEqual(JSON.parse(readFileSync(b.files.policy, "utf8")).needs, []);
});
@@ -88,9 +88,9 @@ test("a chief-of-staff bundle lists end_run and change_organization as typed and
const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness, model: "claude-opus-5-5" }));
const b = build(t, r);
const prefix = harness === "pi" ? "" : "mcp__mosaic__";
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "change_organization", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness);
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "change_organization", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness);
assert.deepEqual(b.manifest.untyped, [], harness);
assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, change_organization, raise_decision, /);
assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, change_organization, read_spec, write_record, raise_decision, /);
assert.doesNotMatch(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet/);
}
});
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { typedTools, callTool } from '../src/tools.mjs';
import { resolvedFor, fakeToolSocket } from './helpers.mjs';
import { fixture } from '../../bus/tests/spec-fixtures.mjs';
test('every role sees read_spec, record writer follows its grant, citation schemas stay strict', t => {
for (const role of ['coder', 'reviewer', 'pm', 'chief']) {
const tools = typedTools({ resolved: resolvedFor(t, role) });
const read = tools.find(t => t.name === 'read_spec'), write = tools.find(t => t.name === 'write_record');
assert.deepEqual([read.verb, read.action], ['spec.read', null]);
assert.deepEqual(read.parameters.required, ['project']);
assert.deepEqual(Object.keys(read.parameters.properties), ['project', 'commit']);
assert.equal(read.parameters.additionalProperties, false);
assert.deepEqual([write.verb, write.action], ['record.write', 'record.write']);
assert.deepEqual(write.parameters.required, ['project', 'path', 'record']);
assert.equal(write.parameters.additionalProperties, false);
assert.equal(Object.hasOwn(write.parameters.properties, 'author'), false);
assert.equal(new RegExp(write.parameters.properties.path.pattern).test('spec/north-star.md'), false);
assert.equal(new RegExp(write.parameters.properties.path.pattern).test('docs/design.md'), true);
const raise = tools.find(t => t.name === 'raise_decision').parameters;
assert.equal(raise.additionalProperties, false);
assert.equal(raise.properties.spec_path.const, 'spec/north-star.md');
assert.equal(new RegExp(raise.properties.spec_commit.pattern).test('a'.repeat(40)), true);
assert.equal(raise.required.includes('spec_commit'), false);
}
const restricted = resolvedFor(t, 'coder', doc => { doc.roles.coder.vars['limits.authority'] = ['message.send']; });
const tools = typedTools({ resolved: restricted });
assert.ok(tools.some(t => t.name === 'read_spec'));
assert.ok(!tools.some(t => t.name === 'write_record'));
});
test('typed socket forwards read content/history and agent record writes, and cannot write a spec', async t => {
const f = await fixture(t);
const tools = typedTools({ resolved: { definition: 'coder', limits: { tools: [], authority: {
withinRole: ['record.write'], crossRole: [],
} } } });
const byName = new Map(tools.map(tool => [tool.name, tool]));
const server = await fakeToolSocket(t, f.root, (name, args) =>
f.broker.request(f.coder, { verb: byName.get(name).verb, args }));
const spec = await callTool(server.path, 'read_spec', { project: 'stack' });
assert.equal(spec.commit, f.commit); assert.equal(spec.log[0].commit, f.commit);
assert.match(spec.content, /North.star/i);
const record = await callTool(server.path, 'write_record', { project: 'stack', path: 'docs/tool.md', record: '# Tool\n' });
assert.match(record.commit, /^[0-9a-f]{40}$/);
await assert.rejects(callTool(server.path, 'write_record', { project: 'stack', path: 'spec/north-star.md', record: '# Bad' }), /project-spec-human-only/);
});
+10 -10
View File
@@ -13,7 +13,7 @@ test("the PM gets its task verbs and the reads, and no launch", (t) => {
"send_message", "request_staff",
"task_create", "task_assign", "task_reassign", "task_schedule", "task_priority_change", "task_scope_change",
"task_update_assigned", "task_close",
"raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "list_requirements",
"read_spec", "write_record", "raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "list_requirements",
]);
// Row 119 (#1613): only the chief of staff launches (REQ-COS-1).
assert.equal(pm.launch, null);
@@ -21,7 +21,7 @@ test("the PM gets its task verbs and the reads, and no launch", (t) => {
const create = tools.find((x) => x.name === "task_create");
assert.deepEqual({ ...create.parameters.properties.project, description: undefined }, { type: "integer", minimum: 1, description: undefined });
assert.deepEqual(create.parameters.required, ["title", "request", "requirement"]);
assert.deepEqual(tools.filter((x) => x.parameters.properties.project).map((x) => x.name), ["task_create"]);
assert.deepEqual(tools.filter((x) => x.parameters.properties.project).map((x) => x.name), ["task_create", "read_spec", "write_record", "raise_decision"]);
for (const tool of tools) {
assert.equal(tool.parameters.type, "object");
assert.equal(tool.parameters.additionalProperties, false);
@@ -33,7 +33,7 @@ test("the PM gets its task verbs and the reads, and no launch", (t) => {
test("the chief of staff gets launch, send_message, end_run and change_organization, and no task verbs", (t) => {
const chief = resolvedFor(t, "chief");
const tools = typedTools({ resolved: chief, tracker: true });
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "change_organization", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "change_organization", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
const launch = tools.find((x) => x.name === "launch");
assert.equal(launch.verb, "launch");
assert.equal(launch.action, "role.launch");
@@ -106,11 +106,11 @@ test("a role without role.end or organization.change gets neither tool, and each
assert.deepEqual(names(typedTools({ resolved: only("organization.change") })).filter((n) => n === "end_run" || n === "change_organization"), ["change_organization"]);
});
// Row 119 (#1613): the assistant holds message.send alone.
// Row 119 gave the assistant message.send; row 138 adds record.write.
test("the assistant gets send_message, the reads and the web tools (it holds the web grant), no launch", (t) => {
const assistant = resolvedFor(t, "assistant", (d) => (d.roles.assistant = { definition: "assistant", holder: "mos" }));
const tools = typedTools({ resolved: assistant, tracker: true });
assert.deepEqual(names(tools), ["send_message", "web_search", "web_fetch", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
assert.deepEqual(names(tools), ["send_message", "web_search", "web_fetch", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
assert.equal(assistant.launch, null);
assert.deepEqual(untypedActions(assistant, tools), []);
});
@@ -144,7 +144,7 @@ test("list_requirements reads the requirement ids, for a task.create holder with
test("a coder gets no launch, no resolve_decision, and no task tools without a tracker", (t) => {
const coder = resolvedFor(t, "coder");
const tools = typedTools({ resolved: coder });
assert.deepEqual(names(tools), ["send_message", "request_review", "raise_decision", "list_agents", "inbox", "trail"]);
assert.deepEqual(names(tools), ["send_message", "request_review", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail"]);
// Held actions without a tool are listed, so the manifest shows them. With
// no clone to push from, push_working is one of them.
assert.deepEqual(untypedActions(coder, tools), ["task.update.assigned", "git.push.working", "task.scope.change"]);
@@ -154,7 +154,7 @@ test("a coder with a tracker gets its own task tools and no task_reassign", (t)
const coder = resolvedFor(t, "coder");
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), [
"send_message", "task_scope_change", "task_update_assigned", "request_review",
"raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
"read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
]);
});
@@ -167,7 +167,7 @@ test("launch only when the business's launch block names the instance as launche
test("an action outside the instance's authority has no tool", (t) => {
const coder = resolvedFor(t, "coder", (d) => (d.roles.coder.vars["limits.authority"] = ["review.request"]));
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true, projects: ["stack"] })), ["request_review", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true, projects: ["stack"] })), ["request_review", "read_spec", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
});
// Row 93 (#1587): the review loop's tools, by authority, and the git ones only with a clone.
@@ -176,7 +176,7 @@ test("the review loop: push_working and request_review for the coder, fetch_revi
const tools = typedTools({ resolved: coder, tracker: true, projects: ["stack", "site"] });
assert.deepEqual(names(tools), [
"send_message", "task_scope_change", "task_update_assigned", "push_working", "request_review",
"raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
"read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
]);
assert.deepEqual(untypedActions(coder, tools), []);
const push = tools.find((x) => x.name === "push_working");
@@ -193,7 +193,7 @@ test("the review loop: push_working and request_review for the coder, fetch_revi
const reviewer = resolvedFor(t, "reviewer");
const rtools = typedTools({ resolved: reviewer, tracker: true, projects: ["stack"] });
assert.deepEqual(names(rtools), ["send_message", "task_update_assigned", "fetch_review", "review_verdict", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
assert.deepEqual(names(rtools), ["send_message", "task_update_assigned", "fetch_review", "review_verdict", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
const fetch = rtools.find((x) => x.name === "fetch_review");
assert.deepEqual([fetch.verb, fetch.action], ["working.fetch", null]);
assert.deepEqual(fetch.parameters.required, ["task_ref", "request"]);
+26
View File
@@ -117,3 +117,29 @@ callers must coordinate simultaneous human saves. No destructive repair, push,
remote creation, role grant, spec-confirmation mechanism or live action ships.
Tests: `node --test packages/project/tests/*.test.mjs`. Fixtures only.
## Committed file reads (row 138)
`readProjectFile({business,project,dataRoot,path,commit?})` reads only
spec/north-star.md or a named docs/NAME.md; `readSpec({business,project,dataRoot,commit?})`
fixes the spec path. Both are async and read-only, share repository/path checks,
refuse remotes and unsafe Git metadata, and validate committed project.json
identity. Working-tree edits do not replace committed content. A supplied full
SHA-1 must be a commit reachable from a captured HEAD.
The result is {repo,path,head,revision,commit,content,author,log,truncated}.
commit is the last commit changing the file at revision, not necessarily HEAD;
content preserves exact valid UTF-8 including trailing newlines, up to 1 MiB with
no NUL. Log has at most 100 entries, newest first, each
{commit,author:{name,email},at,subject}; at is canonical UTC. truncated says more
entries exist. Reads reject nonregular Git blobs, missing paths and invalid
content. History output is separately bounded to 256 KiB.
readSpec adds confirmed=true exactly when the file commit author.name equals
business.human. An agent-authored latest change stays unconfirmed. This is local
Git provenance, not a cryptographic identity proof. Broker citation validation
compares a fresh current snapshot and never uses an older read as authority.
Read errors are ProjectError: project-read-path, project-read-commit,
project-read-missing, project-read-content, project-invalid, plus existing
project-id/path/not-declared/repository/remote/git codes. No write or spec save
API is added; row 148 owns the human editor writer and its stale-base check.
+32 -1
View File
@@ -1,6 +1,7 @@
import { lstatSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { gitEnv, runGit } from '../../seat/src/checkout.mjs';
import { spawn } from 'node:child_process';
import { gitEnv, runGit, GIT_LIMITS } from '../../seat/src/checkout.mjs';
import { ProjectError } from './paths.mjs';
const SAFE = ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-c', 'core.ignorestat=false',
@@ -53,3 +54,33 @@ export async function requireClean(repo) {
}
export const identity = name => ({ GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: `${name}@mosaic.invalid`,
GIT_COMMITTER_NAME: name, GIT_COMMITTER_EMAIL: `${name}@mosaic.invalid` });
// Blob/log reads preserve bytes and bound memory before decoding untrusted Git data.
export async function gitBytes(repo, args, maxBytes) {
return new Promise((resolve, reject) => {
const child = spawn('git', [...SAFE, '-C', repo, ...args], {
env: { ...gitEnv(), GIT_CEILING_DIRECTORIES: dirname(repo) },
detached: true, stdio: ['ignore', 'pipe', 'pipe'],
});
let size = 0, failed = false, settled = false;
const chunks = [];
const kill = () => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} };
const timer = setTimeout(() => { failed = true; kill(); }, GIT_LIMITS.timeoutMs);
const finish = (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (failed || code !== 0) reject(new ProjectError('project-git', 'bounded local Git read refused'));
else resolve(Buffer.concat(chunks));
};
child.stdout.on('data', chunk => {
size += chunk.length;
if (size > maxBytes) { failed = true; kill(); }
else chunks.push(chunk);
});
// Drain stderr without retaining repository-controlled text.
child.stderr.on('data', () => {});
child.on('error', () => finish(-1));
child.on('close', finish);
});
}
+2
View File
@@ -230,3 +230,5 @@ export async function writeRecord({ path, record, author, ...context }) {
if (lstatSync(lock).ino === lockStat.ino) unlinkSync(lock);
}
}
export { readProjectFile, readSpec } from './read.mjs';
+70
View File
@@ -0,0 +1,70 @@
import { projectRepo, ProjectError } from './paths.mjs';
import { git, gitBytes, inspectGit } from './git.mjs';
import { validateRecord } from './validate.mjs';
const MAX_BYTES = 1024 * 1024;
const SHA = /^[0-9a-f]{40}$/;
const fail = (code, message) => { throw new ProjectError(code, message); };
function argumentsFor(args, allowed) {
if (!args || typeof args !== 'object' || Array.isArray(args) || Object.keys(args).some(k => !allowed.includes(k)))
fail('project-read-path', 'unknown read arguments');
}
function text(bytes) {
try {
const value = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
if (value.includes('\0')) fail('project-read-content', 'NUL in blob');
return value;
} catch { fail('project-read-content', 'expected bounded UTF-8 without NUL'); }
}
async function blob(repo, revision, path) {
const entry = await git(repo, ['ls-tree', '-z', revision, '--', path]);
if (!entry) fail('project-read-missing', 'file absent at revision');
const match = /^(100644|100755) blob ([0-9a-f]{40})\t([^\0]+)\0$/.exec(entry);
if (!match || match[3] !== path) fail('project-read-content', 'expected regular Git blob');
const size = Number(await git(repo, ['cat-file', '-s', match[2]]));
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_BYTES)
fail('project-read-content', 'blob exceeds size bound');
return text(await gitBytes(repo, ['cat-file', 'blob', match[2]], MAX_BYTES));
}
export async function readProjectFile(args) {
argumentsFor(args, ['business', 'project', 'dataRoot', 'path', 'commit']);
const { business, project, dataRoot, path, commit } = args;
if (typeof path !== 'string' || !(path === 'spec/north-star.md' || /^docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/.test(path)))
fail('project-read-path', 'expected spec/north-star.md or a named Markdown doc');
const repo = projectRepo({ business, project, dataRoot });
await inspectGit(repo);
let head;
try { head = await git(repo, ['rev-parse', '--verify', 'HEAD^{commit}']); }
catch { fail('project-read-commit', 'repository has no readable HEAD commit'); }
const revision = commit === undefined ? head : commit;
if (typeof revision !== 'string' || !SHA.test(revision)) fail('project-read-commit', 'expected full commit ID');
try {
if (await git(repo, ['cat-file', '-t', revision]) !== 'commit') fail('project-read-commit', 'not a commit');
await git(repo, ['merge-base', '--is-ancestor', revision, head]);
} catch { fail('project-read-commit', 'commit is not reachable from HEAD'); }
let metadata;
try { metadata = JSON.parse(await blob(repo, revision, 'project.json')); }
catch { fail('project-invalid', 'missing or invalid committed project metadata'); }
if (validateRecord('project', metadata).length || metadata.business !== business.id || metadata.project !== project)
fail('project-invalid', 'committed project identity does not match context');
const content = await blob(repo, revision, path);
const bytes = await gitBytes(repo, ['log', '-z', '--max-count=101',
'--format=%H%x00%an%x00%ae%x00%aI%x00%s', revision, '--', path], 256 * 1024);
let fields;
try { fields = new TextDecoder('utf-8', { fatal: true }).decode(bytes).split('\0'); }
catch { fail('project-git', 'invalid UTF-8 Git history'); }
if (fields.pop() !== '' || !fields.length || fields.length % 5 !== 0)
fail('project-git', 'invalid Git history');
const log = [];
for (let i = 0; i < fields.length; i += 5) {
const [commit, name, email, at, subject] = fields.slice(i, i + 5);
if (!SHA.test(commit) || !Number.isFinite(Date.parse(at))) fail('project-git', 'invalid Git history');
log.push({ commit, author: { name, email }, at: new Date(at).toISOString(), subject });
}
return { repo, path, head, revision, commit: log[0].commit, content, author: log[0].author,
log: log.slice(0, 100), truncated: log.length > 100 };
}
export async function readSpec(args) {
argumentsFor(args, ['business', 'project', 'dataRoot', 'commit']);
const result = await readProjectFile({ ...args, path: 'spec/north-star.md' });
return { ...result, confirmed: result.author.name === args.business.human };
}
+124
View File
@@ -0,0 +1,124 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync, readFileSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import * as api from '../src/index.mjs';
const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8' }).trim();
async function fixture(t) {
const root = mkdtempSync(join(tmpdir(), 'project-spec-read-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const context = { business: { id: 'demo', human: 'jason', projects: {
stack: { root: join(root, 'code'), repo: join(root, 'records') },
} }, project: 'stack', dataRoot: root };
const { repo, commit } = await api.initProject(context);
const save = (path, bytes, author = 'jason') => {
writeFileSync(join(repo, path), bytes);
git(repo, 'add', '--', path);
git(repo, '-c', `user.name=${author}`, '-c', '[email protected]', 'commit', '-m', 'Save ' + path);
return git(repo, 'rev-parse', 'HEAD');
};
return { context, repo, commit, save, root };
}
test('spec reads exact committed content and its path commit/log, not dirty bytes or a later docs commit', async t => {
const { context, repo, save, commit } = await fixture(t);
const spec = save('spec/north-star.md', '# Spec\n\nKeep this.\n\n');
const docs = save('docs/notes.md', '# Docs\n');
writeFileSync(join(repo, 'spec/north-star.md'), '# Unsaved human edit\n');
const result = await api.readSpec(context);
assert.equal(result.content, '# Spec\n\nKeep this.\n\n');
assert.equal(result.repo, repo);
assert.equal(result.path, 'spec/north-star.md');
assert.equal(result.head, docs); assert.equal(result.revision, docs); assert.equal(result.commit, spec);
assert.equal(result.confirmed, true);
assert.deepEqual(result.author, { name: 'jason', email: '[email protected]' });
assert.deepEqual(result.log.map(r => r.commit), [spec, commit]);
assert.equal(result.truncated, false);
assert.equal(readFileSync(join(repo, 'spec/north-star.md'), 'utf8'), '# Unsaved human edit\n');
assert.equal(git(repo, 'rev-parse', 'HEAD'), docs);
for (const row of result.log) assert.equal(new Date(row.at).toISOString(), row.at);
const historical = await api.readSpec({ ...context, commit });
assert.equal(historical.commit, commit);
assert.deepEqual(historical.log.map(r => r.commit), [commit]);
assert.equal(historical.content, readFileSync(new URL('../templates/north-star.md', import.meta.url), 'utf8'));
});
test('latest agent spec edit is visible but never confirmed or replaced by an earlier human version', async t => {
const { context, save } = await fixture(t);
const commit = save('spec/north-star.md', '# Agent\n', 'coder.builder');
const result = await api.readSpec(context);
assert.equal(result.commit, commit); assert.equal(result.confirmed, false);
assert.equal(result.author.name, 'coder.builder');
});
test('shared file reader exposes committed docs and refuses path/revision injection or another history', async t => {
const { context, repo, save } = await fixture(t);
const commit = save('docs/design.md', '# Design\n');
assert.equal((await api.readProjectFile({ ...context, path: 'docs/design.md' })).commit, commit);
for (const path of ['../outside.md', 'spec/other.md', '/spec/north-star.md', 'docs/a/b.md', 'requirements/R.json'])
await assert.rejects(api.readProjectFile({ ...context, path }), { code: 'project-read-path' });
for (const commit of ['HEAD', '--help', 'a'.repeat(40), 'abc', git(repo, 'write-tree')])
await assert.rejects(api.readSpec({ ...context, commit }), { code: 'project-read-commit' });
const orphan = git(repo, '-c', 'user.name=jason', '-c', '[email protected]',
'commit-tree', git(repo, 'write-tree'), '-m', 'Unreachable');
await assert.rejects(api.readSpec({ ...context, commit: orphan }), { code: 'project-read-commit' });
});
test('reader rejects missing paths, links, invalid UTF-8, NUL and oversized committed blobs', async t => {
const { context, repo, save } = await fixture(t);
await assert.rejects(api.readProjectFile({ ...context, path: 'docs/missing.md' }), { code: 'project-read-missing' });
for (const bytes of [Buffer.from([0xff]), 'x\0y', 'x'.repeat(1024 * 1024 + 1)]) {
save('spec/north-star.md', bytes);
await assert.rejects(api.readSpec(context), { code: 'project-read-content' });
}
rmSync(join(repo, 'spec/north-star.md'));
symlinkSync('../docs/README.md', join(repo, 'spec/north-star.md'));
git(repo, 'add', '--', 'spec/north-star.md');
git(repo, '-c', 'user.name=jason', '-c', '[email protected]', 'commit', '-m', 'Linked spec');
await assert.rejects(api.readSpec(context), { code: 'project-read-content' });
});
test('reader refuses mismatched committed project identity, unsafe config and unknown argument keys', async t => {
const { context, repo, save } = await fixture(t);
await assert.rejects(api.readSpec({ ...context, path: 'docs/README.md' }), { code: 'project-read-path' });
await assert.rejects(api.readSpec({ ...context, project: 'other' }), { code: 'project-not-declared' });
const metadata = JSON.parse(readFileSync(join(repo, 'project.json'))); metadata.business = 'other';
save('project.json', JSON.stringify(metadata));
await assert.rejects(api.readSpec(context), { code: 'project-invalid' });
git(repo, 'remote', 'add', 'origin', 'https://fixture.invalid/no-network');
await assert.rejects(api.readSpec(context), { code: 'project-remote' });
});
test('path history is bounded at 100 and explicitly marks truncation', async t => {
const { context, save } = await fixture(t);
for (let i = 0; i < 101; i++) save('spec/north-star.md', `# Spec ${i}\n`);
const result = await api.readSpec(context);
assert.equal(result.log.length, 100);
assert.equal(result.truncated, true);
assert.equal(result.log[0].commit, result.commit);
});
test('committed UTF-8 BOM, Unicode and trailing newlines are returned without normalization', async t => {
const { context, save } = await fixture(t);
const value = '\uFEFF# North star 🧭\r\n\r\n';
save('spec/north-star.md', Buffer.from(value));
assert.equal((await api.readSpec(context)).content, value);
});
test('D4: spec log refuses a commit subject larger than its 256 KiB read bound', async t => {
const root = mkdtempSync(join(tmpdir(), 'spec-log-bound-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const business = { id: 'demo', human: 'jason', projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') } } };
const { repo } = await api.initProject({ business, project: 'stack', dataRoot: root });
const message = join(root, 'commit-message.txt');
writeFileSync(message, 'X'.repeat(256 * 1024 + 1) + '\n');
writeFileSync(join(repo, 'spec/north-star.md'), '# Small spec\n');
execFileSync('git', ['-C', repo, 'add', '--', 'spec/north-star.md']);
execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgsign=false',
'-c', 'user.name=jason', '-c', '[email protected]', '-C', repo, 'commit', '-F', message],
{ stdio: ['ignore', 'ignore', 'pipe'] });
await assert.rejects(api.readSpec({ business, project: 'stack', dataRoot: root }), { code: 'project-git' });
});
+1 -1
View File
@@ -6,7 +6,7 @@
"tools": ["read", "grep", "find", "ls", "web"],
"network": "api-only",
"authority": {
"withinRole": ["message.send"],
"withinRole": ["message.send", "record.write"],
"crossRole": []
},
"credentials": [],
+1 -1
View File
@@ -6,7 +6,7 @@
"tools": ["read", "grep", "find", "ls"],
"network": "api-only",
"authority": {
"withinRole": ["message.send"],
"withinRole": ["message.send", "record.write"],
"crossRole": []
},
"credentials": [],
+1 -1
View File
@@ -6,7 +6,7 @@
"tools": ["read", "grep", "find", "ls"],
"network": "api-only",
"authority": {
"withinRole": ["message.send"],
"withinRole": ["message.send", "record.write"],
"crossRole": []
},
"credentials": [],
+1 -1
View File
@@ -6,7 +6,7 @@
"tools": ["read", "grep", "find", "ls"],
"network": "api-only",
"authority": {
"withinRole": ["message.send"],
"withinRole": ["message.send", "record.write"],
"crossRole": []
},
"credentials": [],
@@ -6,7 +6,7 @@
"tools": ["read", "grep", "find", "ls"],
"network": "api-only",
"authority": {
"withinRole": ["message.send"],
"withinRole": ["message.send", "record.write"],
"crossRole": []
},
"credentials": [],
+1 -1
View File
@@ -6,7 +6,7 @@
"tools": ["read", "grep", "find", "ls"],
"network": "api-only",
"authority": {
"withinRole": ["message.send"],
"withinRole": ["message.send", "record.write"],
"crossRole": []
},
"credentials": [],
+1 -1
View File
@@ -6,7 +6,7 @@
"tools": ["read", "grep", "find", "ls"],
"network": "api-only",
"authority": {
"withinRole": ["message.send", "role.launch", "role.end", "organization.change"],
"withinRole": ["message.send", "role.launch", "role.end", "organization.change", "record.write"],
"crossRole": []
},
"credentials": []
+1 -1
View File
@@ -6,7 +6,7 @@
"tools": ["read", "write", "edit", "bash", "grep", "find", "ls"],
"network": "api-only",
"authority": {
"withinRole": ["task.update.assigned", "git.push.working", "review.request", "message.send"],
"withinRole": ["task.update.assigned", "git.push.working", "review.request", "message.send", "record.write"],
"crossRole": ["task.scope.change"]
},
"credentials": [
+1 -1
View File
@@ -6,7 +6,7 @@
"tools": ["read", "write", "edit", "bash", "grep", "find", "ls"],
"network": "api-only",
"authority": {
"withinRole": ["review.request", "task.update.assigned", "message.send", "decision.resolve.technical"],
"withinRole": ["review.request", "task.update.assigned", "message.send", "decision.resolve.technical", "record.write"],
"crossRole": ["task.scope.change"]
},
"credentials": [
+1 -1
View File
@@ -6,7 +6,7 @@
"tools": ["read", "write", "edit", "grep", "find", "ls"],
"network": "api-only",
"authority": {
"withinRole": ["task.create", "task.assign", "task.reassign", "task.schedule", "task.update.assigned", "task.close", "message.send"],
"withinRole": ["task.create", "task.assign", "task.reassign", "task.schedule", "task.update.assigned", "task.close", "message.send", "record.write"],
"crossRole": ["task.priority.change", "task.scope.change"]
},
"credentials": [
+1 -1
View File
@@ -6,7 +6,7 @@
"tools": ["read", "grep", "find", "ls", "bash"],
"network": "api-only",
"authority": {
"withinRole": ["review.verdict", "message.send", "task.update.assigned"],
"withinRole": ["review.verdict", "message.send", "task.update.assigned", "record.write"],
"crossRole": []
},
"credentials": [