limits: a daily token quota for the account the stack uses, and bus schema 3h to 3i (row 126, #1620)

The business file can name the account claude-code launches use,
`launch.account` = {name, credential}. `name` is an identifier and
`credential` names one login set's environment variables, never a value.
`launch.limits.tokens.daily.<account>` = {limit, since, reset, timeZone}
sets a daily quota for that account. A daily key that isn't the account's
name refuses the business read.

The new plug-in limit-check/daily-quota (interface 2, pure) starts a day at
the first instant at or after `reset` on that local date in `timeZone`. Across
DST that makes a 23- or 25-hour day, and a reset in a skipped hour starts at
the gap's end. It counts the account's known records of all four kinds since
the later of the day start and `since`, and counts unknown turns apart.

The launcher refuses account-credential-missing, account-credential-ambiguous
and account-is-credential (an account name equal to a session credential's
value). Messages name variables, never values. The account rides mayLaunch,
bindLaunch, the usage records, mustStop and the launch record. A pi launch
has no account, records NULL, and no daily quota covers it. `mosaic limits`
and the Console's Settings show one fixed line when a daily row shows:
"Daily quotas count only this stack's own use of the account, not its use
anywhere else."

Bus schema 3h to 3i: 3h reserved usage_records.account as
CHECK (account IS NULL). The new step 3h-3i.sql rebuilds the table in one
transaction, keeping every row's seq and the sqlite_sequence value, and
recreates the three append-only triggers. All of sqlite_master equals 3h's
apart from the account line. A 3h engine refuses a 3i store.
packages/bus/tests/account-migration.test.mjs covers all three, with a
step that fails partway and leaves 3h intact. 3i goes live only through
`stack-release.sh activate ... --migrate` with Jason present (P19 371); the
activate path takes the pre-migrate backup, and the way back is restoring
it (agents/dewey/work/queue-126/MIGRATION.md).

Mos's go mos-0835, six conditions: (1) one transaction, account-migration
test 2 and mutant D29; (2) sqlite_master against 3h, test 1 and mutant D28;
(3) account is never a credential, the launcher's account-is-credential
refusal (launch-account.test.mjs) and the business name pattern
(token-limit-settings.test.mjs); (4) MIGRATION.md, accepted by Mos in
mos-0837; (5) the packet's DESIGN.md "no schema change" line corrected;
(6) the public/ digest below.

The public/ change in bus.js (the daily line under the token-limit table)
carries Mos's decision 123 go mos-0833 on the +/- digest 03e6d9da...ba78,
unchanged at landing.

Owner Dewey, brief docs/plans/2026-10-14_prd-1.2-build.md, "Daily quota for
the credential the stack uses today". Review round 2 rebases the round 4
build onto 1df04f52f (row 167). Reviewer: Filbert approve 29068 on round 1
(manifest d18314f7...8e65); Filbert approve 29080 on
round 2 (manifest f332db76...6fa6).
Seat run on round 2: touched 158/0, singles 1388/0 with 1 esbuild skip,
test-land-work.sh 86/0, 30/30 mutants killed.
Sage gate on 54319f552: GATE_PASSED, 2474+422/0, all 15 packages, the queue
suite and 10 script selftests.

Nothing live.
This commit is contained in:
2026-10-18 20:35:02 -05:00
parent 54319f5521
commit ca15d73e9f
39 changed files with 1334 additions and 54 deletions
+21 -6
View File
@@ -671,10 +671,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
## Schema and verification
`schema.sql` is v3g, SHA-256
`a79b3dbe6f4dfba10871c01ad8513ea290071eb8d8af2b87be6db52dbcf23428`.
It has 15 tables, 68 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3g`, WAL,
`schema.sql` is v3i, SHA-256
`6cde5232c57ac04dc6073112c0830c89cfc5e953d7a5727e7716263747fe8737`.
It has 16 tables, 71 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3i`, 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.
@@ -822,6 +822,18 @@ Schema 3h adds only the append-only usage_records table and its three protection
triggers. The 3g→3h step preserves all prior evidence; migration is explicit and
offline in Mos's re-pin window. The normal bus never auto-migrates an old store.
Schema 3i (row 126, #1620) drops the 3h reservation `account TEXT CHECK (account IS NULL)`
so a record can carry the run's account. SQLite cannot drop a CHECK, so the 3h→3i step
drops the three triggers, renames the table to usage_records_3h, creates usage_records
with text identical to schema.sql's, copies every row with its seq, carries the table's
sqlite_sequence value, drops the old table and recreates the three triggers. The rename
comes first because a renamed table's stored SQL quotes its new name, and the digest
compares that text. Every other column, CHECK and trigger is unchanged. The step runs in
one transaction; a failure leaves the store at 3h. It runs only through
`stack-release.sh activate … --migrate`, which backs up the store first; a 3h engine
refuses a 3i store. The 3h fixture, `tests/fixtures/schema-3h.sql`, is pinned from
81f26551b's tree; no live store is used.
The managed runner calls usage.begin before an actual harness turn and usage.end
after it, including a failed/stopped turn. Each takes only {turn: deliveredMessageId}.
The broker requires the bound role holder and its delivery, binds run identity,
@@ -838,6 +850,9 @@ It returns total plus byRun, byInstance, byProvider, byModel and byAccount array
Each total has input, output, cacheWrite, cacheRead and unknownTurns. Known
readings sum; unknown turns count distinctly by run and turn next to every total.
An exact aggregate beyond JavaScript's safe integer range is a decimal string,
not a rounded count or a policy ceiling. Account is null until account tracking
ships. A session can edit its own transcript: this is a best reading, not proof.
not a rounded count or a policy ceiling. Row 126: a run's account is the one
the launcher bound it with (`launch.account.name` for a Claude Code run), kept
on the session and written on each usage record; a pi run's is null. The
account stays out of `session.launched`'s body. `mayLaunch` and `mustStop`
pass it to the limit checks in the subject. A session can edit its own transcript: this is a best reading, not proof.
No enforcement or resets are added. Settings reads through the same bus view.
+1 -1
View File
@@ -344,7 +344,7 @@ CREATE TABLE usage_records (
instance TEXT NOT NULL,
provider TEXT,
model TEXT,
account TEXT CHECK (account IS NULL),
account TEXT,
turn TEXT NOT NULL,
source TEXT NOT NULL,
phase TEXT NOT NULL CHECK (phase IN ('begin','end')),
+5 -2
View File
@@ -412,7 +412,7 @@ export class Broker {
bindLaunch(record) {
keys(
record,
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'provider', 'model'],
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'provider', 'model', 'account'],
['business', 'role', 'run', 'harness'],
);
const b = this.#business(identifier(record.business));
@@ -422,6 +422,9 @@ export class Broker {
if (record.address !== undefined) string(record.address, 1024);
if (record.provider !== undefined) string(record.provider, 1024);
if (record.model !== undefined) string(record.model, 1024);
// Row 126: the account a claude-code run spends (launch.account.name); null for a pi run. The
// session keeps it for usage records and mustStop; session.launched's body doesn't carry it.
if (record.account !== undefined && record.account !== null) identifier(record.account);
// Row 121: a thread instance's run is for one thread, and no other run names one.
if (this.#threaded(record.business, record.role)) {
if (record.thread === undefined) fail('thread-required');
@@ -1287,7 +1290,7 @@ export class Broker {
if (!this.#limits) return;
let answer;
try {
answer = await this.#limits.mustStop(s.business, { instance: s.role, provider: s.provider ?? null, model: s.model ?? null });
answer = await this.#limits.mustStop(s.business, { instance: s.role, provider: s.provider ?? null, model: s.model ?? null, account: s.account ?? null });
} catch {
answer = { stop: true, check: null, code: 'check-threw' };
}
+2 -2
View File
@@ -26,10 +26,10 @@ const turns = value => Number.isSafeInteger(value) && value >= 0;
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
// Every token limit in launch.limits, in file order, at its schema depth only: tokens.instances.X,
// tokens.providers.X, tokens.keys.X and tokens.models.P.M. The agent limits (total, providers,
// tokens.providers.X, tokens.keys.X, tokens.models.P.M and tokens.daily.A (row 126). The agent limits (total, providers,
// models) are never checks' limits, whatever their names, so a provider called `limit` there isn't
// taken for one. The settings writer (business launch-settings.mjs) walks the same depths.
const DEPTH = { instances: 1, providers: 1, keys: 1, models: 2 };
const DEPTH = { instances: 1, providers: 1, keys: 1, models: 2, daily: 1 };
export function limitLeaves(limits) {
const out = [];
const walk = (value, depth, path) => {
+40
View File
@@ -0,0 +1,40 @@
-- Row 126 (#1620): usage records carry the account a claude-code run spends. Only the account
-- column's CHECK changes. SQLite can't drop a CHECK, so the table is rebuilt in this one step's
-- transaction. The old table is renamed first so the new CREATE TABLE text stays unquoted and
-- matches schema.sql; seq values are copied as they are, and the sequence carries over.
DROP TRIGGER usage_records_no_update;
DROP TRIGGER usage_records_no_delete;
DROP TRIGGER usage_records_no_replace;
ALTER TABLE usage_records RENAME TO usage_records_3h;
CREATE TABLE usage_records (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
run TEXT NOT NULL,
instance TEXT NOT NULL,
provider TEXT,
model TEXT,
account TEXT,
turn TEXT NOT NULL,
source TEXT NOT NULL,
phase TEXT NOT NULL CHECK (phase IN ('begin','end')),
status TEXT NOT NULL CHECK (status IN ('known','unknown')),
code TEXT,
cursor TEXT CHECK (cursor IS NULL OR json_valid(cursor)),
input INTEGER,
output INTEGER,
cache_write INTEGER,
cache_read INTEGER,
UNIQUE (business,run,turn,source,phase),
CHECK ((status='unknown' AND input IS NULL AND output IS NULL AND cache_write IS NULL AND cache_read IS NULL)
OR (status='known' AND input >= 0 AND output >= 0 AND cache_write >= 0 AND cache_read >= 0
AND input IS NOT NULL AND output IS NOT NULL AND cache_write IS NOT NULL AND cache_read IS NOT NULL))
) STRICT;
INSERT INTO usage_records(seq,at,business,run,instance,provider,model,account,turn,source,phase,status,code,cursor,input,output,cache_write,cache_read)
SELECT seq,at,business,run,instance,provider,model,account,turn,source,phase,status,code,cursor,input,output,cache_write,cache_read FROM usage_records_3h ORDER BY seq;
DELETE FROM sqlite_sequence WHERE name='usage_records';
INSERT INTO sqlite_sequence(name,seq) SELECT 'usage_records', seq FROM sqlite_sequence WHERE name='usage_records_3h';
DROP TABLE usage_records_3h;
CREATE TRIGGER usage_records_no_update BEFORE UPDATE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_delete BEFORE DELETE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_replace BEFORE INSERT ON usage_records WHEN EXISTS (SELECT 1 FROM usage_records WHERE seq=NEW.seq OR (business=NEW.business AND run=NEW.run AND turn=NEW.turn AND source=NEW.source AND phase=NEW.phase)) BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
+1 -1
View File
@@ -189,7 +189,7 @@ export async function startBroker({
threadsState: (business) => broker.threadsState(business),
threadIdle: (r) => broker.threadIdle(r),
// Row 125 (#1619, REQ-LIMIT-4): the launcher asks the limit checks before every launch.
mayLaunch: ({ business, instance, provider, model }) => limits.mayLaunch(business, { instance, provider, model }),
mayLaunch: ({ business, instance, provider, model, account = null }) => limits.mayLaunch(business, { instance, provider, model, account }),
identity: (cap) => broker.identity(cap),
authorizeLaunch,
checkPush,
+7 -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 = 'bb2ba58175a111bb99681778911507846bef714a6d573f8a8af67580a3636950';
export const SCHEMA_VERSION = '3h';
export const SCHEMA_SHA256 = '6cde5232c57ac04dc6073112c0830c89cfc5e953d7a5727e7716263747fe8737';
export const SCHEMA_VERSION = '3i';
// 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';
@@ -51,6 +51,11 @@ const migrations = [{
from: '3g',
to: '3h',
sql: readFileSync(new URL('./migrations/3g-3h.sql', import.meta.url), 'utf8'),
}, {
// Row 126 (#1620): usage records carry the run's account; the table is rebuilt without its CHECK.
from: '3h',
to: '3i',
sql: readFileSync(new URL('./migrations/3h-3i.sql', import.meta.url), 'utf8'),
}];
const timedTables = [
'events',
+3 -3
View File
@@ -62,7 +62,7 @@ export async function createUsage({ store, dataRoot, businesses, log }) {
// This map serializes retries per run, without keeping a SQL transaction across source awaits.
const pending = new Map();
async function perform(session, phase, turn) {
const { business, run, role: instance, harness, provider = null, model = null } = session;
const { business, run, role: instance, harness, provider = null, model = null, account = null } = session;
const existing = store.all('SELECT * FROM usage_records WHERE business=? AND run=? AND turn=? AND phase=?', business, run, turn, phase);
if (existing.length) return { recorded: true };
const loader = loaders.get(business);
@@ -91,8 +91,8 @@ export async function createUsage({ store, dataRoot, businesses, log }) {
const known = result.status === 'known';
const counts = phase === 'begin' ? zero() : result;
store.run(`INSERT INTO usage_records(at,business,run,instance,provider,model,account,turn,source,phase,status,code,cursor,input,output,cache_write,cache_read)
VALUES (?,?,?,?,?,?,NULL,?,?,?,?,?,?,?,?,?,?)`, new Date().toISOString(), business, run, instance,
provider, model, turn, name, phase, result.status, result.code ?? null,
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, new Date().toISOString(), business, run, instance,
provider, model, account, turn, name, phase, result.status, result.code ?? null,
phase === 'begin' ? JSON.stringify(result) : null,
known ? counts.input : null, known ? counts.output : null, known ? counts.cacheWrite : null, known ? counts.cacheRead : null);
}
@@ -0,0 +1,147 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createHash } from 'node:crypto';
import { DatabaseSync } from 'node:sqlite';
import { Store, schemaDigest } from '../src/store.mjs';
// Row 126 (#1620, REQ-LIMIT-4): the 3h→3i step rebuilds usage_records so its account column takes a
// name. It runs on a 3h store built here from the pinned 3h schema (81f26551b's schema.sql) and
// fixture rows. No live store is read or copied. Mos's go (mos-0835) asks for one transaction, a
// schema that differs from 3h only in the account CHECK, and a 3h engine that refuses a 3i store.
const AT = '2026-10-18T00:00:00.000Z';
const SEQUENCE = 50;
const ddl3h = readFileSync(new URL('./fixtures/schema-3h.sql', import.meta.url), 'utf8');
const here = (p) => new URL(p, import.meta.url);
function source3h(t) {
const root = mkdtempSync(join(tmpdir(), 'account-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);
assert.equal(createHash('sha256').update(ddl3h).digest('hex'), 'bb2ba58175a111bb99681778911507846bef714a6d573f8a8af67580a3636950');
db.exec(ddl3h);
const run = (sql, ...args) => db.prepare(sql).run(...args);
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3h');
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 instance_names(at,business,instance,name,by) VALUES(?,?,?,?,?)', AT, 'demo', 'coder', 'Builder', 'jason');
const usage = 'INSERT INTO usage_records(at,business,run,instance,provider,model,account,turn,source,phase,status,code,cursor,input,output,cache_write,cache_read) VALUES(?,?,?,?,?,?,NULL,?,?,?,?,?,?,?,?,?,?)';
run(usage, AT, 'demo', 'run1', 'coder', 'anthropic', 'opus', 't1', 'claude', 'begin', 'known', null, '{"input":1}', 0, 0, 0, 0);
run(usage, AT, 'demo', 'run1', 'coder', 'anthropic', 'opus', 't1', 'claude', 'end', 'known', null, null, 4, 5, 6, 7);
run(usage, AT, 'demo', 'run2', 'coder', null, null, 't1', 'claude', 'end', 'unknown', 'no-source', null, null, null, null, null);
// The sequence runs past the last seq, so the step must carry it, not rebuild it from the rows.
run("UPDATE sqlite_sequence SET seq=? WHERE name='usage_records'", SEQUENCE);
db.close(); chmodSync(path, 0o600);
return { root, path };
}
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 ${table === 'sqlite_sequence' ? 'name' : 'rowid'}`).all()
.filter(r => table !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key))]));
return { columns, rows };
} finally { db.close(); }
}
function master(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map(r => ({ ...r }));
} finally { db.close(); }
}
const version = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try { return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value; } finally { db.close(); }
};
// A copy of the bus module whose store.mjs, schema.sql and steps the caller edits.
async function engine(root, name, { store, schema, step = (s) => s }) {
const moduleRoot = join(root, name);
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
const source = readFileSync(here('../src/store.mjs'), 'utf8'), edited = store ? store(source) : source;
if (store) assert.notEqual(edited, source);
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
writeFileSync(join(moduleRoot, 'schema.sql'), schema ?? readFileSync(here('../schema.sql'), 'utf8'));
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql'])
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
writeFileSync(join(moduleRoot, 'src/migrations/3h-3i.sql'), step(readFileSync(here('../src/migrations/3h-3i.sql'), 'utf8')));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence carries over, and only the account CHECK changes', t => {
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3i');
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
assert.deepEqual({ ...before.rows.sqlite_sequence.find(r => r.name === 'usage_records') }, { name: 'usage_records', seq: SEQUENCE });
// sqlite_master as at 3h, the account line aside: same tables, indexes and triggers, same text.
const old = ' account TEXT CHECK (account IS NULL),\n';
assert.equal(was.filter(r => r.sql?.includes(old)).map(r => r.name).join(), 'usage_records');
assert.deepEqual(master(path), was.map(r => r.name === 'usage_records' ? { ...r, sql: r.sql.replace(old, ' account TEXT,\n') } : r));
assert.equal(was.filter(r => r.type === 'trigger' && r.tbl_name === 'usage_records').length, 3);
const store = new Store(root);
try {
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
// A new record takes an account, and its seq follows the carried sequence.
store.run(`INSERT INTO usage_records(at,business,run,instance,provider,model,account,turn,source,phase,status,cursor,input,output,cache_write,cache_read)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, AT, 'demo', 'run3', 'coder', 'anthropic', 'opus', 'jason-max', 't1', 'claude', 'begin', 'known', '{}', 0, 0, 0, 0);
assert.deepEqual({ ...store.get("SELECT seq,account FROM usage_records WHERE run='run3'") }, { seq: SEQUENCE + 1, account: 'jason-max' });
// Still append-only on 3i.
assert.throws(() => store.run("UPDATE usage_records SET account='other' WHERE seq=1"), /usage_records is append-only/);
assert.throws(() => store.run('DELETE FROM usage_records WHERE seq=1'), /usage_records is append-only/);
assert.throws(() => store.run(`INSERT OR REPLACE INTO usage_records(at,business,run,instance,turn,source,phase,status)
VALUES(?,?,?,?,?,?,?,?)`, AT, 'demo', 'run2', 'coder', 't1', 'claude', 'end', 'unknown'), /usage_records is append-only/);
assert.throws(() => store.run(`INSERT INTO usage_records(seq,at,business,run,instance,turn,source,phase,status)
VALUES(?,?,?,?,?,?,?,?,?)`, 1, AT, 'demo', 'run9', 'coder', 't1', 'claude', 'end', 'unknown'), /usage_records is append-only/);
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 4);
assert.equal(store.get("SELECT account FROM usage_records WHERE seq=1").account, null);
} finally { store.close(); }
});
test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with every row, the sequence and the schema intact', async t => {
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
// The real step with one statement added after the copy: it copies a row again, which only
// fails when there are rows, so the empty replay at import passes and the store's run fails
// after the triggers are dropped, the table renamed, and the new one made and filled.
const extra = 'INSERT INTO usage_records SELECT * FROM usage_records_3h LIMIT 1;\n';
const Failing = await engine(root, 'engine-failing', { step: (sql) => {
assert.equal(sql.split('DROP TABLE usage_records_3h;\n').length, 2);
return sql.replace('DROP TABLE usage_records_3h;\n', extra + 'DROP TABLE usage_records_3h;\n');
} });
assert.throws(() => new Failing(root, { migrateSchema: true }), /UNIQUE constraint failed: usage_records\.seq/);
assert.equal(version(path), '3h');
assert.deepEqual(master(path), was);
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
// Still a whole 3h store: the real step migrates it.
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3i');
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
});
test('REQ-LIMIT-4 the 3h engine refuses a 3i store, migrating or not, and leaves it at 3i', async t => {
const { root, path } = source3h(t);
new Store(root, { migrateSchema: true }).close();
const after = snapshot(path), was = master(path);
const Store3h = await engine(root, 'engine-3h', {
schema: ddl3h,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3i';", "export const SCHEMA_VERSION = '3h';")
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
assert.throws(() => new Store3h(root), /schema-mismatch/);
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3i');
assert.deepEqual(master(path), was);
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
});
+2 -2
View File
@@ -434,7 +434,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 = '3h';", "export const SCHEMA_VERSION = '3c';")
.replace("export const SCHEMA_VERSION = '3i';", "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');
@@ -458,7 +458,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, '3h');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
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 });
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
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, '3h');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
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;
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g 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 = '3h';", "export const SCHEMA_VERSION = '3i';")
.replace('}];\nconst timedTables', `}, { from: '3h', to: '3i', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
.replace("export const SCHEMA_VERSION = '3i';", "export const SCHEMA_VERSION = '3j';")
.replace('}];\nconst timedTables', `}, { from: '3i', to: '3j', 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', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql'])
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.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, '3i');
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
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 3h ready/);
assert.match(migrated.stdout, /bus schema 3i ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
@@ -0,0 +1,89 @@
// Row 126 (#1620, REQ-LIMIT-2, REQ-LIMIT-4): the account a run spends. bindLaunch keeps it on the
// session, out of session.launched's body; usage records carry it; mayLaunch and mustStop pass it
// to the shipped daily-quota through the real loader. A run with no account is null throughout.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
const SINCE = '2026-10-18T07:00:00.000Z';
const DAILY = ['tokens', 'daily', 'jason-max'];
const ROLES = { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } };
async function setup(t, limit = 0) {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'mosaic-daily-quota-')), rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo');
doc.plugins = { 'limit-check': ['daily-quota'] };
doc.launch.account = { name: 'jason-max', credential: 'CLAUDE_CODE_OAUTH_TOKEN' };
doc.launch.limits = { ...doc.launch.limits,
tokens: { daily: { 'jason-max': { limit, since: SINCE, reset: '05:00', timeZone: 'America/Chicago' } } } };
writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const bus = await startBroker({ dataRoot: join(root, 'data'),
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: ROLES, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
return { bus, human: bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
}
async function turn(broker, human, cap) {
const { id } = broker.request(human, { verb: 'message.send', args: { to: 'coder', body: 'work' } });
broker.request(cap, { verb: 'message.receive' });
await broker.request(cap, { verb: 'usage.begin', args: { turn: id } });
await broker.request(cap, { verb: 'usage.end', args: { turn: id } });
}
test('REQ-LIMIT-2 runtime: limits counts the daily quota, and mayLaunch refuses only the account at it', async t => {
const { bus, human } = await setup(t);
const [row] = bus.broker.request(human, { verb: 'limits' }).tokens;
assert.deepEqual({ path: row.path, limit: row.limit, since: row.since, status: row.status, check: row.check, count: row.count },
{ path: DAILY, limit: 0, since: SINCE, status: 'counted', check: 'daily-quota', count: 0 });
assert.deepEqual(await bus.mayLaunch({ business: 'demo', instance: 'coder', provider: 'anthropic', model: 'opus', account: 'jason-max' }),
{ allow: false, check: 'daily-quota', hit: { path: DAILY, limit: 0, count: 0, unknownTurns: 0 } });
// A pi launch has no account, so no daily quota covers it; the op may also leave it out.
assert.deepEqual(await bus.mayLaunch({ business: 'demo', instance: 'coder', provider: 'zai', model: 'glm', account: null }), { allow: true });
assert.deepEqual(await bus.mayLaunch({ business: 'demo', instance: 'coder', provider: 'zai', model: 'glm' }), { allow: true });
});
test('REQ-LIMIT-4 runtime: a run bound with the account writes it on its usage records and stops at the quota', async t => {
const { bus, human } = await setup(t);
const { broker } = bus;
const coder = broker.bindLaunch({ business: 'demo', role: 'coder', run: 'run1', harness: 'claude-code', provider: 'anthropic', model: 'opus', account: 'jason-max' });
broker.request(coder, { verb: 'role.claim' });
await turn(broker, human, coder);
assert.deepEqual(broker.request(human, { verb: 'usage' }).byAccount.map(r => r.account), ['jason-max']);
assert.throws(() => broker.request(coder, { verb: 'message.receive' }), { code: 'not-holder' });
assert.deepEqual(bus.endLaunch({ business: 'demo', run: 'run1', reason: 'exited', exitCode: 22 }),
{ released: false, limit: 'limit: daily-quota tokens.daily.jason-max limit 0 count 0' });
});
test('REQ-LIMIT-4 runtime: a run bound without an account records null and the daily quota never stops it', async t => {
const { bus, human } = await setup(t);
const { broker } = bus;
const coder = broker.bindLaunch({ business: 'demo', role: 'coder', run: 'run1', harness: 'pi', provider: 'zai', model: 'glm' });
broker.request(coder, { verb: 'role.claim' });
await turn(broker, human, coder);
assert.deepEqual(broker.request(human, { verb: 'usage' }).byAccount.map(r => r.account), [null]);
assert.deepEqual(broker.request(coder, { verb: 'message.receive' }), []);
});
test('REQ-LIMIT-4 bindLaunch keeps the account off session.launched, refuses one that is not an identifier, and takes null as none', t => {
const root = mkdtempSync(join(tmpdir(), 'mosaic-daily-bind-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
roles: ROLES, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } } });
const base = { business: 'demo', role: 'coder', harness: 'claude-code', provider: 'anthropic', model: 'opus' };
for (const [i, account] of ['Jason Max', '', 5, {}, '-x'].entries())
assert.throws(() => broker.bindLaunch({ ...base, run: `bad${i}`, account }), { code: 'invalid-request' }, JSON.stringify(account));
assert.ok(broker.bindLaunch({ ...base, run: 'none', account: null }));
assert.ok(broker.bindLaunch({ ...base, run: 'run1', account: 'jason-max' }));
// The body is what it was before row 126, so a rebind after a broker restart compares the same.
const { body } = store.get("SELECT body FROM events WHERE kind='session.launched' AND subject='run1'");
assert.deepEqual(JSON.parse(body), { harness: 'claude-code', address: null, pid: null, startTime: null });
});
+365
View File
@@ -0,0 +1,365 @@
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)
, spec_path TEXT, spec_commit TEXT) 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
, to_name TEXT) 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;
CREATE TABLE instance_names (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
instance TEXT NOT NULL,
name TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE TRIGGER instance_names_unique BEFORE INSERT ON instance_names
WHEN EXISTS (SELECT 1 FROM instance_names n WHERE n.business = NEW.business AND n.name = NEW.name
AND n.instance <> NEW.instance
AND n.seq = (SELECT max(seq) FROM instance_names WHERE business = n.business AND instance = n.instance))
BEGIN SELECT RAISE(ABORT, 'an instance name is unique in its business'); END;
CREATE TRIGGER instance_names_no_update BEFORE UPDATE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
CREATE TRIGGER instance_names_no_delete BEFORE DELETE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
CREATE TRIGGER instance_names_no_replace BEFORE INSERT ON instance_names WHEN EXISTS (SELECT 1 FROM instance_names WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
-- Row 124 (#1618): immutable source snapshots and turn outcomes, never transcript text.
CREATE TABLE usage_records (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
run TEXT NOT NULL,
instance TEXT NOT NULL,
provider TEXT,
model TEXT,
account TEXT CHECK (account IS NULL),
turn TEXT NOT NULL,
source TEXT NOT NULL,
phase TEXT NOT NULL CHECK (phase IN ('begin','end')),
status TEXT NOT NULL CHECK (status IN ('known','unknown')),
code TEXT,
cursor TEXT CHECK (cursor IS NULL OR json_valid(cursor)),
input INTEGER,
output INTEGER,
cache_write INTEGER,
cache_read INTEGER,
UNIQUE (business,run,turn,source,phase),
CHECK ((status='unknown' AND input IS NULL AND output IS NULL AND cache_write IS NULL AND cache_read IS NULL)
OR (status='known' AND input >= 0 AND output >= 0 AND cache_write >= 0 AND cache_read >= 0
AND input IS NOT NULL AND output IS NOT NULL AND cache_write IS NOT NULL AND cache_read IS NOT NULL))
) STRICT;
CREATE TRIGGER usage_records_no_update BEFORE UPDATE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_delete BEFORE DELETE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_replace BEFORE INSERT ON usage_records WHEN EXISTS (SELECT 1 FROM usage_records WHERE seq=NEW.seq OR (business=NEW.business AND run=NEW.run AND turn=NEW.turn AND source=NEW.source AND phase=NEW.phase)) BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
+1 -1
View File
@@ -535,7 +535,7 @@ test('usage.begin with a decision id needs it held for the run\'s instance, usag
['begin', 'end'].map((phase) => ({ run: 'merge-gate-run', instance: 'merge-gate', turn: open.id, phase })),
);
assert.equal(asked.length, 1);
assert.deepEqual(asked[0].subject, { instance: 'merge-gate', provider: null, model: null });
assert.deepEqual(asked[0].subject, { instance: 'merge-gate', provider: null, model: null, account: null });
assert.deepEqual(asked[0].turns.map((r) => [r.run, r.turn, r.instance]), [['merge-gate-run', open.id, 'merge-gate']]);
// Spent, a second landing turn's begin refuses.
await assert.rejects(usage('merge-gate', 'begin', open.id), { code: 'not-recipient' }, 'spent');
+1 -1
View File
@@ -58,7 +58,7 @@ test('REQ-LIMIT-4 a role run at its limit stops at the end of its turn: RESULT a
assert.deepEqual(broker.request(coder, { verb: 'message.receive' }), []);
// At the limit: the turn's reply and read receipt still land.
const id = await turn(broker, human, coder, send);
assert.deepEqual(asked, [1, 2].map(() => ({ business: 'demo', subject: { instance: 'coder', provider: 'anthropic', model: 'opus' } })));
assert.deepEqual(asked, [1, 2].map(() => ({ business: 'demo', subject: { instance: 'coder', provider: 'anthropic', model: 'opus', account: null } })));
const reply = broker.request(coder, { verb: 'message.send', args: { to: 'human', body: 'done', class: 'RESULT', in_reply_to: id } });
assert.ok(reply.id);
broker.request(coder, { verb: 'message.read', args: { id } });
+1 -1
View File
@@ -55,7 +55,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
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, '3h');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
+1 -1
View File
@@ -53,7 +53,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
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, '3h');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
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'), []);
+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 = '3h';", "export const SCHEMA_VERSION = '3d';")
.replace("export const SCHEMA_VERSION = '3i';", "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);
@@ -101,7 +101,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, '3h');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
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.
@@ -161,5 +161,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 3h ready', ''].join('\n'));
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3i ready', ''].join('\n'));
});
+2 -2
View File
@@ -51,12 +51,12 @@ function snapshot(path, columns) {
return { columns, rows };
} finally { db.close(); }
}
test('REQ-LIMIT-4 3g copy migrates to 3h preserving every old column, row and sequence; new usage table is empty', t => {
test('REQ-LIMIT-4 3g copy migrates through 3h to 3i preserving every old column, row and sequence; new usage table is empty', t => {
const { root, path, copy } = source3g(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, '3h');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
+9
View File
@@ -107,6 +107,15 @@ instance whose definition holds `role.launch` within-role; otherwise the
file refuses. Only that instance keeps `role.launch`. Every other instance
loses it, so for them it's gated.
`launch.account` (optional, row 126) is `{name, credential}`: the account
the business's Claude Code launches spend, an identifier, and the
environment variables of one login set joined with `+` (for example
`CLAUDE_CODE_OAUTH_TOKEN`), never a value. Which sets exist is the
launcher's check. `launch.limits.tokens.daily` maps an account to
`{limit, since, reset, timeZone}`: `reset` is `HH:MM`, `timeZone` an IANA
name `Intl` accepts. A daily quota for any name but `launch.account.name`
refuses the file, since it would count nothing.
`launch.threadIdleSeconds` (optional, row 121) is how long a thread's
session runs with nothing pending and no new message or open before the
launcher stops it. Absent means 900 (`THREAD_IDLE_SECONDS` in
+9 -3
View File
@@ -5,7 +5,7 @@
import { dirname, isAbsolute, join, normalize } from "node:path";
import { homedir } from "node:os";
import { refuse } from "./errors.mjs";
import { checkModels, checkLimits } from "./launch.mjs";
import { checkModels, checkLimits, checkAccount } from "./launch.mjs";
import { checkVars } from "./vars.mjs";
import { parseCredentialRef } from "./credentials.mjs";
import { loadRole } from "./role.mjs";
@@ -118,7 +118,7 @@ function checkLaunch(launch, roles, definitions, file) {
const where = `${file} launch`;
requireObject(launch, where);
if (Object.hasOwn(launch,"max")) refuse(`${where}.max needs migration: mosaic limits migrate --file FILE --models FILE`);
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds"], where);
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account"], where);
requireId(launch.by, `${where}.by`);
if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`);
const launcher = definitions[roles[launch.by].definition];
@@ -133,10 +133,16 @@ function checkLaunch(launch, roles, definitions, file) {
const models=checkModels(launch.models,`${where}.models`);
const limits=checkLimits(launch.limits===undefined?{}:launch.limits,`${where}.limits`);
for(const instance of Object.keys(limits.tokens?.instances??{}))if(!Object.hasOwn(roles,instance))refuse(`${where}.limits.tokens.instances names ${instance}, which the business file doesn't declare`);
// Row 126 (#1620): a daily quota for any account but launch.account would count nothing and still
// show as counted, so it refuses the read.
const account = launch.account === undefined ? undefined : checkAccount(launch.account, `${where}.account`);
for (const name of Object.keys(limits.tokens?.daily ?? {})) {
if (name !== account?.name) refuse(`${where}.limits.tokens.daily names ${name}, which isn't ${where}.account.name`);
}
for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} selects unknown model ${role.vars.model}`);
// No upper bound: limits have no ceiling in code (Q150).
const threadIdleSeconds = launch.threadIdleSeconds === undefined ? THREAD_IDLE_SECONDS : requirePositiveInt(launch.threadIdleSeconds, `${where}.threadIdleSeconds`);
return { by: launch.by, instances, models, limits, threadIdleSeconds };
return { by: launch.by, instances, models, limits, threadIdleSeconds, ...(account ? { account } : {}) };
}
// Validate a parsed business document read from `file`. `rolesDir` is where
+2 -2
View File
@@ -18,10 +18,10 @@ const own = (value, key) => value !== null && typeof value === "object" && !Arra
const plain = value => value !== null && typeof value === "object" && !Array.isArray(value);
// Row 125 (#1619, REQ-LIMIT-3): token limits sit only at their schema depth, tokens.instances.X,
// tokens.providers.X, tokens.keys.X and tokens.models.P.M. Nothing else in launch.limits is one,
// tokens.providers.X, tokens.keys.X, tokens.models.P.M and (row 126) tokens.daily.X. Nothing else in launch.limits is one,
// so a concurrency provider or model named `limit`, or a token limit's subject named `limit`, is
// never mistaken for a leaf. The bus core's limitLeaves walks the same depths.
const DEPTH = { instances: 1, providers: 1, keys: 1, models: 2 };
const DEPTH = { instances: 1, providers: 1, keys: 1, models: 2, daily: 1 };
function tokenLeaves(limits) {
const out = [];
const walk = (value, depth, path) => {
+36 -2
View File
@@ -34,15 +34,33 @@ const tokenLimit = (value, at) => {
new Date(since).toISOString() !== since) refuse(`${at}.since must be a canonical timestamp`);
return { limit: count(value.limit, `${at}.limit`), since };
};
// Row 126 (#1620, REQ-LIMIT-2): a daily quota is a token limit with the wall-clock time its day
// starts and the IANA zone that time is read in. A zone must be a name (an offset such as +05:00
// is refused) that Intl accepts.
const RESET = /^([01]\d|2[0-3]):[0-5]\d$/;
const ZONE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/;
const dailyQuota = (value, at) => {
requireObject(value, at);
rejectUnknownKeys(value, ["limit", "since", "reset", "timeZone"], at);
const { limit, since } = tokenLimit({ limit: value.limit, since: value.since }, at);
if (typeof value.reset !== "string" || !RESET.test(value.reset)) refuse(`${at}.reset must be a time of day, HH:MM`);
const timeZone = value.timeZone;
let known = typeof timeZone === "string" && ZONE.test(timeZone);
if (known) {
try { new Intl.DateTimeFormat("en-US", { timeZone }); } catch { known = false; }
}
if (!known) refuse(`${at}.timeZone must be an IANA time zone name`);
return { limit, since, reset: value.reset, timeZone };
};
const tokenMap = (value, at, key, each) => {
requireObject(value, at);
return Object.fromEntries(Object.entries(value).map(([k, v]) => [key(k, at), each(v, `${at}.${k}`)]));
};
// launch.limits.tokens: instances, providers, models (per provider) and keys, each optional.
// launch.limits.tokens: instances, providers, models (per provider), keys and daily, each optional.
export function checkTokenLimits(value, where = "launch.limits.tokens") {
requireObject(value, where);
rejectUnknownKeys(value, ["instances", "providers", "models", "keys"], where);
rejectUnknownKeys(value, ["instances", "providers", "models", "keys", "daily"], where);
const out = {};
if (Object.hasOwn(value, "instances")) out.instances = tokenMap(value.instances, `${where}.instances`, requireId, tokenLimit);
if (Object.hasOwn(value, "providers")) out.providers = tokenMap(value.providers, `${where}.providers`, name, tokenLimit);
@@ -51,9 +69,25 @@ export function checkTokenLimits(value, where = "launch.limits.tokens") {
}
// A key is a name until row 126 names the accounts.
if (Object.hasOwn(value, "keys")) out.keys = tokenMap(value.keys, `${where}.keys`, requireId, tokenLimit);
// One quota per account; the business read checks each key names launch.account.
if (Object.hasOwn(value, "daily")) out.daily = tokenMap(value.daily, `${where}.daily`, requireId, dailyQuota);
return out;
}
// Row 126 (#1620): the account a business's claude-code launches use. `credential` names the
// environment variables of one credential set, joined with "+", so a secret can't pass the check.
// Which sets exist is the launcher's to check (seat imports business, not the other way).
const CREDENTIAL = /^[A-Z][A-Z0-9_]*(\+[A-Z][A-Z0-9_]*)*$/;
export function checkAccount(value, where = "launch.account") {
requireObject(value, where);
rejectUnknownKeys(value, ["name", "credential"], where);
const accountName = requireId(value.name, `${where}.name`);
if (typeof value.credential !== "string" || !CREDENTIAL.test(value.credential)) {
refuse(`${where}.credential must name environment variables, joined with "+"`);
}
return { name: accountName, credential: value.credential };
}
export function checkLimits(value = {}, where = "launch.limits") {
requireObject(value, where);
rejectUnknownKeys(value, ["total", "providers", "models", "tokens"], where);
@@ -101,3 +101,52 @@ test('REQ-LIMIT-3 a reset moves since to now, is logged with its actor, and refu
assert.throws(()=>f.settings.set('acme',{revision:view.revision,reset},'jason'),{code:'invalid-request'},JSON.stringify(reset));
assert.equal(readFileSync(f.file,'utf8'),bytes);
});
// Row 126 (#1620, REQ-LIMIT-2): launch.account and tokens.daily.<account>.
const ACCOUNT={name:'max',credential:'CLAUDE_CODE_OAUTH_TOKEN'};
const DAILY={limit:40,since:T0,reset:'05:00',timeZone:'America/Chicago'};
test('REQ-LIMIT-2 schema: launch.account names a credential set; a daily quota needs a reset and an IANA zone and names the account',()=>{
const f=fixture(),read=(launch)=>{const d=structuredClone(f.doc);Object.assign(d.launch,launch);return validateBusinessDocument(d,f.file,{rolesDir:f.rolesDir}).launch;};
// No account: the launch reads as before, with no account key.
assert.equal(Object.hasOwn(read({}),'account'),false);
for(const credential of ['CLAUDE_CODE_OAUTH_TOKEN','ANTHROPIC_API_KEY','ANTHROPIC_AUTH_TOKEN+ANTHROPIC_BASE_URL'])
assert.deepEqual(read({account:{name:'max',credential}}).account,{name:'max',credential});
const ok=read({account:ACCOUNT,limits:{tokens:{daily:{max:DAILY}}}});
assert.deepEqual(ok.limits.tokens.daily,{max:DAILY});
for(const timeZone of ['UTC','Etc/GMT+5','America/Argentina/Buenos_Aires'])
assert.equal(read({account:ACCOUNT,limits:{tokens:{daily:{max:{...DAILY,timeZone}}}}}).limits.tokens.daily.max.timeZone,timeZone);
const bad=[
{account:{name:'max'}},{account:{credential:'ANTHROPIC_API_KEY'}},{account:{name:'Max Account',credential:'ANTHROPIC_API_KEY'}},
{account:{name:'max',credential:'anthropic_api_key'}},{account:{name:'max',credential:'sk-ant-api03-abc'}},
{account:{name:'max',credential:'ANTHROPIC_API_KEY+'}},{account:{name:'max',credential:'ANTHROPIC_API_KEY',key:'x'}},{account:'max'},
];
for(const reset of ['5:00','24:00','05:60','05:00:00','',undefined,500])bad.push({account:ACCOUNT,limits:{tokens:{daily:{max:{...DAILY,reset}}}}});
for(const timeZone of ['Chicago time','+05:00','-0600','Mars/Phobos','America/Chicago ','',undefined,5])bad.push({account:ACCOUNT,limits:{tokens:{daily:{max:{...DAILY,timeZone}}}}});
bad.push({account:ACCOUNT,limits:{tokens:{daily:{max:{...DAILY,limit:-1}}}}},{account:ACCOUNT,limits:{tokens:{daily:{max:{...DAILY,since:'2026-10-18'}}}}},
{account:ACCOUNT,limits:{tokens:{daily:{max:{...DAILY,extra:1}}}}},{account:ACCOUNT,limits:{tokens:{daily:{max:{limit:40,since:T0}}}}});
// A daily quota for another account, or with no account at all, would count nothing.
bad.push({account:ACCOUNT,limits:{tokens:{daily:{other:DAILY}}}},{limits:{tokens:{daily:{max:DAILY}}}});
for(const launch of bad)assert.throws(()=>read(launch),JSON.stringify(launch));
// The credential check's refusal names the field, never the value given.
assert.throws(()=>read({account:{name:'max',credential:'sk-ant-api03-abc'}}),e=>/launch\.account\.credential/.test(e.message)&&!e.message.includes('sk-ant'));
assert.throws(()=>read({account:ACCOUNT,limits:{tokens:{daily:{other:DAILY}}}}),/daily names other/);
});
test('REQ-LIMIT-2 the writer stamps a daily quota\'s since, keeps its reset and zone, and a reset moves since',()=>{
const f=fixture();
f.doc.launch.account=ACCOUNT;writeJson(f.file,f.doc);
let view=f.settings.read('acme');
view=f.settings.set('acme',{revision:view.revision,limits:{tokens:{daily:{max:{limit:40,reset:'05:00',timeZone:'America/Chicago'}}}}},'jason');
assert.deepEqual(stored(f),{daily:{max:{limit:40,reset:'05:00',timeZone:'America/Chicago',since:T0}}});
f.tick(T1);
view=f.settings.set('acme',{revision:view.revision,limits:{tokens:{daily:{max:{limit:30,reset:'06:00',timeZone:'UTC'}}}}},'jason');
assert.deepEqual(stored(f),{daily:{max:{limit:30,reset:'06:00',timeZone:'UTC',since:T0}}});
assert.throws(()=>f.settings.set('acme',{revision:view.revision,limits:{tokens:{daily:{max:{limit:30,reset:'06:00',timeZone:'UTC',since:T2}}}}},'jason'),{code:'invalid-request'});
f.tick(T2);
view=f.settings.set('acme',{revision:view.revision,reset:[['tokens','daily','max']]},'chief');
assert.equal(stored(f).daily.max.since,T2);
assert.equal(lastLog(f).actor,'chief');
assert.throws(()=>f.settings.set('acme',{revision:view.revision,reset:[['tokens','daily']]},'jason'),{code:'invalid-request'});
// The account itself stays a hand edit: the file's account is untouched by the writes.
assert.deepEqual(JSON.parse(readFileSync(f.file,'utf8')).launch.account,ACCOUNT);
});
+10
View File
@@ -341,6 +341,9 @@ is `{"ok": true, "result": {run, instance, harness, model}}` or
| the instance resolves to `pi` or `claude-code` and a model | `unsupported-harness` |
| its role has a contract file | `no-role-contract` |
| a Claude Code session has `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, or `ANTHROPIC_AUTH_TOKEN` with `ANTHROPIC_BASE_URL` to log in with | `no-session-credential` |
| with `launch.account` set, its name is not the value of any of those variables the bus host sets, since usage records keep the name | `account-is-credential` |
| with `launch.account` set, a Claude Code session's account names one of those login sets and the bus host holds all of it | `account-credential-missing` |
| with `launch.account` set, the bus host sets no other of those variables, `ANTHROPIC_BASE_URL` included | `account-credential-ambiguous` |
| bwrap runs here | `sandbox-unavailable` |
| its model selects an exact entry in `launch.models` | `unsupported-harness` |
| total, provider and exact provider/model limits have room; the PM counts too | `capacity-full` |
@@ -348,6 +351,13 @@ is `{"ok": true, "result": {run, instance, harness, model}}` or
| the workspace has a clone of each project, or the host can make one | `checkout-failed`, with the project id |
| the bundle builds and the runner starts | `launch-failed` |
Row 126 (#1620): only a Claude Code launch spends `launch.account.name`. It
goes to the `mayLaunch` limit checks, to the bus with the run, onto the
run's usage records, and onto the `launch` record as `account`. A pi launch
has account null: its records say null, no daily quota covers it, and the
two account checks above are skipped. Both account refusals name the
variables, never a value.
Every refusal is an `action.refused` event (the broker's `refuse`, or its
own refusal for the last broker check) and a line in the launch log. Then
the launcher reads the instance's credential status from the broker
+20 -6
View File
@@ -243,6 +243,20 @@ export function createLauncher({
const names = SESSION_CREDENTIALS.map((set) => set.join(" with ")).join(", ");
throw new Refusal("no-session-credential", `the bus host has none of ${names}; the bus unit's EnvironmentFile sets one when the bus starts`);
}
// Row 126 (#1620): only a claude-code launch spends the named account; a pi launch has none.
// The bus environment must hold the named set and no other credential variable, so the session
// can't log in some other way. Messages name variables, never values.
const account = model.harness === "claude-code" ? b.launch.account?.name ?? null : null;
if (account !== null) {
// The usage records keep this name (bus schema 3i), so it must never be a credential's value.
const holds = [...new Set(SESSION_CREDENTIALS.flat())].filter((k) => env[k] === account);
if (holds.length) throw new Refusal("account-is-credential", `launch.account.name is the value of ${holds.join(", ")}; it names the account, never its credential`);
const named = b.launch.account.credential.split("+");
if (!SESSION_CREDENTIALS.some((set) => set.join("+") === named.join("+")) || !named.every((k) => Boolean(env[k])))
throw new Refusal("account-credential-missing", `account ${account} names ${named.join(" with ")}, which the bus host doesn't hold as a session login`);
const other = [...new Set(SESSION_CREDENTIALS.flat())].filter((k) => !named.includes(k) && Boolean(env[k]));
if (other.length) throw new Refusal("account-credential-ambiguous", `account ${account} names ${named.join(" with ")}, but the bus host also sets ${other.join(", ")}`);
}
const unavailable = sandboxProblem({ bwrap });
if (unavailable) throw new Refusal("sandbox-unavailable", unavailable);
const hit=limitHit(registry.sessions,b.id,b.launch.limits,model);
@@ -251,7 +265,7 @@ export function createLauncher({
// and so does a bus that can't answer (fail closed, lead decision 150).
let may;
try {
may = await host.op({ op: "mayLaunch", business: b.id, instance, provider: model.provider, model: model.model });
may = await host.op({ op: "mayLaunch", business: b.id, instance, provider: model.provider, model: model.model, account });
} catch (e) {
throw new Refusal("limit-check-failed", `bus: ${e.code ?? "launch-op-refused"}`);
}
@@ -259,11 +273,11 @@ export function createLauncher({
if (may?.hit) { const error = new Refusal("limit-reached", JSON.stringify(may.hit)); error.limit = { check: may.check, ...may.hit }; throw error; }
throw new Refusal("limit-check-failed", `${may?.check ?? "bus"}: ${may?.code ?? "malformed-answer"}`);
}
return { resolved, model };
return { resolved, model, account };
}
// Bundle, spawn, bind, register, hand over the capability.
async function start({ instance, launchedBy, resolved, model, thread = null }) {
async function start({ instance, launchedBy, resolved, model, account = null, thread = null }) {
// A thread's store is checked first, so a bad id or an unsafe directory
// launches nothing.
let store = null;
@@ -293,7 +307,7 @@ export function createLauncher({
if (c.outcome === "kept") record({ event: "checkout-kept", instance, project: c.project, reason: c.reason });
else if (c.outcome === "fetched") record({ event: "checkout-fetched", instance, project: c.project, reason: c.reason });
}
({resolved,model}=await check(instance,{pm:instance===b.launch.by,thread}));
({resolved,model,account}=await check(instance,{pm:instance===b.launch.by,thread}));
const { run, runDir } = newRun(dataRoot, b.id);
const sessionDir = store ? store.session : join(runDir, "session");
if (!store) mkdirSync(sessionDir, { mode: 0o700 });
@@ -369,7 +383,7 @@ export function createLauncher({
const outerStart = startTimeOf(child.pid);
const runner = await runnerOf(child);
if (!outerStart) throw new Error("the session process exited at once");
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, pid: child.pid, startTime: outerStart, ...(thread ? { thread } : {}) });
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, pid: child.pid, startTime: outerStart, ...(thread ? { thread } : {}) });
bound = true;
entry = {
business: b.id, instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey,
@@ -404,7 +418,7 @@ export function createLauncher({
refusedRelease.delete(run);
}
});
record({ event: "launch", instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey, pid: child.pid, runnerPid: entry.runnerPid, manifest });
record({ event: "launch", instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey, account, pid: child.pid, runnerPid: entry.runnerPid, manifest });
return { run, instance, harness: model.harness, model: model.model };
}
+5 -1
View File
@@ -11,6 +11,8 @@ export const LIMITS_USAGE = "mosaic limits [--business ID] [--json] | set --file
"reset PATH... [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " +
"PATCH keys: revision, limits, models, instances, reset, assistants";
export const DAILY_NOTE = "Daily quotas count only this stack's own use of the account, not its use anywhere else.";
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): one line per token limit, its path in the words
// `mosaic limits reset` takes. A limit no switched-on check counts reads "not enforced".
export function formatTokens(tokens) {
@@ -24,7 +26,9 @@ export function formatTokens(tokens) {
if (t.status === "not-enforced") return `${head}, not enforced${t.reason ? `: ${t.reason}` : ""}`;
return `${head}, check failed: ${t.code}${t.check ? ` (${t.check})` : ""}`;
});
return `token limits:\n${lines.map(l => ` ${l}`).join("\n")}\n`;
// Row 126 (#1620): a daily quota counts only the records this stack wrote. Said once, after the list.
const daily = tokens.some(t => t.path?.[1] === "daily") ? `${DAILY_NOTE}\n` : "";
return `token limits:\n${lines.map(l => ` ${l}`).join("\n")}\n${daily}`;
}
export function parseLimits(args) {
+109
View File
@@ -0,0 +1,109 @@
// Row 126 (#1620, REQ-LIMIT-2): the account a launch spends. Only a claude-code launch carries
// launch.account.name; the bus environment must hold the named login set and no other credential
// variable, and the refusals name variables, never values. A pi launch has account null and skips
// both checks. A stub host answers the ops; no sandbox, Docker or session process.
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {createLauncher} from '../src/launcher.mjs';
import {fixture,tmp} from './helpers.mjs';
import {launchLogFile,SESSION_CREDENTIALS} from '../../seat/src/session.mjs';
const VALUE='stand-in-value-7f3a';
const ACCOUNT={name:'jason-max',credential:'CLAUDE_CODE_OAUTH_TOKEN'};
// set is the credential variables the bus environment holds; every other one is removed.
function setup(t,{harness='claude-code',account=ACCOUNT,set=['CLAUDE_CODE_OAUTH_TOKEN']}={}){
const root=tmp(t),f=fixture(root,'acme',doc=>{
doc.roles.coder.vars.model='claude-sonnet-5-5';doc.roles.coder.vars.harness=harness;
if(account)doc.launch.account=account;
return doc;
}),ops=[];
for(const k of SESSION_CREDENTIALS.flat())delete f.env[k];
for(const k of set)f.env[k]=VALUE;
const host={business:'acme',beforeClose:()=>{},op:async x=>{
ops.push(x);
if(x.op==='identity')return {business:'acme',role:'pm'};
if(x.op==='mayLaunch')return {allow:true};
if(x.op==='authorizeLaunch')throw Object.assign(Error('fixture authorization stop'),{code:'fixture-policy'});
return {};
}};
const launcher=createLauncher({host,system:JSON.parse(readFileSync(f.config,'utf8')),env:f.env,bwrap:'/usr/bin/true',log:()=>{}});
const last=()=>JSON.parse(readFileSync(launchLogFile(f.dataRoot,'acme'),'utf8').trim().split('\n').at(-1));
const may=()=>ops.find(x=>x.op==='mayLaunch');
return {launcher,ops,last,may};
}
test('REQ-LIMIT-2 a claude-code launch asks mayLaunch with launch.account.name',async t=>{
const {launcher,may}=setup(t);
assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy');
assert.equal(may().account,'jason-max');
});
test('REQ-LIMIT-2 each login set the account can name is accepted when the bus host holds exactly it',async t=>{
for(const set of SESSION_CREDENTIALS){
const {launcher,may}=setup(t,{account:{name:'jason-max',credential:set.join('+')},set});
assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy',set.join('+'));
assert.equal(may().account,'jason-max');
}
});
test('REQ-LIMIT-2 account-credential-missing: the named set is not a login set, or the bus host lacks part of it',async t=>{
for(const [credential,set] of [
['ANTHROPIC_API_KEY',['CLAUDE_CODE_OAUTH_TOKEN']],
// The host holds a login (OAuth), so no-session-credential passes; the named set lacks its URL.
['ANTHROPIC_AUTH_TOKEN+ANTHROPIC_BASE_URL',['CLAUDE_CODE_OAUTH_TOKEN','ANTHROPIC_AUTH_TOKEN']],
['ANTHROPIC_AUTH_TOKEN',['ANTHROPIC_AUTH_TOKEN','ANTHROPIC_BASE_URL']],
['ANTHROPIC_BASE_URL+ANTHROPIC_AUTH_TOKEN',['ANTHROPIC_AUTH_TOKEN','ANTHROPIC_BASE_URL']],
]){
const {launcher,ops,last}=setup(t,{account:{name:'jason-max',credential},set});
assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'account-credential-missing'},credential);
const event=last();
assert.equal(event.code,'account-credential-missing');
assert.match(event.why,new RegExp(`^account jason-max names ${credential.split('+').join(' with ')},`));
assert.ok(!JSON.stringify(event).includes(VALUE),'no value in the log');
assert.equal(ops.find(x=>x.op==='mayLaunch'),undefined,credential);
}
});
test('REQ-LIMIT-2 account-credential-ambiguous: the bus host sets a credential variable outside the named set, ANTHROPIC_BASE_URL included',async t=>{
for(const [extra,named] of [[['ANTHROPIC_API_KEY'],'ANTHROPIC_API_KEY'],[['ANTHROPIC_BASE_URL'],'ANTHROPIC_BASE_URL'],
[['ANTHROPIC_AUTH_TOKEN','ANTHROPIC_BASE_URL'],'ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL']]){
const {launcher,ops,last}=setup(t,{set:['CLAUDE_CODE_OAUTH_TOKEN',...extra]});
assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'account-credential-ambiguous'},named);
const event=last();
assert.equal(event.why,`account jason-max names CLAUDE_CODE_OAUTH_TOKEN, but the bus host also sets ${named}`);
assert.ok(!JSON.stringify(event).includes(VALUE),'no value in the log');
assert.equal(ops.find(x=>x.op==='mayLaunch'),undefined,named);
}
});
// Usage records keep the account's name, so a name that is a credential variable's value refuses,
// whichever variable holds it, before any message that names the account.
test('REQ-LIMIT-2 account-is-credential: launch.account.name is the value of a credential variable the bus host sets',async t=>{
for(const set of [['CLAUDE_CODE_OAUTH_TOKEN'],['CLAUDE_CODE_OAUTH_TOKEN','ANTHROPIC_API_KEY']]){
const {launcher,ops,last}=setup(t,{account:{name:VALUE,credential:'CLAUDE_CODE_OAUTH_TOKEN'},set});
assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'account-is-credential'},set.join());
const event=last();
assert.equal(event.code,'account-is-credential');
assert.match(event.why,/^launch\.account\.name is the value of [A-Z_, ]+; it names the account, never its credential$/);
for(const k of set)assert.ok(event.why.includes(k),k);
assert.ok(!JSON.stringify(event).includes(VALUE),'no value in the log');
assert.equal(ops.find(x=>x.op==='mayLaunch'),undefined,set.join());
}
});
test('REQ-LIMIT-2 a pi launch carries account null and skips both account checks',async t=>{
// No credential variable at all, and two at once: neither refuses a pi launch.
for(const set of [[],['CLAUDE_CODE_OAUTH_TOKEN','ANTHROPIC_API_KEY']]){
const {launcher,may}=setup(t,{harness:'pi',set});
assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy',set.join());
assert.equal(may().account,null);
}
});
test('REQ-LIMIT-2 with no launch.account a claude-code launch carries null and the environment is not checked for ambiguity',async t=>{
const {launcher,may}=setup(t,{account:null,set:['CLAUDE_CODE_OAUTH_TOKEN','ANTHROPIC_API_KEY']});
assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy');
assert.equal(may().account,null);
});
@@ -26,10 +26,10 @@ function setup(t,answer){
return {launcher,ops,last};
}
test('REQ-LIMIT-4 the launcher asks mayLaunch with the instance, provider and model, and goes on to authorization on an allow',async t=>{
test('REQ-LIMIT-4 the launcher asks mayLaunch with the instance, provider, model and account (row 126), and goes on to authorization on an allow',async t=>{
const {launcher,ops}=setup(t,{allow:true});
assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy');
assert.deepEqual(ops.find(x=>x.op==='mayLaunch'),{op:'mayLaunch',business:'acme',instance:'coder',provider:'zai',model:'claude-sonnet-5-5'});
assert.deepEqual(ops.find(x=>x.op==='mayLaunch'),{op:'mayLaunch',business:'acme',instance:'coder',provider:'zai',model:'claude-sonnet-5-5',account:null});
assert.ok(ops.findIndex(x=>x.op==='mayLaunch')<ops.findIndex(x=>x.op==='authorizeLaunch'));
});
+12
View File
@@ -171,6 +171,8 @@ test("bus start --pm: the launcher instance (the chief of staff) runs under its
const [launched] = log(f).filter((e) => e.event === "launch");
assert.equal(launched.run, chief.run);
assert.equal(launched.manifest, join(runDir, "bundle", "manifest.json"));
// Row 126: a pi launch spends no account, so the record says null.
assert.equal(launched.account, null);
assert.equal(manifest.model, "claude-opus-5-5");
// A second PM is refused before anything spawns.
@@ -708,6 +710,8 @@ test("a real Claude Code coder's shell can't read a stand-in secret or anything
const given = prepare(t, (doc) => {
doc.roles.coder.vars.harness = "claude-code";
doc.roles.coder.vars["limits.tools"] = ["bash"];
// Row 126: the claude-code launch spends this account, which names the proxy login set.
doc.launch.account = { name: "proxy-login", credential: "ANTHROPIC_AUTH_TOKEN+ANTHROPIC_BASE_URL" };
return doc;
});
delete given.f.env.CLAUDE_CODE_OAUTH_TOKEN;
@@ -743,6 +747,7 @@ test("a real Claude Code coder's shell can't read a stand-in secret or anything
assert.equal(JSON.parse(launched.body).instance, "coder", launched.body);
const coder = readSessions(f.dataRoot).find((s) => s.instance === "coder");
assert.equal(coder.harness, "claude-code");
assert.deepEqual(log(f).filter((e) => e.event === "launch").map((e) => [e.instance, e.account]), [["chief", null], ["coder", "proxy-login"]]);
const hello = await client.call("message.send", { to: "coder", class: "REQUEST", body: "probe" });
const reply = await until(async () => (await client.call("message.receive")).find((m) => m.in_reply_to === hello.id), 150000);
@@ -758,6 +763,13 @@ test("a real Claude Code coder's shell can't read a stand-in secret or anything
// The session logged in with the stand-in proxy key, against the mock.
assert.ok(api.requests.length > 0);
assert.ok(api.requests.some((r) => r.tools.includes("Bash")), api.requests.map((r) => r.tools.join(",")).join(" | "));
// Row 126: the bus got the account with the run, so the coder's turn records it (no usage
// source here, so the turn is unknown); the pi chief's turns record null.
const accounts = await until(async () => {
const seen = (await client.call("usage")).byAccount.map((r) => r.account);
return seen.includes("proxy-login") && seen;
});
assert.deepEqual([...accounts].sort(), [null, "proxy-login"]);
});
for (const exited of [false, true]) {
+10 -1
View File
@@ -3,7 +3,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {main} from '../src/cli.mjs';
import {formatTokens} from '../src/limits.mjs';
import {formatTokens,DAILY_NOTE} from '../src/limits.mjs';
import {io,tmp} from './helpers.mjs';
const SINCE='2026-10-18T07:00:00.000Z';
@@ -26,6 +26,15 @@ test('REQ-LIMIT-3 REQ-LIMIT-4 formatTokens shows each limit counted, not enforce
for(const none of [[],undefined])assert.equal(formatTokens(none),'token limits: none\n');
});
test('REQ-LIMIT-2 row 126: any daily quota row adds one line after the list saying the count is this stack\'s own use',()=>{
const daily={path:['tokens','daily','jason-max'],limit:40,since:SINCE,status:'counted',check:'daily-quota',count:3,kinds:{input:1,output:1,cacheWrite:0,cacheRead:1},unknownTurns:0};
const out=formatTokens([TOKENS[0],daily,{...daily,path:['tokens','daily','other'],status:'not-enforced',reason:null}]);
assert.ok(out.endsWith(` tokens daily other: limit 40 since ${SINCE}, not enforced\n${DAILY_NOTE}\n`),out);
assert.equal(out.split(DAILY_NOTE).length,2,'said once');
assert.equal(DAILY_NOTE,"Daily quotas count only this stack's own use of the account, not its use anywhere else.");
assert.ok(!formatTokens(TOKENS).includes(DAILY_NOTE));
});
test('REQ-LIMIT-3 the view prints the limits then the token lines; --json keeps tokens in the result',async t=>{
const root=tmp(t),deps={system:{dataRoot:root},transport:()=>()=>({revision:'b'.repeat(64),limits:{total:3},tokens:TOKENS})};
const x=io();await main(['limits','--business','acme'],x,deps);
+1 -1
View File
@@ -631,7 +631,7 @@ test("REQ-LIMIT-4 a runner at its limit stops at the end of its turn: RESULT and
assert.equal(reply.class, "RESULT");
assert.equal(reply.in_reply_to, sent.id);
assert.ok(ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
assert.deepEqual(asked, [{ business: "demo", subject: { instance: "coder", provider: null, model: null } }]);
assert.deepEqual(asked, [{ business: "demo", subject: { instance: "coder", provider: null, model: null, account: null } }]);
const row = ctx.store.get("SELECT op, by, reason, holder_run FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1");
assert.deepEqual({ ...row }, { op: "revoke", by: "limit-check", holder_run: "coder-run",
reason: "limit: token-limit tokens.instances.coder limit 100 count 120" });
+16
View File
@@ -193,6 +193,22 @@ sets `since` when a limit is added; a changed limit keeps it, a caller's
record's account; while no record names one, a key limit reports as not
enforced. See `limit-check/token-limit/README.md`.
### daily-quota
`limit-check/daily-quota` (row 126) enforces `launch.limits.tokens.daily`,
one quota per account, each with the `reset` time of day and the IANA
`timeZone` its day starts in:
```json
"daily": {"jason-max": {"limit": 40000000, "since": "…", "reset": "05:00", "timeZone": "America/Chicago"}}
```
A count is the sum of all four kinds over the account's known records since
the later of the day's start and `since`. Only a Claude Code launch carries
an account; a pi launch's is null, so no quota covers it. The count is this
stack's own use of the account, not its use anywhere else, and `mosaic
limits` and Settings say so. See `limit-check/daily-quota/README.md`.
## Human project/thread channels
`channel` modules carry project/thread operations between a human surface and
@@ -0,0 +1,41 @@
# Daily quota check
Row 126 (#1620), REQ-LIMIT-2 and REQ-LIMIT-4, limit-check interface 2. Enable
with `"plugins": { "limit-check": ["daily-quota"] }` in the organization file,
keeping its other plug-in kinds. Human changes use `mosaic plugins set` and the
current revision.
It enforces `launch.limits.tokens.daily`, one quota per account:
```json
"daily": {"jason-max": {"limit": 40000000, "since": "…", "reset": "05:00", "timeZone": "America/Chicago"}}
```
- The account is `launch.account.name`. Only a claude-code launch carries it;
a pi launch has account null, so no daily quota covers it.
- A day starts at `reset`, wall-clock time in `timeZone`. A date's reset is
the earliest instant on that local date at or after `reset`: when the
clocks skip it, the day starts at the first instant after the gap (02:30
on 2026-03-08 in America/Chicago is 03:00 CDT); when they repeat it, the
first one starts the day and the second starts nothing. A day with a DST
change is 23 or 25 hours long.
- A count is `input + output + cacheWrite + cacheRead` summed over the known
per-turn records whose `account` is the quota's and whose `at` is at or
after the later of the day's start and `since`. An unknown turn adds
nothing; `unknownTurns` counts the distinct unknown turns apart, so a count
is a floor, not proof.
- `mayLaunch` and `mustStop` answer the first quota for the subject's
account whose count is at or over it. A limit of 0 refuses every launch on
the account.
- `report` gives one counted entry per quota for the day that holds `now`.
Its `since` is the quota's own `since`, not the day's start.
- The count is this stack's own use of the account. Use of the same account
anywhere else isn't in the records, and the CLI and Settings say so.
- Counts are summed as BigInt; one past `Number.MAX_SAFE_INTEGER` is reported
as a digit string.
The module is pure: it reads no file and no store. A bad `since`, record `at`
or `now` throws `bad-time`, a bad amount `bad-record`, records that aren't a
list `bad-records`, and a quota without a whole `limit`, an `HH:MM` `reset`
or a zone `Intl` accepts `bad-leaf`; core turns any throw into a refusal, a
stop, or `check failed` (fail closed).
@@ -0,0 +1,115 @@
// Row 126 (#1620, REQ-LIMIT-2, REQ-LIMIT-4): a daily token quota per account. Pure: core passes
// the business's launch limits, its per-turn usage records and the time, and nothing here reads a
// file or a store. Each `launch.limits.tokens.daily.<account>` leaf is {limit, since, reset,
// timeZone}. A count is every kind summed over the account's known records since the later of the
// day's reset and the leaf's `since`; an unknown turn adds nothing and is counted on its own.
// It copies token-limit's tally helpers rather than importing a sibling check, so each loads alone.
export const interfaceVersion = 2;
const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead'];
const SAFE = BigInt(Number.MAX_SAFE_INTEGER);
const DAY = 86400000;
const RESET = /^([01]\d|2[0-3]):([0-5]\d)$/;
const refuse = code => { throw Object.assign(new Error(code), { code }); };
const plain = n => n <= SAFE ? Number(n) : String(n);
const time = value => {
const t = typeof value === 'string' ? Date.parse(value) : NaN;
if (Number.isNaN(t)) refuse('bad-time');
return t;
};
const amount = value => Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : refuse('bad-record');
// The wall-clock reading of instant t in a zone, as the UTC milliseconds of the same reading.
function reader(timeZone) {
let format;
try {
format = new Intl.DateTimeFormat('en-US', { timeZone, hourCycle: 'h23', year: 'numeric', month: 'numeric',
day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' });
} catch { refuse('bad-leaf'); }
return t => {
const p = Object.fromEntries(format.formatToParts(new Date(t)).map(x => [x.type, Number(x.value)]));
return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) + (t % 1000 + 1000) % 1000;
};
}
// Date D's reset: the earliest instant whose local date is D and whose local time is at or after
// `reset`. D is given as the UTC midnight of its date; wall is D's reset as a local reading.
// Each offset in force a day either side gives a candidate. A candidate that reads back as the
// reset is one; the earliest wins (a repeated hour). If neither does, the reset is in a skipped
// hour, and the first instant reading at or after it lies between the two candidates.
function resetOf(local, date, minutes) {
const wall = date + minutes * 60000;
const candidates = [wall - (local(wall - DAY) - (wall - DAY)), wall - (local(wall + DAY) - (wall + DAY))].sort((a, b) => a - b);
const exact = candidates.find(c => local(c) === wall);
if (exact !== undefined) return exact;
let [lo, hi] = candidates;
while (hi - lo > 1) {
const mid = Math.floor((lo + hi) / 2);
if (local(mid) >= wall) hi = mid; else lo = mid;
}
return hi;
}
// The start of the day window that holds now.
function windowStart(entry, now) {
const local = reader(entry.timeZone);
const [, hh, mm] = RESET.exec(entry.reset);
const minutes = Number(hh) * 60 + Number(mm);
const today = Math.floor(local(now) / DAY) * DAY;
const start = resetOf(local, today, minutes);
return start <= now ? start : resetOf(local, today - DAY, minutes);
}
function leaves(limits) {
const daily = limits?.tokens?.daily ?? {};
return Object.entries(daily).map(([account, entry]) => {
if (!Number.isSafeInteger(entry?.limit) || entry.limit < 0 || typeof entry.reset !== 'string' || !RESET.test(entry.reset) ||
typeof entry.timeZone !== 'string') refuse('bad-leaf');
return { path: ['tokens', 'daily', account], entry, account };
});
}
function tally(leaf, records, now) {
const from = Math.max(windowStart(leaf.entry, now), time(leaf.entry.since));
const kinds = Object.fromEntries(KINDS.map(k => [k, 0n]));
const unknown = new Set();
for (const r of records) {
if (r.account !== leaf.account || time(r.at) < from) continue;
if (r.status === 'known') for (const k of KINDS) kinds[k] += amount(r[k]);
else unknown.add(JSON.stringify([r.run, r.turn]));
}
return { count: KINDS.reduce((sum, k) => sum + kinds[k], 0n), kinds, unknownTurns: unknown.size };
}
// The first quota for the subject's account whose count is at or over it.
function hitFor({ limits, subject, records, now }) {
if (!Array.isArray(records)) refuse('bad-records');
const at = time(now);
for (const leaf of leaves(limits)) {
if (subject?.account !== leaf.account) continue;
const { count, unknownTurns } = tally(leaf, records, at);
if (count >= BigInt(leaf.entry.limit)) return { path: leaf.path, limit: leaf.entry.limit, count: plain(count), unknownTurns };
}
return null;
}
export function mayLaunch(args) {
const hit = hitFor(args);
return hit ? { allow: false, hit } : { allow: true };
}
export function mustStop(args) {
const hit = hitFor(args);
return hit ? { stop: true, hit } : { stop: false };
}
// Every daily quota with today's count. `since` is the leaf's own, as core requires.
export function report({ limits, records, now }) {
if (!Array.isArray(records)) refuse('bad-records');
const at = time(now);
return leaves(limits).map(leaf => {
const { path, entry: { limit, since } } = leaf;
const { count, kinds, unknownTurns } = tally(leaf, records, at);
return { path, limit, since, count: plain(count), kinds: Object.fromEntries(KINDS.map(k => [k, plain(kinds[k])])), unknownTurns };
});
}
+171
View File
@@ -0,0 +1,171 @@
// Row 126 (#1620, REQ-LIMIT-2, REQ-LIMIT-4): the daily-quota check on its own. It is pure, so
// these tests pass it limits, records and a time and read its answers; no loader, store or file.
// America/Chicago in 2026: CDT (UTC-5) until 2026-11-01 07:00Z, CST (UTC-6) from then, and CDT
// again from 2026-03-08 08:00Z.
import test from "node:test";
import assert from "node:assert/strict";
import * as check from "../limit-check/daily-quota/index.mjs";
const SINCE = "2026-01-01T00:00:00.000Z";
const NOW = "2026-10-18T12:00:00.000Z";
const ZONE = "America/Chicago";
const PATH = ["tokens", "daily", "max"];
const subject = (over = {}) => ({ instance: "coder", provider: "anthropic", model: "opus", account: "max", ...over });
let seq = 0;
const known = (at, over = {}, counts = [10, 20, 30, 40]) => {
const [input, output, cacheWrite, cacheRead] = counts;
return { at, run: "r1", turn: `t${++seq}`, source: "pi-session", instance: "coder", provider: "anthropic", model: "opus",
account: "max", status: "known", input, output, cacheWrite, cacheRead, ...over };
};
const unknown = (at, over = {}) => ({ ...known(at, over), status: "unknown", input: null, output: null, cacheWrite: null, cacheRead: null });
const quota = (over = {}) => ({ limit: 1000, since: SINCE, reset: "05:00", timeZone: ZONE, ...over });
const args = (daily, records, over = {}) => ({ business: "demo", limits: { tokens: { daily } }, subject: subject(), records, now: NOW, ...over });
const count = (daily, records, now) => check.report(args(daily, records, { now }))[0].count;
test("REQ-LIMIT-2 daily-quota exports interface version 2 with mayLaunch, mustStop and report", () => {
assert.equal(check.interfaceVersion, 2);
for (const name of ["mayLaunch", "mustStop", "report"]) assert.equal(typeof check[name], "function");
});
test("REQ-LIMIT-2 the count sums every kind of the account's known records since the later of the day start and since", () => {
const records = [known("2026-10-18T11:00:00.000Z", {}, [1, 2, 3, 4]), known("2026-10-18T11:30:00.000Z", { account: "other" }),
known("2026-10-18T11:40:00.000Z", { account: null }), known("2026-10-18T11:50:00.000Z", {}, [5, 0, 0, 5])];
assert.deepEqual(check.report(args({ max: quota() }, records)), [{ path: PATH, limit: 1000, since: SINCE, count: 20,
kinds: { input: 6, output: 2, cacheWrite: 3, cacheRead: 9 }, unknownTurns: 0 }]);
// A since after the day's start moves the count's start to it; report keeps the quota's own since.
const late = "2026-10-18T11:45:00.000Z";
assert.deepEqual(check.report(args({ max: quota({ since: late }) }, records))[0],
{ path: PATH, limit: 1000, since: late, count: 10, kinds: { input: 5, output: 0, cacheWrite: 0, cacheRead: 5 }, unknownTurns: 0 });
// Another account's quota covers neither these records nor this subject's launches.
const full = [known("2026-10-18T11:00:00.000Z"), known("2026-10-18T11:10:00.000Z")];
assert.deepEqual(check.mayLaunch(args({ other: quota({ limit: 150 }) }, full)), { allow: true });
assert.equal(check.report(args({ other: quota({ limit: 150 }) }, full))[0].count, 0);
assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 150 }) }, full, { subject: subject({ account: "other" }) })), { allow: true });
});
test("REQ-LIMIT-2 a launch with no account (pi) is covered by no daily quota", () => {
const records = [known("2026-10-18T11:00:00.000Z", { account: null })];
const a = args({ max: quota({ limit: 0 }) }, records, { subject: subject({ account: null }) });
assert.deepEqual(check.mayLaunch(a), { allow: true });
assert.deepEqual(check.mustStop(a), { stop: false });
assert.equal(check.report(a)[0].count, 0);
});
test("REQ-LIMIT-4 an unknown turn adds nothing and is counted apart", () => {
const u = unknown("2026-10-18T11:00:00.000Z", { run: "r2", turn: "u1" });
const records = [known("2026-10-18T11:00:00.000Z", {}, [1, 1, 1, 1]), u, { ...u, source: "other-source" },
unknown("2026-10-18T11:10:00.000Z", { run: "r2", turn: "u2" }), unknown("2026-10-18T09:00:00.000Z", { run: "r2", turn: "u0" })];
const [entry] = check.report(args({ max: quota({ limit: 5 }) }, records));
assert.equal(entry.count, 4);
assert.equal(entry.unknownTurns, 2);
assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 5 }) }, records)), { allow: true });
});
test("REQ-LIMIT-2 the day turns at the reset in its zone, not in UTC, and before it the day is yesterday's", () => {
const daily = { max: quota() }; // 05:00 America/Chicago is 10:00Z in October
const records = [known("2026-10-17T11:00:00.000Z", {}, [1, 0, 0, 0]), known("2026-10-18T09:59:59.999Z", {}, [10, 0, 0, 0]),
known("2026-10-18T10:00:00.000Z", {}, [100, 0, 0, 0])];
assert.equal(count(daily, records, "2026-10-18T12:00:00.000Z"), 100);
assert.equal(count(daily, records, "2026-10-18T10:00:00.000Z"), 100);
// At 04:59 CDT the day that started at 05:00 yesterday still runs.
assert.equal(count(daily, records.slice(0, 2), "2026-10-18T09:59:59.999Z"), 11);
assert.equal(count(daily, records.slice(0, 2), "2026-10-18T09:00:00.000Z"), 11);
});
test("REQ-LIMIT-2 the spring DST change makes the day after a 00:00 reset 23 hours long", () => {
const daily = { max: quota({ reset: "00:00" }) };
// 2026-03-08 00:00 CST is 06:00Z; 2026-03-09 00:00 CDT is 05:00Z.
const records = [known("2026-03-08T05:30:00.000Z", {}, [1, 0, 0, 0]), known("2026-03-08T06:00:00.000Z", {}, [10, 0, 0, 0]),
known("2026-03-09T04:59:59.999Z", {}, [100, 0, 0, 0]), known("2026-03-09T05:00:00.000Z", {}, [1000, 0, 0, 0])];
assert.equal(count(daily, records.slice(0, 3), "2026-03-09T04:59:59.999Z"), 110);
assert.equal(count(daily, records, "2026-03-09T05:00:00.000Z"), 1000);
});
test("REQ-LIMIT-2 the fall DST change makes the day after a 00:00 reset 25 hours long", () => {
const daily = { max: quota({ reset: "00:00" }) };
// 2026-11-01 00:00 CDT is 05:00Z; 2026-11-02 00:00 CST is 06:00Z.
const records = [known("2026-11-01T04:59:59.999Z", {}, [1, 0, 0, 0]), known("2026-11-01T05:30:00.000Z", {}, [10, 0, 0, 0]),
known("2026-11-02T05:30:00.000Z", {}, [100, 0, 0, 0]), known("2026-11-02T06:00:00.000Z", {}, [1000, 0, 0, 0])];
assert.equal(count(daily, records.slice(0, 3), "2026-11-02T05:59:59.999Z"), 110);
assert.equal(count(daily, records, "2026-11-02T06:00:00.000Z"), 1000);
});
test("REQ-LIMIT-2 a reset the clocks skip starts the day at the first instant after the gap", () => {
const daily = { max: quota({ reset: "02:30" }) };
// 02:30 doesn't happen on 2026-03-08: 01:59:59 CST (07:59:59Z) is followed by 03:00 CDT (08:00Z).
const records = [known("2026-03-07T09:00:00.000Z", {}, [1, 0, 0, 0]), known("2026-03-08T07:59:59.999Z", {}, [10, 0, 0, 0]),
known("2026-03-08T08:00:00.000Z", {}, [100, 0, 0, 0])];
assert.equal(count(daily, records, "2026-03-08T08:15:00.000Z"), 100);
assert.equal(count(daily, records, "2026-03-08T08:00:00.000Z"), 100);
// Before the gap ends, the day that started at 02:30 CST on 03-07 (08:30Z) still runs.
assert.equal(count(daily, records.slice(0, 2), "2026-03-08T07:59:59.999Z"), 11);
});
test("REQ-LIMIT-2 a reset the clocks repeat starts one day, at the first one", () => {
const daily = { max: quota({ reset: "01:30" }) };
// 01:30 happens twice on 2026-11-01: CDT at 06:30Z and CST at 07:30Z.
const records = [known("2026-11-01T06:29:59.999Z", {}, [1, 0, 0, 0]), known("2026-11-01T06:45:00.000Z", {}, [10, 0, 0, 0]),
known("2026-11-01T07:45:00.000Z", {}, [100, 0, 0, 0])];
assert.equal(count(daily, records, "2026-11-01T07:50:00.000Z"), 110);
assert.equal(count(daily, records.slice(0, 2), "2026-11-01T06:50:00.000Z"), 10);
// Before the first 01:30, the day that started at 01:30 CDT on 10-31 (06:30Z) still runs.
assert.equal(count(daily, records.slice(0, 1), "2026-11-01T06:29:59.999Z"), 1);
});
test("REQ-LIMIT-2 a launch is refused at the quota, a turn that reaches it stops, and the next day allows again", () => {
const records = [known("2026-10-18T11:00:00.000Z"), known("2026-10-18T11:10:00.000Z")]; // 200 tokens
const hit = { path: PATH, limit: 200, count: 200, unknownTurns: 0 };
assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 200 }) }, records)), { allow: false, hit });
assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 201 }) }, records)), { allow: true });
// mustStop answers on the records the turn's usage end added.
assert.deepEqual(check.mustStop(args({ max: quota({ limit: 200 }) }, records.slice(0, 1))), { stop: false });
assert.deepEqual(check.mustStop(args({ max: quota({ limit: 200 }) }, records)), { stop: true, hit });
// The next reset, 05:00 CDT on 10-19 (10:00Z), starts a day with nothing counted.
const next = { now: "2026-10-19T10:00:00.000Z" };
assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 200 }) }, records, { now: "2026-10-19T09:59:59.999Z" })), { allow: false, hit });
assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 200 }) }, records, next)), { allow: true });
assert.deepEqual(check.mustStop(args({ max: quota({ limit: 200 }) }, records, next)), { stop: false });
// A limit of 0 refuses with no records.
assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 0 }) }, [])),
{ allow: false, hit: { path: PATH, limit: 0, count: 0, unknownTurns: 0 } });
});
test("REQ-LIMIT-2 counts past the safe integer range come back as digit strings", () => {
const big = Number.MAX_SAFE_INTEGER;
const records = [known("2026-10-18T11:00:00.000Z", {}, [big, big, 0, 0])];
const [entry] = check.report(args({ max: quota({ limit: 1 }) }, records));
assert.equal(entry.count, (2n * BigInt(big)).toString());
assert.equal(entry.kinds.input, big);
assert.equal(check.mustStop(args({ max: quota({ limit: 1 }) }, records)).hit.count, (2n * BigInt(big)).toString());
});
test("REQ-LIMIT-4 bad leaves, records or times throw a code, and the check reads nothing but its arguments", () => {
const ok = [known("2026-10-18T11:00:00.000Z")];
const leaves = [{ limit: -1 }, { limit: 1.5 }, { limit: "1" }, { reset: "5:00" }, { reset: "24:00" }, { reset: "05:60" },
{ reset: undefined }, { timeZone: "Mars/Phobos" }, { timeZone: undefined }, { timeZone: 5 }];
for (const fn of ["mayLaunch", "mustStop", "report"]) {
for (const over of leaves) {
assert.throws(() => check[fn](args({ max: quota(over) }, ok)), { code: "bad-leaf" }, `${fn} ${JSON.stringify(over)}`);
}
assert.throws(() => check[fn](args({ max: null }, ok)), { code: "bad-leaf" }, fn);
assert.throws(() => check[fn](args({ max: quota({ since: "never" }) }, ok)), { code: "bad-time" }, fn);
assert.throws(() => check[fn](args({ max: quota() }, ok, { now: "soon" })), { code: "bad-time" }, fn);
assert.throws(() => check[fn](args({ max: quota() }, null)), { code: "bad-records" }, fn);
assert.throws(() => check[fn](args({ max: quota() }, [known("yesterday")])), { code: "bad-time" }, fn);
assert.throws(() => check[fn](args({ max: quota() }, [known("2026-10-18T11:00:00.000Z", {}, [-1, 0, 0, 0])])), { code: "bad-record" }, fn);
}
const input = args({ max: quota() }, ok);
const copy = structuredClone(input);
check.mayLaunch(input); check.mustStop(input); check.report(input);
assert.deepEqual(input, copy);
});
test("REQ-LIMIT-2 no daily quotas: every launch is allowed and the report is empty", () => {
const records = [known("2026-10-18T11:00:00.000Z")];
for (const l of [{}, { tokens: {} }, { tokens: { daily: {} } }, { tokens: { instances: { coder: { limit: 0, since: SINCE } } } }]) {
const a = { business: "demo", limits: l, subject: subject(), records, now: NOW };
assert.deepEqual(check.mayLaunch(a), { allow: true });
assert.deepEqual(check.mustStop(a), { stop: false });
assert.deepEqual(check.report(a), []);
}
});
@@ -141,3 +141,11 @@ test("REQ-LIMIT-3 no token limits: every launch is allowed and the report is emp
assert.deepEqual(check.report(a), []);
}
});
test("REQ-LIMIT-3 token-limit ignores tokens.daily, which the daily-quota check enforces (row 126)", () => {
const daily = { k1: { limit: 0, since: SINCE, reset: "05:00", timeZone: "America/Chicago" } };
const a = { business: "demo", limits: { tokens: { daily } }, subject: subject({ account: "k1" }), records: [known({ account: "k1" })], now: NOW };
assert.deepEqual(check.mayLaunch(a), { allow: true });
assert.deepEqual(check.mustStop(a), { stop: false });
assert.deepEqual(check.report(a), []);
});
+3 -2
View File
@@ -592,7 +592,8 @@
catch{settingsReceipt='Outcome unknown. Read current settings before trying again. Draft kept.';}finally{settingsSaving=false;if(!out())render(false);}
});
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): each token limit as the bus's checks report it. A
// limit no switched-on check counts reads "not enforced". Reset counts it again from now.
// limit no switched-on check counts reads "not enforced". Reset counts it again from now. Row 126
// (#1620): any daily quota row adds one line saying the count is this stack's own use only.
let resetReceipt='';
function tokenLimits(rows, refused) {
const title = '<h2 id="token-limits-title">Token limits</h2>';
@@ -604,7 +605,7 @@
: t.status === 'not-enforced' ? `not enforced${t.reason ? `: ${txt(t.reason)}` : ''}`
: `check failed: ${txt(t.code)}${t.check ? ` (${txt(t.check)})` : ''}`;
const body = rows.map(t => `<tr><th scope="row">${txt((t.path || []).join(' / '))}</th><td>${txt(t.limit)}</td><td>${txt(t.since)}</td><td>${state(t)}</td><td><button type="button" data-limit-reset="${esc(JSON.stringify(t.path || []))}"${can ? '' : ' disabled'}>Reset</button></td></tr>`).join('');
return `<section class="panel" aria-labelledby="token-limits-title">${title}<p>A count is every token kind since the limit was set or reset. Unknown turns add nothing to it.</p><div class="table-wrap"><table id="token-limits-table"><thead><tr><th scope="col">Subject</th><th scope="col">Limit</th><th scope="col">Since</th><th scope="col">Count</th><th scope="col"><span class="sr-only">Reset</span></th></tr></thead><tbody>${body}</tbody></table></div><p class="small muted">${canSend ? 'A reset is logged with your identity.' : 'Resetting requires signed-in send mode.'}</p><p role="status" id="token-limits-receipt">${txt(resetReceipt)}</p></section>`;
return `<section class="panel" aria-labelledby="token-limits-title">${title}<p>A count is every token kind since the limit was set or reset. Unknown turns add nothing to it.</p><div class="table-wrap"><table id="token-limits-table"><thead><tr><th scope="col">Subject</th><th scope="col">Limit</th><th scope="col">Since</th><th scope="col">Count</th><th scope="col"><span class="sr-only">Reset</span></th></tr></thead><tbody>${body}</tbody></table></div>${rows.some(t => t.path?.[1] === 'daily') ? '<p class="small muted" id="token-limits-daily">Daily quotas count only this stack\'s own use of the account, not its use anywhere else.</p>' : ''}<p class="small muted">${canSend ? 'A reset is logged with your identity.' : 'Resetting requires signed-in send mode.'}</p><p role="status" id="token-limits-receipt">${txt(resetReceipt)}</p></section>`;
}
document.addEventListener('click',async e=>{
const path=e.target.dataset?.limitReset;if(path===undefined||!canSend||out()||settingsSaving)return;
@@ -37,6 +37,7 @@ test('REQ-LIMIT-3 REQ-LIMIT-4 Settings shows token limits counted, not enforced
['tokens / models / anthropic / claude-opus-5-5','7',SINCE,`check failed: check-threw (${hostile})`,'Reset'],
]);
assert.equal(await b.evaluate('[...document.querySelectorAll("[data-limit-reset]")].every(e=>e.disabled)'),true);
assert.equal(await b.evaluate('document.querySelectorAll("#token-limits-daily").length'),0,'no daily row, no daily line');
assert.equal(await b.evaluate('window.injected===true'),false);
// Signed-in send mode: Reset posts the revision and that one path, and nothing else. A draft
// typed before the reset stays, and its save carries the revision the reset returned (row 167).
@@ -54,6 +55,13 @@ test('REQ-LIMIT-3 REQ-LIMIT-4 Settings shows token limits counted, not enforced
assert.equal(save.revision,'b'.repeat(64));
assert.equal(save.limits.total,3);
assert.deepEqual(save.limits.tokens,tokens);
// Row 126 (#1620): a daily quota row adds one line under the table. Its own query string makes
// this a full load, and the next block's /#/settings one too.
answer={revision:launch.revision,limits:launch.limits,tokens:[...TOKENS,{path:['tokens','daily','jason-max'],limit:40,since:SINCE,status:'counted',check:'daily-quota',count:3,kinds:{input:1,output:1,cacheWrite:0,cacheRead:1},unknownTurns:0}]};
await b.navigate(`http://127.0.0.1:${port}/?daily=1#/settings`);
await wait('!!document.querySelector("#token-limits-daily")');
assert.equal(await b.evaluate('document.querySelector("#token-limits-daily").textContent'),"Daily quotas count only this stack's own use of the account, not its use anywhere else.");
assert.equal(await b.evaluate('document.querySelectorAll("#token-limits-daily").length'),1);
// A limits answer the server refuses shows no rows and no count.
answer={revision:launch.revision,limits:launch.limits};
await b.navigate(`http://127.0.0.1:${port}/#/settings`);