From cfe753553340f98911c8e528408168ff94af15bf Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 11 Nov 2026 13:39:44 -0600 Subject: [PATCH] fix(autonomy): account quota coverage reads the harness module's countsAccount, not a name (#1873) authorityBudgetCovered named claude-code to decide whether an account quota covers every future launch, which fails the row 65 scan. The bus host now passes each harness module's own countsAccount answer, read once with its levels in harnessLevels, through autonomyHost to the broker. Without the lookup no account quota counts (fail closed); the provider/model branch is unchanged. --- packages/bus/src/autonomy-host.mjs | 5 +++- packages/bus/src/broker.mjs | 10 +++++--- packages/bus/src/runtime.mjs | 14 +++++++++-- .../bus/tests/autonomy-authority.test.mjs | 12 ++++++++- packages/bus/tests/routes-verb.test.mjs | 3 +++ packages/organization/src/autonomy.mjs | 9 ++++--- packages/organization/tests/autonomy.test.mjs | 25 ++++++++++++++++--- 7 files changed, 63 insertions(+), 15 deletions(-) diff --git a/packages/bus/src/autonomy-host.mjs b/packages/bus/src/autonomy-host.mjs index 3e6e6a80b..e7f17b6bc 100644 --- a/packages/bus/src/autonomy-host.mjs +++ b/packages/bus/src/autonomy-host.mjs @@ -5,8 +5,11 @@ import { randomBytes } from 'node:crypto'; import { prepareRevert, finishLand, dropLand } from '../../seat/src/working.mjs'; import { isMosaicStack, runGate, gateEnv } from '../../seat/src/landing.mjs'; const refuse=code=>{throw Object.assign(new Error(code),{code});}; -export function autonomyHost({writer,dataRoot,business,hires,reload,processing,env=process.env,gate={classify:isMosaicStack,run:runGate}}) { +// countsAccount(name) answers what the named harness module declares (runtime harnessLevels); +// the broker's authority budget check reads it here instead of naming a harness. +export function autonomyHost({writer,dataRoot,business,hires,reload,processing,countsAccount=()=>null,env=process.env,gate={classify:isMosaicStack,run:runGate}}) { return { + countsAccount, get:id=>{const r=writer.read(id);return {revision:r.revision,autonomy:r.document.autonomy??{}};}, set:(id,args,actor)=>{ const old=writer.read(id);if(old.revision!==args.revision)refuse('revision-conflict'); diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 7b64da2ea..1851b9374 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -6415,7 +6415,7 @@ export class Broker { if(!role?.authority?.withinRole.includes(right) || (cls==='authority' && name===b.launch?.by))fail('autonomy-precondition'); } if(['merge','policy'].includes(cls) && !Object.values(b.roles).some(r=>r.authority?.withinRole.includes('review.verdict')))fail('autonomy-precondition'); - if(cls==='authority' && !authorityBudgetCovered(b))fail('autonomy-precondition'); + if(cls==='authority' && !this.#budgetCovered(b))fail('autonomy-precondition'); if(after==='out of the loop') { if(before!=='near the loop')fail('autonomy-precondition'); const review=this.#store.get("SELECT seq,body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.review' AND json_extract(body,'$.decisionClass')=? ORDER BY seq DESC LIMIT 1",s.business,cls); @@ -6504,7 +6504,7 @@ export class Broker { let route='human'; if(!immediate && level!=='in the loop' && p.level!=='root' && !p.flagged.length && !p.credentialLines.length) { if(level==='out of the loop')fail('authority-limits-unavailable'); - if(!authorityBudgetCovered(this.#business(s.business)))fail('autonomy-precondition'); + if(!this.#budgetCovered(this.#business(s.business)))fail('autonomy-precondition'); route=b.autonomy?.authorityResolver; if(!b.roles[route]?.authority?.withinRole.includes('decision.resolve.authority'))fail('autonomy-precondition'); if(route===s.role || resolverSubject(b,route,p.name))route='human'; @@ -6518,6 +6518,8 @@ export class Broker { if(immediate) return this.#hireConsole(s,'hire.resolve.console',{id:did,choice:'yes',digest:p.digest}); return {...this.#decisionView(this.#decision(s,did)),hire:p}; } + // The harness fact comes from the host's loaded modules; no host, no account coverage. + #budgetCovered(b) { return authorityBudgetCovered(b,{countsAccount:this.#autonomyHost?.countsAccount}); } // Trusted runtime inverse only; never a socket verb. hireReverse(...args) { if(!this.#hires?.reverse)fail('reversal-unavailable'); @@ -6534,7 +6536,7 @@ export class Broker { if(this.#expireHire(s,d,p))fail('decision-expired'); if(this.#autonomyLevel(d)==='in the loop')fail('hire-resolution-required'); if(this.#autonomyLevel(d)==='out of the loop')fail('authority-limits-unavailable'); - if(!authorityBudgetCovered(this.#business(s.business)))fail('autonomy-precondition'); + if(!this.#budgetCovered(this.#business(s.business)))fail('autonomy-precondition'); if(!this.#business(s.business).roles[s.role]?.authority?.withinRole.includes('decision.resolve.authority'))fail('not-resolver'); }; validate(); @@ -6554,7 +6556,7 @@ export class Broker { const recheck=()=>{ this.#agent(s); if(this.#autonomyLevel(d)==='out of the loop')fail('authority-limits-unavailable'); - if(!authorityBudgetCovered(this.#business(s.business)))fail('autonomy-precondition'); + if(!this.#budgetCovered(this.#business(s.business)))fail('autonomy-precondition'); if(this.#autonomyLevel(d)==='in the loop' || !this.#business(s.business).roles[s.role]?.authority?.withinRole.includes('decision.resolve.authority') || resolverSubject(this.#business(s.business),s.role,p.name))fail('not-resolver'); }; recheck(); diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 37f6da205..0539360ca 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -111,18 +111,28 @@ export async function harnessLevels({ log = (e) => process.stderr.write(`plugins try { names = [...new Set([...names,...Object.keys(readPluginLock(dataRoot).plugins).filter(name=>hasModule('harness',name,dataRoot))])]; } catch { log({event:'harness-levels-unread',message:'installed harness discovery refused'}); } } + const accounts = new Map(); for (const name of names) { const loader = createLoader({ log, dataRoot }); if ((await loader.load({ harness: [name] })).refused.length) continue; + const [module] = loader.modules('harness'); try { - const list = loader.modules('harness')[0].levels(); + const list = module.levels(); if (Array.isArray(list) && list.every((level) => typeof level === 'string')) levels.set(name, Object.freeze([...list])); } catch (e) { log({ event: 'harness-levels-failed', name, message: e.message }); } + // AU (#1873): whether this harness's launches spend launch.account, as its module declares. + try { + const counts = module.countsAccount(); + if (typeof counts === 'boolean') accounts.set(name, counts); + } catch (e) { + log({ event: 'harness-account-failed', name, message: e.message }); + } } const lookup = (name) => levels.get(name) ?? null; lookup.names = [...levels.keys()].sort(); + lookup.countsAccount = (name) => accounts.get(name) ?? null; return lookup; } // Row 224 C2-05b: a host-only adapter. A failed fetch keeps serving immutable old @@ -891,7 +901,7 @@ export async function startBroker({ return INSTANCE_VERBS.has(verb)?{...result,restart:!live}:result; }):null, // Row 194 (D2.3): the role files as they stand, for the hire checks. - autonomy: organization ? autonomyHost({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),dataRoot,business:id=>businesses[id],hires:{reverse:(...args)=>broker.hireReverse(...args)},reload,processing}) : null, + autonomy: organization ? autonomyHost({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),dataRoot,business:id=>businesses[id],countsAccount:levels.countsAccount,hires:{reverse:(...args)=>broker.hireReverse(...args)},reload,processing}) : null, hires: organization ? (()=>{ const service=hireDecisions({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),rolesDir:organization.rolesDir,configDir:organization.dir,dataRoot, system:organization.system,levels,catalogueFor:async(id,doc)=>doc.plugins?.['model-provider']?.length?catalogueFor(id,{fresh:true}):null,assertClean:value=>credentials.assertClean(value),scanDefinition:(name,entry)=>scanDefinitions({[name]:entry},value=>credentials.assertClean(value)),registryFor:doc=>organizationPlugins(instanceRoot??dataRoot,doc.plugins).registry}); diff --git a/packages/bus/tests/autonomy-authority.test.mjs b/packages/bus/tests/autonomy-authority.test.mjs index e0db934f8..b63c3007e 100644 --- a/packages/bus/tests/autonomy-authority.test.mjs +++ b/packages/bus/tests/autonomy-authority.test.mjs @@ -17,6 +17,8 @@ function world(t,options={}) { const root=mkdtempSync(join(tmpdir(),'au-authority-')),rolesDir=rolesCopy(root),dataRoot=join(root,'data');mkdirSync(dataRoot,{mode:0o700}); const doc=businessDoc(root,'demo');doc.plugins={harness:['pi'],'limit-check':['token-limit']}; doc.launch.limits.tokens={providers:{zai:cap}}; + // #1873: an account quota alone, covered only through the host's declared harness fact. + if(options.account){doc.launch.account={name:'shared',credential:'FIXTURE_TOKEN'};doc.launch.limits.tokens={keys:{shared:cap}};} doc.definitions={judge:{rev:1,role:{...inline,authority:{withinRole:['decision.resolve.authority'],crossRole:[]}},instructions:'Judge'},scout:{rev:3,role:structuredClone(inline),instructions:'Prior role'}}; // Definition instructions live beside the role composition. for(const d of Object.values(doc.definitions))delete d.role.instructions; @@ -31,7 +33,7 @@ function world(t,options={}) { const reload=id=>{if(reversing && options.crossAfterCommit)Date.now=()=>now()+3600001;if(reversing && options.reloadRefused)return false;broker.reloadBusiness(id,loaded());return true;}; const service=hireDecisions({writer,rolesDir,configDir:root,dataRoot,assertClean:()=>{}}); const hires=authorityHost(service,{reload,modelKeys:{prepareRevoke:async(instance,guard)=>{guard();assert.equal(instance,'demo/scout');order.push('prepare');},completeRevoke:instance=>{assert.equal(instance,'demo/scout');assert.ok(store.get("SELECT 1 FROM events WHERE json_extract(body,'$.action')='fire.stopped'"));assert.equal(writer.read('demo').document.definitions.scout.instructions,'Changed role');order.push('complete');}}}); - const host=autonomyHost({writer,dataRoot,business:()=>loaded(),hires,reload}); + const host=autonomyHost({writer,dataRoot,business:()=>loaded(),hires,reload,...(options.countsAccount?{countsAccount:options.countsAccount}:{})}); const instances=instanceSettings({dir:root,rolesDir,writer,revoke:()=>{order.push('fire-revoke');}}); broker=new Broker({store,businesses:{demo:loaded()},hires,autonomy:host,organization:(id,verb,args,actor,context)=>{if(verb==='instance.fireCheck'){instances.checkFire(id,args.name);return {};}if(verb==='instance.fire')return instances.fire(id,args,{actor,context});throw Error('unexpected organization verb');}}); const caps={};const call=(who,verb,args={})=>broker.request(caps[who],{verb,args}); @@ -107,3 +109,11 @@ test('AU inverse receipt-write failure returns committed with trailPending and n assert.equal(f.writer.read('demo').document.definitions.scout.instructions,'Prior role'); assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse.failed'").n,0); }); +test('AU an account-quota authority raise routes to the resolver only when the host says every harness spends the account (#1873)',async t=>{ + const covered=world(t,{account:true,countsAccount:name=>name==='pi'}),d=await covered.raise();assert.equal(d.route_to,'judge'); + for(const countsAccount of [undefined,()=>false,()=>null]){ + const f=world(t,{account:true,countsAccount}); + await assert.rejects(f.raise(),{code:'autonomy-precondition'}); + assert.equal(f.store.get("SELECT count(*) n FROM decisions").n,0); + } +}); diff --git a/packages/bus/tests/routes-verb.test.mjs b/packages/bus/tests/routes-verb.test.mjs index 5abac21f6..75b589ec5 100644 --- a/packages/bus/tests/routes-verb.test.mjs +++ b/packages/bus/tests/routes-verb.test.mjs @@ -96,6 +96,9 @@ test('row 65 runtime: harnessLevels answers each module in the tree, and null fo assert.deepEqual(levels('pi'), ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']); assert.deepEqual(levels('claude-code'), ['low', 'medium', 'high', 'xhigh', 'max']); for (const name of ['cursor', '', '../pi']) assert.equal(levels(name), null, name); + // AU (#1873): each module's own countsAccount answer, read once with its levels. + assert.deepEqual([levels.countsAccount('pi'), levels.countsAccount('claude-code')], [false, true]); + for (const name of ['cursor', '', '../pi']) assert.equal(levels.countsAccount(name), null, name); assert.deepEqual(logged, []); }); diff --git a/packages/organization/src/autonomy.mjs b/packages/organization/src/autonomy.mjs index 41701b12f..d5df0a51c 100644 --- a/packages/organization/src/autonomy.mjs +++ b/packages/organization/src/autonomy.mjs @@ -30,12 +30,15 @@ export function resolverSubject(business, resolver, target) { } // Aggregate provider/model limits cover names that do not exist yet. An account -// quota covers every future launch only when every enabled harness uses that account. -export function authorityBudgetCovered(business) { +// quota covers every future launch only when every enabled harness's module declares +// that its launches spend launch.account (its countsAccount export). The host passes +// that answer as countsAccount(name) -> true|false|null; core names no harness, and +// without the lookup no account quota counts. +export function authorityBudgetCovered(business, {countsAccount=()=>null}={}) { const launch=business.launch, tokens=launch?.limits?.tokens, checks=business.plugins?.['limit-check']??[]; const quota=x=>Number.isSafeInteger(x?.limit) && x.limit>=0 && typeof x.since==='string' && !Number.isNaN(Date.parse(x.since)); const account=launch?.account?.name, harnesses=business.plugins?.harness??[]; - if(account && harnesses.length && harnesses.every(h=>h==='claude-code') && + if(account && harnesses.length && harnesses.every(h=>countsAccount(h)===true) && (checks.includes('daily-quota') && quota(tokens?.daily?.[account]) || checks.includes('token-limit') && quota(tokens?.keys?.[account]) && tokens.keys[account].reset===undefined))return true; const models=Object.values(launch?.models??{}); diff --git a/packages/organization/tests/autonomy.test.mjs b/packages/organization/tests/autonomy.test.mjs index 556a34ad2..cac58ea4b 100644 --- a/packages/organization/tests/autonomy.test.mjs +++ b/packages/organization/tests/autonomy.test.mjs @@ -38,8 +38,25 @@ test('AU authority budgets cover all selectable models and only enforced account b.launch.limits.tokens.models={two:{n:cap}};assert.equal(authorityBudgetCovered(b),true); b.plugins['limit-check']=[];assert.equal(authorityBudgetCovered(b),false); b.launch.account={name:'shared'};b.launch.limits.tokens={keys:{shared:cap}};b.plugins['limit-check']=['token-limit']; - assert.equal(authorityBudgetCovered(b),false); - b.plugins.harness=['claude-code'];assert.equal(authorityBudgetCovered(b),true); - b.plugins.harness.push('pi');assert.equal(authorityBudgetCovered(b),false); - b.plugins.harness=['claude-code'];b.plugins['limit-check']=['daily-quota'];b.launch.limits.tokens={daily:{shared:{...cap,reset:'00:00',timeZone:'UTC'}}};assert.equal(authorityBudgetCovered(b),true); + // What today's two harness modules declare (packages/plugins/harness/*/index.mjs countsAccount). + const declared={countsAccount:h=>({'claude-code':true,pi:false})[h]??null}; + assert.equal(authorityBudgetCovered(b,declared),false); + b.plugins.harness=['claude-code'];assert.equal(authorityBudgetCovered(b,declared),true); + b.plugins.harness.push('pi');assert.equal(authorityBudgetCovered(b,declared),false); + b.plugins.harness=['claude-code'];b.plugins['limit-check']=['daily-quota'];b.launch.limits.tokens={daily:{shared:{...cap,reset:'00:00',timeZone:'UTC'}}};assert.equal(authorityBudgetCovered(b,declared),true); +}); +test('AU an account quota covers authority only when every enabled harness declares it spends the launch account (#1873)',()=>{ + const cap={limit:0,since:'2026-01-01T00:00:00.000Z'}; + const b={plugins:{harness:['alpha','beta'],'limit-check':['token-limit']},launch:{account:{name:'shared'},models:{one:{provider:'one',model:'m'}},limits:{tokens:{keys:{shared:cap}}}}}; + const declares=map=>({countsAccount:h=>map[h]??null}); + // The declared fact decides, whatever the harness is called. + assert.equal(authorityBudgetCovered(b,declares({alpha:true,beta:true})),true); + assert.equal(authorityBudgetCovered(b,declares({alpha:true,beta:false})),false); + assert.equal(authorityBudgetCovered(b,declares({alpha:true})),false,'an undeclared harness is not covered'); + assert.equal(authorityBudgetCovered(b,declares({alpha:true,beta:'yes'})),false,'only true counts'); + b.plugins.harness=['claude-code']; + assert.equal(authorityBudgetCovered(b,declares({'claude-code':false})),false,'the name alone covers nothing'); + assert.equal(authorityBudgetCovered(b),false,'no lookup: fail closed'); + // The provider/model branch needs no harness fact. + b.launch.limits.tokens.providers={one:cap};assert.equal(authorityBudgetCovered(b),true); });