Files

1988 lines
129 KiB
Diff

diff --git a/packages/bus/README.md b/packages/bus/README.md
index 7867cdbe6..3ea66529c 100644
--- a/packages/bus/README.md
+++ b/packages/bus/README.md
@@ -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.
diff --git a/packages/bus/schema.sql b/packages/bus/schema.sql
index 53059db47..e1edcbaee 100644
--- a/packages/bus/schema.sql
+++ b/packages/bus/schema.sql
@@ -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')),
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index 61167ddff..e20509eac 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -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' };
}
diff --git a/packages/bus/src/limits.mjs b/packages/bus/src/limits.mjs
index f80c7237e..cbcc623f9 100644
--- a/packages/bus/src/limits.mjs
+++ b/packages/bus/src/limits.mjs
@@ -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) => {
diff --git a/packages/bus/src/migrations/3h-3i.sql b/packages/bus/src/migrations/3h-3i.sql
new file mode 100644
index 000000000..b60983f47
--- /dev/null
+++ b/packages/bus/src/migrations/3h-3i.sql
@@ -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;
diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs
index 4df4406a4..625ea0146 100644
--- a/packages/bus/src/runtime.mjs
+++ b/packages/bus/src/runtime.mjs
@@ -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,
diff --git a/packages/bus/src/store.mjs b/packages/bus/src/store.mjs
index 19742246c..8772f939e 100644
--- a/packages/bus/src/store.mjs
+++ b/packages/bus/src/store.mjs
@@ -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',
diff --git a/packages/bus/src/usage.mjs b/packages/bus/src/usage.mjs
index f6a183675..eecd20150 100644
--- a/packages/bus/src/usage.mjs
+++ b/packages/bus/src/usage.mjs
@@ -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);
}
diff --git a/packages/bus/tests/account-migration.test.mjs b/packages/bus/tests/account-migration.test.mjs
new file mode 100644
index 000000000..41794a6cd
--- /dev/null
+++ b/packages/bus/tests/account-migration.test.mjs
@@ -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);
+});
diff --git a/packages/bus/tests/chief-of-staff.test.mjs b/packages/bus/tests/chief-of-staff.test.mjs
index 91ea423fc..a7679b180 100644
--- a/packages/bus/tests/chief-of-staff.test.mjs
+++ b/packages/bus/tests/chief-of-staff.test.mjs
@@ -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 });
diff --git a/packages/bus/tests/comment-migration.test.mjs b/packages/bus/tests/comment-migration.test.mjs
index 9db61ce43..88c5a82d2 100644
--- a/packages/bus/tests/comment-migration.test.mjs
+++ b/packages/bus/tests/comment-migration.test.mjs
@@ -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);
diff --git a/packages/bus/tests/daily-quota-runtime.test.mjs b/packages/bus/tests/daily-quota-runtime.test.mjs
new file mode 100644
index 000000000..421330e31
--- /dev/null
+++ b/packages/bus/tests/daily-quota-runtime.test.mjs
@@ -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 });
+});
diff --git a/packages/bus/tests/fixtures/schema-3h.sql b/packages/bus/tests/fixtures/schema-3h.sql
new file mode 100644
index 000000000..53059db47
--- /dev/null
+++ b/packages/bus/tests/fixtures/schema-3h.sql
@@ -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;
diff --git a/packages/bus/tests/land.test.mjs b/packages/bus/tests/land.test.mjs
index 33d3cc222..ca249121c 100644
--- a/packages/bus/tests/land.test.mjs
+++ b/packages/bus/tests/land.test.mjs
@@ -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');
diff --git a/packages/bus/tests/limit-stop.test.mjs b/packages/bus/tests/limit-stop.test.mjs
index 1b97f338c..990852b4e 100644
--- a/packages/bus/tests/limit-stop.test.mjs
+++ b/packages/bus/tests/limit-stop.test.mjs
@@ -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 } });
diff --git a/packages/bus/tests/name-migration.test.mjs b/packages/bus/tests/name-migration.test.mjs
index d63a23c67..3f6053259 100644
--- a/packages/bus/tests/name-migration.test.mjs
+++ b/packages/bus/tests/name-migration.test.mjs
@@ -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) });
diff --git a/packages/bus/tests/spec-migration.test.mjs b/packages/bus/tests/spec-migration.test.mjs
index d108bf0a2..46cb90536 100644
--- a/packages/bus/tests/spec-migration.test.mjs
+++ b/packages/bus/tests/spec-migration.test.mjs
@@ -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'), []);
diff --git a/packages/bus/tests/thread-migration.test.mjs b/packages/bus/tests/thread-migration.test.mjs
index 6b2e914dc..bb79d4026 100644
--- a/packages/bus/tests/thread-migration.test.mjs
+++ b/packages/bus/tests/thread-migration.test.mjs
@@ -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'));
});
diff --git a/packages/bus/tests/usage-migration.test.mjs b/packages/bus/tests/usage-migration.test.mjs
index 421b1d417..7c82ebd11 100644
--- a/packages/bus/tests/usage-migration.test.mjs
+++ b/packages/bus/tests/usage-migration.test.mjs
@@ -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');
diff --git a/packages/business/README.md b/packages/business/README.md
index 62d243a28..953a8b4da 100644
--- a/packages/business/README.md
+++ b/packages/business/README.md
@@ -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
diff --git a/packages/business/src/business.mjs b/packages/business/src/business.mjs
index 5a13c80ef..aad1187bd 100644
--- a/packages/business/src/business.mjs
+++ b/packages/business/src/business.mjs
@@ -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
diff --git a/packages/business/src/launch-settings.mjs b/packages/business/src/launch-settings.mjs
index 670320b54..be8a0ee99 100644
--- a/packages/business/src/launch-settings.mjs
+++ b/packages/business/src/launch-settings.mjs
@@ -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) => {
diff --git a/packages/business/src/launch.mjs b/packages/business/src/launch.mjs
index 6aaa8f5eb..6f9b18690 100644
--- a/packages/business/src/launch.mjs
+++ b/packages/business/src/launch.mjs
@@ -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);
diff --git a/packages/business/tests/token-limit-settings.test.mjs b/packages/business/tests/token-limit-settings.test.mjs
index 8ac3e953e..9fd18717a 100644
--- a/packages/business/tests/token-limit-settings.test.mjs
+++ b/packages/business/tests/token-limit-settings.test.mjs
@@ -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);
+});
diff --git a/packages/cli/README.md b/packages/cli/README.md
index e8b4c31d8..e732b4c75 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -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
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
index f9271003a..e85f71666 100644
--- a/packages/cli/src/launcher.mjs
+++ b/packages/cli/src/launcher.mjs
@@ -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 };
}
diff --git a/packages/cli/src/limits.mjs b/packages/cli/src/limits.mjs
index 4f8a8ec3d..e1243ffa3 100644
--- a/packages/cli/src/limits.mjs
+++ b/packages/cli/src/limits.mjs
@@ -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) {
diff --git a/packages/cli/tests/launch-account.test.mjs b/packages/cli/tests/launch-account.test.mjs
new file mode 100644
index 000000000..f107c4204
--- /dev/null
+++ b/packages/cli/tests/launch-account.test.mjs
@@ -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);
+});
diff --git a/packages/cli/tests/launch-limit-checks.test.mjs b/packages/cli/tests/launch-limit-checks.test.mjs
index 68f1384fd..488f421b6 100644
--- a/packages/cli/tests/launch-limit-checks.test.mjs
+++ b/packages/cli/tests/launch-limit-checks.test.mjs
@@ -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'));
});
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
index e7cd64b25..7c45e54df 100644
--- a/packages/cli/tests/launcher.test.mjs
+++ b/packages/cli/tests/launcher.test.mjs
@@ -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]) {
diff --git a/packages/cli/tests/limits-tokens.test.mjs b/packages/cli/tests/limits-tokens.test.mjs
index b2dc19fd5..6c85a9d6a 100644
--- a/packages/cli/tests/limits-tokens.test.mjs
+++ b/packages/cli/tests/limits-tokens.test.mjs
@@ -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);
diff --git a/packages/harness/tests/runner.test.mjs b/packages/harness/tests/runner.test.mjs
index 25767b33e..faa97d47c 100644
--- a/packages/harness/tests/runner.test.mjs
+++ b/packages/harness/tests/runner.test.mjs
@@ -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" });
diff --git a/packages/plugins/README.md b/packages/plugins/README.md
index 66b1279b2..e2723c1d7 100644
--- a/packages/plugins/README.md
+++ b/packages/plugins/README.md
@@ -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
diff --git a/packages/plugins/limit-check/daily-quota/README.md b/packages/plugins/limit-check/daily-quota/README.md
new file mode 100644
index 000000000..ec1654ca1
--- /dev/null
+++ b/packages/plugins/limit-check/daily-quota/README.md
@@ -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).
diff --git a/packages/plugins/limit-check/daily-quota/index.mjs b/packages/plugins/limit-check/daily-quota/index.mjs
new file mode 100644
index 000000000..b6addc1ab
--- /dev/null
+++ b/packages/plugins/limit-check/daily-quota/index.mjs
@@ -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 };
+ });
+}
diff --git a/packages/plugins/tests/daily-quota.test.mjs b/packages/plugins/tests/daily-quota.test.mjs
new file mode 100644
index 000000000..c09c1bfe8
--- /dev/null
+++ b/packages/plugins/tests/daily-quota.test.mjs
@@ -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), []);
+ }
+});
diff --git a/packages/plugins/tests/token-limit.test.mjs b/packages/plugins/tests/token-limit.test.mjs
index 451a29a54..21067d6cd 100644
--- a/packages/plugins/tests/token-limit.test.mjs
+++ b/packages/plugins/tests/token-limit.test.mjs
@@ -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), []);
+});
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
index 42643689a..24d36d654 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -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;
diff --git a/packages/webui/tests/limits-browser.test.mjs b/packages/webui/tests/limits-browser.test.mjs
index 1a92984fb..b232a5d8d 100644
--- a/packages/webui/tests/limits-browser.test.mjs
+++ b/packages/webui/tests/limits-browser.test.mjs
@@ -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`);