From 0ef6326084f53ab40a09fc15988b0aaaad53bb70 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 11 Nov 2026 08:51:12 -0600 Subject: [PATCH 1/4] feat(org): add per-class autonomy and guarded reversals (#1873) --- packages/bus/src/autonomy-host.mjs | 46 +++ packages/bus/src/autonomy-state.mjs | 11 + packages/bus/src/broker.mjs | 300 ++++++++++++++++-- packages/bus/src/landing.mjs | 13 +- packages/bus/src/model-keys.mjs | 4 +- packages/bus/src/refusals.mjs | 4 + packages/bus/src/runtime.mjs | 17 +- packages/bus/tests/autonomy-host.test.mjs | 70 ++++ packages/bus/tests/autonomy.test.mjs | 111 +++++++ packages/bus/tests/console-decide.test.mjs | 5 +- packages/bus/tests/hard-waits.test.mjs | 2 +- packages/bus/tests/hire-decisions.test.mjs | 31 ++ packages/bus/tests/host-fixtures.mjs | 6 +- packages/bus/tests/model-keys.test.mjs | 7 + packages/bus/tests/project-runtime.test.mjs | 5 +- packages/bus/tests/request-console.test.mjs | 2 +- packages/bus/tests/spec-fixtures.mjs | 4 +- packages/bus/tests/task-store.test.mjs | 15 + packages/business/src/autonomy.mjs | 2 + packages/cli/tests/case2-install.test.mjs | 2 +- packages/organization/README.md | 29 ++ packages/organization/src/autonomy.mjs | 30 ++ packages/organization/src/hire-decisions.mjs | 37 ++- .../organization/src/instance-settings.mjs | 3 +- packages/organization/src/org-rights.mjs | 5 + .../organization/src/organization-config.mjs | 13 +- packages/organization/src/organization.mjs | 54 +++- packages/organization/src/rails.mjs | 13 +- packages/organization/src/resolve.mjs | 2 +- packages/organization/src/vocabulary.mjs | 2 + .../tests/autonomy-inverse.test.mjs | 55 ++++ packages/organization/tests/autonomy.test.mjs | 31 ++ .../organization/tests/org-rights.test.mjs | 5 + packages/seat/src/sandbox.mjs | 2 + packages/seat/src/working.mjs | 24 ++ packages/seat/tests/working.test.mjs | 16 + packages/webui/src/autonomy-http.mjs | 28 ++ packages/webui/src/public/autonomy.css | 10 + packages/webui/src/public/autonomy.js | 60 ++++ packages/webui/src/public/bus.js | 5 +- packages/webui/src/public/index.html | 2 +- packages/webui/src/public/workspace.js | 3 +- packages/webui/src/serve.mjs | 14 +- .../webui/tests/autonomy-browser.test.mjs | 37 +++ packages/webui/tests/autonomy-http.test.mjs | 28 ++ packages/webui/tests/browser.test.mjs | 2 +- .../fixtures/rename-a/business-response.json | 6 + 47 files changed, 1097 insertions(+), 76 deletions(-) create mode 100644 packages/bus/src/autonomy-host.mjs create mode 100644 packages/bus/src/autonomy-state.mjs create mode 100644 packages/bus/tests/autonomy-host.test.mjs create mode 100644 packages/bus/tests/autonomy.test.mjs create mode 100644 packages/business/src/autonomy.mjs create mode 100644 packages/organization/src/autonomy.mjs create mode 100644 packages/organization/tests/autonomy-inverse.test.mjs create mode 100644 packages/organization/tests/autonomy.test.mjs create mode 100644 packages/webui/src/autonomy-http.mjs create mode 100644 packages/webui/src/public/autonomy.css create mode 100644 packages/webui/src/public/autonomy.js create mode 100644 packages/webui/tests/autonomy-browser.test.mjs create mode 100644 packages/webui/tests/autonomy-http.test.mjs diff --git a/packages/bus/src/autonomy-host.mjs b/packages/bus/src/autonomy-host.mjs new file mode 100644 index 000000000..960d3ddd3 --- /dev/null +++ b/packages/bus/src/autonomy-host.mjs @@ -0,0 +1,46 @@ +// AU trusted host seam. No worker can choose a path, snapshot or inverse operation. +import { mkdirSync, symlinkSync } from 'node:fs'; +import { join } from 'node:path'; +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}}) { + return { + 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'); + const result=writer.write(id,{...old.document,autonomy:args.autonomy},{revision:args.revision,actor,detail:{verb:'autonomy.set'}}); + const live=reload(id);return {...result,restart:live===false}; + }, + reversible:cls=>['cross-role','merge'].includes(cls) || cls==='authority' && typeof hires?.reverse==='function', + async reverse({business:id,decision,decisionClass,raised,effects,actor,beforeWrite,fire,stop,relaunch,record}) { + if(decisionClass==='authority') { + if(!hires?.reverse)refuse('reversal-unavailable'); + const result=await hires.reverse(id,decision,{actor,beforeWrite,fire,stop,relaunch,record});reload(id);if(result.relaunch){beforeWrite();await relaunch(result.relaunch);}return {transaction:result.transaction??null}; + } + const used=effects.filter(e=>e.operation!=='decision.raise' && !String(e.operation??'').startsWith('autonomy.')); + // An unused approval has changed no task or policy. Reversing it revokes that permission. + if(!used.length) {beforeWrite();return {permissionRevoked:true};} + if(decisionClass!=='merge')refuse('reversal-unavailable'); + const landing=used.find(e=>e.action==='git.merge.protected' && typeof e.commit==='string'); + if(!landing)refuse('reversal-unavailable'); + const b=business(id),p=b.projects[landing.project];if(!p)refuse('project-not-found'); + const name=`reverse-${randomBytes(6).toString('hex')}`,dir=join(dataRoot,'autonomy-reversals',decision,name); + mkdirSync(dir,{recursive:true,mode:0o700}); + const where={dataRoot,business:id,project:landing.project,env}; + let prepared; + return processing.work(async scope=>{ + try { + if(!await gate.classify(p.root,env))refuse('gate-missing'); + prepared=await prepareRevert({...where,commit:landing.commit,dir,name,decision}); + symlinkSync(join(p.root,'node_modules'),join(prepared.worktree,'node_modules')); + const result=await gate.run({worktree:prepared.worktree,logs:dir,env:gateEnv(env),signal:scope.signal}); + scope.guard();if(!result.ok)refuse('gate-failed');beforeWrite(); + const receipt=await finishLand({...where,land:prepared,record:receipt=>{scope.guard();beforeWrite();record({...receipt,reverted:landing.commit});}}); + return {...receipt,reverted:landing.commit}; + } finally {if(prepared)await dropLand({...where,worktree:prepared.worktree});} + }); + }, + }; +} diff --git a/packages/bus/src/autonomy-state.mjs b/packages/bus/src/autonomy-state.mjs new file mode 100644 index 000000000..065d4d492 --- /dev/null +++ b/packages/bus/src/autonomy-state.mjs @@ -0,0 +1,11 @@ +// AU reads only immutable evidence. These operations are reserved broker writes. +export function autonomyEvent(q, business, decision, operations) { + const placeholders=operations.map(()=>'?').join(','); + const row=q.get(`SELECT * FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.decision')=? AND json_extract(body,'$.operation') IN (${placeholders}) ORDER BY seq DESC LIMIT 1`,business,decision,...operations); + return row?{...row,body:JSON.parse(row.body)}:null; +} +export function decisionResolution(q, business, decision) { + const overlay=autonomyEvent(q,business,decision,['autonomy.resolved','autonomy.declined']); + if(overlay)return overlay.body.operation==='autonomy.declined'?null:{...overlay.body.resolution,seq:overlay.seq}; + return q.get("SELECT * FROM decision_events WHERE decision=? AND op IN ('resolved','withdrawn','expired') ORDER BY seq DESC LIMIT 1",decision); +} diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 52c10ad67..5f8c05924 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -1,3 +1,5 @@ +import { autonomyEvent, decisionResolution } from './autonomy-state.mjs'; +import { autonomyClass, checkAutonomy, decisionAutonomy, resolverSubject } from '../../organization/src/autonomy.mjs'; import { ownRails } from '../../organization/src/rails.mjs'; import { refusalGuidance } from './refusals.mjs'; import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs'; @@ -54,6 +56,7 @@ export const ACTIONS = Object.freeze([ 'prd.approve', 'decision.resolve.technical', 'decision.resolve.delivery', + 'decision.resolve.authority', // Row 285 (C2-08b, #1790): routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6, below. 'library.write', ]); @@ -83,6 +86,7 @@ const ROUTED = new Set([ 'prd.approve', 'decision.resolve.technical', 'decision.resolve.delivery', + 'decision.resolve.authority', // PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks. 'role.hire', 'role.fire', @@ -174,7 +178,7 @@ export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'threa 'thread.share', 'thread.unshare', 'thread.shares']); // Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb // refuses it human-required before its own checks, and these refuse every other cap. -export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', +export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', // Task 3 (#1839): and the owner's intake controls. The reads and writes are the same lifecycle methods the PM's // unsuffixed verbs reach, with the owner's verified (provider, iss, sub) as the only caller; the PM-only // request.question and the PM's own reads keep their unsuffixed names and refuse a console cap. @@ -525,6 +529,7 @@ const name = (x) => text(x, 200) && !/[\r\n]/.test(x); // The checks a business document passes at boot and at every reload. function checkBusiness(name, b) { + checkAutonomy(b.autonomy); // Row 177: a human named `tester` would share the tester's threads. if (!id(name) || b.id !== name || !id(b.human) || b.human === 'tester' || !object(b.roles) || !object(b.arbiters)) fail('invalid-business'); @@ -597,6 +602,7 @@ export class Broker { #builtinBinding; #organization; #hires; + #autonomyHost; #pluginService; #credentialVerbs; #proxyInit; @@ -660,7 +666,7 @@ export class Broker { #processing; #taskTracker; // credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use(). - constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, hires = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) { + constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, hires = null, autonomy = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) { if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business'); // Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store. if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function'); @@ -684,6 +690,7 @@ export class Broker { this.#credentialStatus = credentialStatus; this.#organization = organization; this.#hires = hires; + this.#autonomyHost = autonomy; this.#pluginService = pluginService; // Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker. this.#credentialVerbs = credentialVerbs; @@ -4799,10 +4806,32 @@ export class Broker { }; } #closed(id) { - return this.#store.get( - `SELECT * FROM decision_events WHERE decision=? AND op IN ${CLOSED} ORDER BY seq DESC LIMIT 1`, - id, - ); + const d=this.#store.get('SELECT business FROM decisions WHERE id=?',id); + return d ? decisionResolution(this.#store,d.business,id) : null; + } + #autonomyLevel(d) { + if(d.action==='prd.approve' || d.route_to==='human' || d.class==='within-role')return 'out of the loop'; + const current=decisionAutonomy(autonomyClass(d.action),this.#business(d.business).autonomy); + const raised=this.#raised(d.business,d.id)?.autonomyLevel??'out of the loop'; + const levels=['in the loop','near the loop','out of the loop']; + return levels[Math.min(levels.indexOf(current),levels.indexOf(raised))]; + } + #autonomyOwner(s) { + if(!s.console || !s.human || s.tester || s.reader || s.hireMembership || s.human!==this.#business(s.business).human)fail('human-required'); + } + #resolutionEvent(s,d,choice) { + const resolution={decision:d.id,op:'resolved',by:s.run,choice,via:'broker',at:this.#now()}; + if(this.#autonomyLevel(d)==='in the loop') { + this.#event(s,'action.allowed',{operation:'autonomy.pending',decision:d.id,resolution},d.id); + return {id:d.id,op:'pending',choice}; + } + return null; + } + #autonomyNotice(s,d,resolution) { + if(this.#autonomyLevel(d)!=='near the loop' || resolution.choice!==this.#raised(d.business,d.id)?.approvalChoice)return; + const hours=this.#business(d.business).autonomy?.reverseWindowHours??24; + this.#event(s,'action.allowed',{operation:'autonomy.notice',decision:d.id,decisionClass:autonomyClass(d.action),resolution, + reverseUntil:new Date(Date.parse(resolution.at)+hours*3600000).toISOString()},d.id); } // Row 58 (#1547): the 'tasks' rows, each with the task's priority and requirement. Priority // is the current row's; a tombstone has none. The requirement is the first task.created for @@ -5023,6 +5052,8 @@ export class Broker { const json = JSON.stringify(fields); if (Buffer.byteLength(json) > STORE_TASK_BYTES) fail('invalid-request'); this.#secretCheck(fields); + const near=kind==='action.allowed' && body?.decision && this.#autonomyLevel(this.#decision(s,body.decision))==='near the loop'; + if(near && current)this.#event(s,'action.allowed',{operation:'autonomy.before',decision:body.decision,target:ref,fields:current.fields},ref); const event = this.#event(s, kind, { ...body, task_ref: ref, tracker: 'store', op, verb }, ref); this.#store.run( 'INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(?,?,?,?,?,?,?,?,?,?,?)', @@ -5038,7 +5069,9 @@ export class Broker { event, json, ); - return op === 'delete' ? { task_ref: ref, deleted: true, event } : this.#storeCurrent(s.business, ref); + const result=op === 'delete' ? { task_ref: ref, deleted: true, event } : this.#storeCurrent(s.business, ref); + if(near && current)this.#event(s,'action.allowed',{operation:'autonomy.after',decision:body.decision,target:ref,digest:result.digest},ref); + return result; } // U4a: the store module's one write path for a role, as authorize() and recordTask() are the // Vikunja adapter's. One transaction holds the holder check, the authority (a decision is spent here @@ -5122,7 +5155,9 @@ export class Broker { context.target !== target ) fail('decision-mismatch'); + if(!r && autonomyEvent(this.#store,s.business,d.id,['autonomy.pending','autonomy.declined','autonomy.confirmed'])?.body.operation==='autonomy.pending')fail('confirmation-required'); if (r?.op !== 'resolved' || r.choice !== context.approvalChoice) fail('decision-not-approved'); + this.#effectiveDecision(d, r); if (action === 'role.revoke') this.#protectedEnd(s, target, r); // A thread instance has no role claim, so both sides are null for it (row 155, #1649): the raise // records `?? null` and the current holder reads undefined. @@ -5147,14 +5182,25 @@ export class Broker { ); return evidence ? JSON.parse(evidence.body) : null; } + #effectiveDecision(d, r) { + if (d.route_to === 'human' || d.class==='within-role' || r.via !== 'broker') return; + const b = this.#business(d.business); + if (d.raised_by_role === d.route_to || resolverSubject(b, d.route_to, this.#raised(d.business,d.id)?.target)) fail('not-resolver'); + this.#checkResolver({business:d.business,role:d.route_to},d); + if(autonomyEvent(this.#store,d.business,d.id,['autonomy.reverse.consume']))fail('decision-reversed'); + if(this.#autonomyLevel(d)==='in the loop') { + const confirmed=autonomyEvent(this.#store,d.business,d.id,['autonomy.confirmed']); + if(!confirmed || confirmed.body.resolution.at!==r.at || confirmed.body.resolution.by!==r.by)fail('confirmation-required'); + } + } #checkResolver(s, d) { const b = this.#business(s.business), role = b.roles[s.role]; - const domain = d.action === 'prd.approve' ? 'technical' : DECISION_DOMAINS[d.action] ?? this.#raised(s.business, d.id)?.domain; + const domain = ['role.hire','role.fire','role.change'].includes(d.action) ? 'authority' : d.action === 'prd.approve' ? 'technical' : DECISION_DOMAINS[d.action] ?? this.#raised(s.business, d.id)?.domain; // Pre-RR evidence has no domain. Assignments may have changed since raise, // so require both possible rights rather than infer one from today's file. const domains = domain === undefined ? ['technical', 'delivery'] : [domain]; for (const kind of domains) { - if (!['technical', 'delivery'].includes(kind)) fail('not-resolver'); + if (!['technical', 'delivery','authority'].includes(kind)) fail('not-resolver'); const right = `decision.resolve.${kind}`; if (!role?.authority?.withinRole.includes(right)) { throw new BusError('not-resolver', `instance ${s.role}, role ${role?.definition ?? s.role}, lacks ${right}`); @@ -5165,7 +5211,7 @@ export class Broker { // consumed by an executor. #spent(business, decision) { return !!this.#store.get( - "SELECT 1 FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.decision')=? AND json_extract(body,'$.operation') IS NOT 'decision.raise' LIMIT 1", + "SELECT 1 FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.decision')=? AND json_extract(body,'$.operation') IS NOT 'decision.raise' AND COALESCE(json_extract(body,'$.operation'),'') NOT LIKE 'autonomy.%' LIMIT 1", business, decision, ); @@ -5199,6 +5245,9 @@ export class Broker { const s = this.#session(cap); return this.#store.transaction(() => { const result = this.#consumeAuthority(s, action, context); + // External adapters have no host-owned prior task/policy snapshot. Near-loop work + // must use the store mutation path, which commits its inverse evidence atomically. + if(result.decision && this.#autonomyLevel(this.#decision(s,result.decision))==='near the loop' && ['cross-role','policy'].includes(autonomyClass(action)))fail('reversal-unavailable'); if (action === 'task.close') this.#closeVerdict(s, context.target, context.verdict); return result; }); @@ -5711,6 +5760,7 @@ export class Broker { this.#agent(s); if (['human.input', 'launch.revoked', 'launch.restored', ...REVIEW_KINDS].includes(kind)) fail('reserved-event'); if (kind === 'action.allowed' && ['git.push.working', MERGE, 'processing.halt', 'processing.resume', 'processing.stop'].includes(body?.action)) fail('reserved-event'); + if (kind === 'action.allowed' && (String(body?.operation??'').startsWith('autonomy.') || body?.operation==='decision.raise' || String(body?.operation??'').startsWith('hire.'))) fail('reserved-event'); if (kind === 'action.allowed' && grantName(body)) fail('reserved-event'); return this.#event(s, kind, body, subject); }); @@ -6096,7 +6146,19 @@ export class Broker { return this.#withLast(cap, s, request); } if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args)); + if(['autonomy.get.console','autonomy.set.console','autonomy.review.console','decision.reverse.console'].includes(request.verb)) { + this.#autonomyOwner(s); + return this.#autonomyConsole(s,request.verb,structuredClone(args)).catch(e=>{throw this.#refused(s,e instanceof BusError?e:new BusError(e.code??'outcome-unknown'),request);}); + } if (s.hireMembership && !['hire.preview.console','hire.resolve.console','hire.start.console','hire.templates.console'].includes(request.verb)) fail('human-required'); + if(request.verb==='decision.resolve' && !s.human) { + if(s.tester)fail('tester-refused'); + keys(args,['id','choice','note'],['id','choice']);identifier(args.id); + } + if(request.verb==='decision.resolve' && !s.human && ['role.hire','role.fire','role.change'].includes(this.#decision(s,args.id).action)) { + const d=this.#decision(s,args.id); + if(d.route_to!=='human')return this.#resolveAuthority(s,args).catch(e=>{throw this.#refused(s,e instanceof BusError?e:new BusError(e.code??'outcome-unknown'),request);}); + } if(request.verb==='hire.templates.console') { keys(args,[]);const caller=this.#hireCaller(s); if(!caller.rights.includes('org.hire'))fail('right-required'); @@ -6313,6 +6375,89 @@ export class Broker { } return result; } + async #autonomyConsole(s,verb,a) { + if(verb==='autonomy.get.console') { + keys(a,[]); + if(!this.#autonomyHost?.get)fail('not-configured'); + const current=await this.#autonomyHost.get(s.business); + return {...current,levels:Object.fromEntries(['cross-role','merge','policy','authority'].map(k=>[k,decisionAutonomy(k,current.autonomy)]))}; + } + if(verb==='autonomy.review.console') { + keys(a,['decisionClass'],['decisionClass']);decisionAutonomy(a.decisionClass); + const latest=this.#store.get("SELECT seq FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.set' ORDER BY seq DESC LIMIT 1",s.business); + this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.review',decisionClass:a.decisionClass,settingSeq:latest?.seq??null,subject:s.console},null)); + return {reviewed:true}; + } + if(verb==='autonomy.set.console') { + keys(a,['revision','autonomy'],['revision','autonomy']); + const AUTONOMY_CLASSES=['cross-role','merge','policy','authority']; + checkAutonomy(a.autonomy); + if(!this.#autonomyHost?.get || !this.#autonomyHost?.set)fail('not-configured'); + const old=await this.#autonomyHost.get(s.business),b=this.#business(s.business); + if(a.revision!==old.revision)fail('revision-conflict'); + if(AUTONOMY_CLASSES.some(cls=>decisionAutonomy(cls,a.autonomy)==='near the loop') && a.autonomy.notices!==true)fail('autonomy-precondition'); + for(const cls of AUTONOMY_CLASSES) { + const before=decisionAutonomy(cls,old.autonomy),after=decisionAutonomy(cls,a.autonomy); + // Pulling back is unconditional. Only a move away from the loop has prerequisites. + // Lead ruling: policy has no effect executor. Its existing out and in levels + // remain usable; near refuses in checkAutonomy until an inverse exists. + if(cls==='policy')continue; + if(['in the loop','near the loop','out of the loop'].indexOf(after)<=['in the loop','near the loop','out of the loop'].indexOf(before))continue; + if(a.autonomy.notices!==true || !this.#autonomyHost.reversible?.(cls,b))fail('autonomy-precondition'); + const resolvers=cls==='authority'?[a.autonomy.authorityResolver]:cls==='merge'?[b.arbiters.technical]:cls==='policy'?[b.arbiters.delivery]:Object.values(b.arbiters); + for(const name of resolvers) { + const role=b.roles[name],right=cls==='authority'?'decision.resolve.authority':`decision.resolve.${cls==='merge'?'technical':cls==='policy'?'delivery':name===b.arbiters.technical?'technical':'delivery'}`; + 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' && !b.launch?.limits?.tokens)fail('autonomy-precondition'); + if(after==='out of the loop') { + if(before!=='near the loop')fail('autonomy-precondition'); + const review=this.#store.get("SELECT 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); + const latest=this.#store.get("SELECT seq FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.set' ORDER BY seq DESC LIMIT 1",s.business); + if(!review || JSON.parse(review.body).settingSeq!==latest?.seq)fail('autonomy-precondition'); + if(cls==='authority' && Object.keys(b.roles).some(name=>!b.launch?.limits?.tokens?.instances?.[name]?.limit))fail('autonomy-precondition'); + } + } + this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.set.requested',autonomy:a.autonomy,subject:s.console},null)); + const result=await this.#autonomyHost.set(s.business,a,s.human); + this.#autonomyOwner(s); + this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.set',autonomy:a.autonomy,subject:s.console,transaction:result.transaction},null)); + return result; + } + keys(a,['id'],['id']);identifier(a.id); + const d=this.#decision(s,a.id),notice=autonomyEvent(this.#store,s.business,d.id,['autonomy.notice']); + const windowOpen=()=>{ + if(!notice || Date.parse(notice.body.reverseUntil)<=Math.max(Date.now(),this.#clock))throw new BusError('reverse-window-closed','The reverse window has closed; raise an ordinary new decision for this change.'); + }; + windowOpen(); + if(autonomyEvent(this.#store,s.business,d.id,['autonomy.reverse.consume']))fail('decision-reversed'); + const before=autonomyEvent(this.#store,s.business,d.id,['autonomy.before']),after=autonomyEvent(this.#store,s.business,d.id,['autonomy.after']); + if(!before && !this.#autonomyHost?.reverse)fail('reversal-unavailable'); + this.#store.transaction(()=>{ + windowOpen(); + this.#event(s,'action.allowed',{operation:'autonomy.reverse.consume',decision:d.id,subject:s.console},d.id); + }); + try { + if(before && after) return this.#store.transaction(()=>{ + windowOpen(); + const current=this.#storeCurrent(s.business,before.body.target); + if(current.digest!==after.body.digest)fail('decision-stale'); + const result=this.#storeWrite(s,{current,op:'change',verb:'tasks.edit',fields:before.body.fields,by:{role:null,run:null,human:s.human},kind:'human.input',body:{kind:'autonomy.reverse',decision:d.id}}); + this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,revision:result.revision},d.id); + return {id:d.id,reversed:true,task_ref:result.task_ref}; + }); + const effects=this.#store.all("SELECT body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.decision')=? ORDER BY seq",s.business,d.id).map(e=>JSON.parse(e.body)); + let recorded=false; + const result=await this.#autonomyHost.reverse({business:s.business,decision:d.id,decisionClass:autonomyClass(d.action),action:d.action,raised:this.#raised(s.business,d.id),effects,actor:s.human, + beforeWrite:()=>{this.#autonomyOwner(s);windowOpen();},fire:proposal=>this.#fireApproved(s,proposal,windowOpen),stop:proposal=>this.#stopCopyApproved(s,proposal),relaunch:proposal=>this.#dispatch(s,'instance.launch',{instance:proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name}),record:result=>this.#store.transaction(()=>{windowOpen();recorded=true;this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,result},d.id);})}); + if(!recorded)this.#store.transaction(()=>{windowOpen();this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,result},d.id);}); + return {id:d.id,reversed:true,...result}; + } catch(error) { + this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.reverse.failed',decision:d.id,code:error.code??'outcome-unknown'},d.id)); + throw error; + } + } #hireCaller(s) { if (!s.console || !s.human || s.reader || s.tester) fail('human-required'); if(s.hireMembership) return s.hireMembership; @@ -6340,15 +6485,68 @@ export class Broker { if(((p.flagged.length || p.credentialLines.length) && !caller.owner) || (p.level==='root' && !caller.instanceOwner))fail('hire-owner-required'); } const action=`role.${p.op}`; + const level=decisionAutonomy('authority',b.autonomy); + let route='human'; + if(!immediate && level!=='in the loop' && p.level!=='root' && !p.flagged.length && !p.credentialLines.length) { + 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'; + } this.#store.transaction(()=>{ const raiser=immediate?'human':s.role; this.#store.run('INSERT INTO decisions(id,at,business,project,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)', - did,at,s.business,p.project??null,raiser,immediate?'human-console':s.run,'gated',action,'human',`${p.op} ${p.name} at ${p.level} level?`,JSON.stringify([{key:'yes',text:'Approve'},{key:'no',text:'Deny'}]),'no',0); - this.#event(s,'action.allowed',{operation:'decision.raise',decision:did,action,approvalChoice:'yes',target:p.name,hire:p},did); + did,at,s.business,p.project??null,raiser,immediate?'human-console':s.run,'gated',action,route,`${p.op} ${p.name} at ${p.level} level?`,JSON.stringify([{key:'yes',text:'Approve'},{key:'no',text:'Deny'}]),'no',0); + this.#event(s,'action.allowed',{operation:'decision.raise',decision:did,action,approvalChoice:'yes',target:p.name,hire:p,autonomyLevel:level},did); }); 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}; } + // Trusted runtime inverse only; never a socket verb. + hireReverse(...args) { + if(!this.#hires?.reverse)fail('reversal-unavailable'); + return this.#hires.reverse(...args); + } + async #resolveAuthority(s,a) { + keys(a,['id','choice'],['id','choice']);identifier(a.id); + const d=this.#decision(s,a.id),p=this.#raised(s.business,d.id)?.hire; + const validate=()=>{ + this.#agent(s); + if(d.route_to!==s.role || d.raised_by_role===s.role || resolverSubject(this.#business(s.business),s.role,p?.name))fail('not-resolver'); + if(!p || p.level==='root' || p.flagged.length || p.credentialLines.length)fail('hire-owner-required'); + if(this.#closed(d.id))fail('decision-closed'); + if(this.#expireHire(s,d,p))fail('decision-expired'); + if(this.#autonomyLevel(d)==='in the loop')fail('hire-resolution-required'); + if(!this.#business(s.business).roles[s.role]?.authority?.withinRole.includes('decision.resolve.authority'))fail('not-resolver'); + }; + validate(); + if(!['yes','no'].includes(a.choice))fail('invalid-choice'); + if(!this.#hires)fail('not-configured'); + const caller={agent:true,authorityDecision:d.id,resolver:s.role,actor:s.role,rights:['decision.resolve.authority'],digest:p.digest,now:()=>Math.max(Date.now(),this.#clock),beforeWrite:validate}; + if(a.choice==='yes')await this.#hires.check(s.business,d.id,caller); + validate(); + this.#store.transaction(()=>{ + validate(); + this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)',d.id,this.#now(),'resolved',s.run,a.choice,'broker'); + if(a.choice==='yes')this.#event(s,'action.allowed',{operation:'hire.consume',decision:d.id,digest:p.digest,hire:p},d.id); + this.#autonomyNotice(s,d,this.#closed(d.id)); + }); + if(a.choice==='no')return {id:d.id,op:'resolved',choice:a.choice}; + // No caller-provided human identity: the trusted writer receives the resolving instance. + const recheck=()=>{ + this.#agent(s); + 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(); + try { + if(p.op==='fire' && p.level!=='org')await this.#stopCopyApproved(s,p); + recheck(); + const result=p.op==='fire' && p.level==='org'?await this.#fireApproved(s,p,recheck):await this.#hires.apply(s.business,d.id,{...caller,beforeWrite:recheck}); + this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'hire.apply',decision:d.id,transaction:result.transaction??null,revision:result.revision??null},d.id)); + return {...result,id:d.id,op:'resolved',choice:a.choice}; + } catch(error) { + this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'hire.failed',decision:d.id,code:error.code??'outcome-unknown'},d.id));throw error; + } + } async #hireConsole(s,verb,a) { const caller={...this.#hireCaller(s),now:()=>Math.max(Date.now(),this.#clock)}; keys(a,verb==='hire.preview.console'?['id','adjust']:['id','choice','digest','adjust'],verb==='hire.preview.console'?['id']:['id','choice','digest']); @@ -6408,23 +6606,26 @@ export class Broker { const b=this.#business(s.business); if(!b.roles[instance])return; if(this.#revoking.has(s.business+'/'+instance))fail('fire-in-progress'); + let running=false; const set=this.#store.transaction(()=>{ const held=this.#threaded(s.business,instance)?this.#threadHolders(s.business,instance):[this.#holder(s.business,instance)].filter(h=>h?.op==='claim'); for(const h of held)this.#claimEnd(s,h,'revoke',p.id); const runs=[...this.#runs.values()].filter(r=>r.business===s.business&&r.role===instance).map(r=>r.run); + running=runs.length>0; const request=runs.length?randomUUID():null; if(request)this.#event(s,'action.allowed',{action:'lifecycle.end',request,via:'human',by:s.human,runs,reason:'Approved fire decision',kill:true},instance); this.#event(s,'action.allowed',{action:'fire.stopped',request,decision:p.id},instance); return this.#grantSet(s.business,instance); }); if(set.length)await this.#stepG(s.business,instance,set,{job:p.id,reason:'Approved fire decision',by:s.human,flag:{fire:true}}); + return {running}; } // The existing row-281 fire executor, now entered only after a consumed human decision. - #fireApproved(s,p) { + #fireApproved(s,p,beforeWrite=()=>{}) { const args={name:p.name,reason:'Approved fire decision'},verb='instance.fire',action='role.fire'; const b=this.#business(s.business),authority={target:p.name}; const write=op=>{ - try {const result=this.#organization(s.business,op,{name:p.name,revision:p.revision},s.human,{decision:p.id,digest:p.digest}); + try {beforeWrite();const result=this.#organization(s.business,op,{name:p.name,revision:p.revision},s.human??s.role,{decision:p.id,digest:p.digest,beforeWrite}); return result?.then?result.catch(e=>this.#organizationFail(e,verb)):result; } catch(e) {this.#organizationFail(e,verb);} }; @@ -7103,12 +7304,38 @@ export class Broker { // Preserve the common request gates and refuse additions until the vocabulary names them. if (verb.startsWith('tasks.') && !PERSON_TASK_VERBS.includes(verb)) fail('unknown-verb'); switch (verb) { + case 'decision.confirm.console': { + this.#autonomyOwner(s); + keys(a,['id','confirm','reason'],['id','confirm']);identifier(a.id); + if(typeof a.confirm!=='boolean' || (!a.confirm && !text(a.reason,500)))fail('invalid-request'); + const d=this.#decision(s,a.id); + if(d.route_to==='human' || d.action==='prd.approve' || d.class==='within-role')fail('decision-not-eligible'); + const pending=autonomyEvent(this.#store,s.business,d.id,['autonomy.pending','autonomy.declined','autonomy.confirmed']); + const closed=this.#closed(d.id); + if(pending?.body.operation!=='autonomy.pending')fail('decision-not-eligible'); + const resolution=pending.body.resolution; + if(!resolution || resolution.op!=='resolved' || this.#autonomyLevel(d)!=='in the loop')fail('decision-not-eligible'); + if(this.#spent(s.business,d.id))fail('decision-consumed'); + this.#checkResolver({business:s.business,role:d.route_to},d); + if(d.raised_by_role===d.route_to || resolverSubject(b,d.route_to,this.#raised(s.business,d.id)?.target))fail('not-resolver'); + this.#event(s,'human.input',{kind:'autonomy.confirmation',decision:d.id,confirm:a.confirm,reason:a.reason??null,subject:s.console},d.id); + if(!a.confirm) { + this.#event(s,'action.allowed',{operation:'autonomy.declined',decision:d.id,reason:a.reason},d.id); + return {id:d.id,op:'declined'}; + } + if(!this.#store.get("SELECT 1 FROM decision_events WHERE decision=? AND op='resolved'",d.id)) { + this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)',d.id,resolution.at,'resolved',resolution.by,resolution.choice,'broker'); + } + this.#event(s,'action.allowed',{operation:'autonomy.resolved',decision:d.id,resolution},d.id); + this.#event(s,'action.allowed',{operation:'autonomy.confirmed',decision:d.id,resolution},d.id); + return {id:d.id,op:'confirmed'}; + } case 'my_rails': { keys(a, []); this.#agent(s); const entry = b.roles[s.role], definition = b.definitions?.[entry.definition]; return ownRails({ instance: s.role, authority: entry.authority, network: entry.network ?? definition?.network, - credentials: definition?.credentials ?? [], messaging: definition?.messaging, arbiters: b.arbiters, launcher: b.launch?.by }); + credentials: definition?.credentials ?? [], messaging: definition?.messaging, arbiters: b.arbiters, launcher: b.launch?.by, autonomy: b.autonomy }); } case 'role.claim': { keys(a, []); @@ -7255,14 +7482,15 @@ export class Broker { const approvalChoice = a.approvalChoice ?? 'yes'; if (!choices.includes(approvalChoice)) fail('invalid-approval-choice'); if (a.domain !== undefined && !['technical', 'delivery'].includes(a.domain)) fail('invalid-domain'); - const domain = DECISION_DOMAINS[a.action] ?? a.domain ?? 'delivery'; + if (a.action === 'prd.approve' && a.domain !== undefined && a.domain !== 'technical') throw new BusError('invalid-domain', 'prd.approve requires domain technical'); + const domain = a.action === 'prd.approve' ? 'technical' : DECISION_DOMAINS[a.action] ?? a.domain ?? 'delivery'; // PRD 1.5 REQ-DEC-2 (row 139, #1633): a gated raise citing the project's current spec commit // goes to the arbiter for its domain, like a cross-role one. An uncited gated raise and any // hard wait go to the human. readSpec already refused a stale or unconfirmed citation. const arbitrated = cls === 'cross-role' || (cls === 'gated' && spec !== null && !this.#hardWait(s, a.action, a.target)); const proposedRoute = arbitrated ? b.arbiters[domain] : cls === 'gated' ? 'human' : s.role; - const route = arbitrated && proposedRoute === s.role ? 'human' : proposedRoute; + const route = arbitrated && (proposedRoute === s.role || resolverSubject(b, proposedRoute, a.target)) ? 'human' : proposedRoute; // Row 177 r2 (#1675, F1): nor does it put a decision in the human's inbox. A raise routed to an // arbiter stays allowed; the chief's reach to the human is no new reach (README, N1). if (route === 'human' && this.#testerThread(s)) fail('tester-thread-human'); @@ -7338,6 +7566,7 @@ export class Broker { action: a.action, operation: 'decision.raise', domain, + ...(arbitrated && a.action!=='prd.approve' && route!=='human' ? {autonomyLevel:decisionAutonomy(autonomyClass(a.action),b.autonomy)} : {}), task_ref: a.task_ref ?? null, target: a.target ?? null, approvalChoice, @@ -7403,13 +7632,21 @@ export class Broker { } } else if (d.route_to === 'human') this.#human(s); else { + if(s.human && this.#autonomyLevel(d)==='in the loop')fail('not-resolver'); this.#agent(s); if (s.role !== d.route_to) fail('not-resolver'); - if (verb === 'decision.resolve') this.#checkResolver(s, d); + if (verb === 'decision.resolve') { + if(d.class!=='within-role' && (d.raised_by_role===s.role || resolverSubject(b,s.role,this.#raised(s.business,d.id)?.target)))fail('not-resolver'); + this.#checkResolver(s, d); + } } const op = verb === 'decision.resolve' || viaConsole ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn'; if (op === 'resolved' && !JSON.parse(d.options).some((o) => o.key === a.choice)) fail('invalid-choice'); + if(op==='resolved' && !s.human && d.route_to!=='human' && this.#autonomyLevel(d)==='in the loop') { + const pending=this.#resolutionEvent(s,d,a.choice); + if(pending)return pending; + } const seq = Number( this.#store.run( 'INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)', @@ -7422,6 +7659,7 @@ export class Broker { s.console ? 'console' : s.human ? 'cli' : 'broker', ).lastInsertRowid, ); + if(op==='resolved' && !s.human)this.#autonomyNotice(s,d,this.#closed(d.id)); // Row 252 (N2): the human.input comes before #approvedGrant, which needs a console answer's // record to start the executor. Its body names the owner subject and the close's seq. if (s.human) @@ -7846,19 +8084,19 @@ export class Broker { keys(a, allowed, verb === 'trail' ? ['subject'] : []); if (!s.human && !s.reader) this.#agent(s); if (verb === 'inbox')this.#expireHires(s); - if (verb === 'inbox') - return this.#store - .all( - `SELECT d.* FROM decisions d WHERE business=? AND route_to=? AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED}) ORDER BY CASE class WHEN 'gated' THEN 0 ELSE 1 END,seq`, - s.business, - s.human || s.reader ? 'human' : s.role, - ) - .map((d) => { - // Row 252: a grant request the Console can answer carries its two answers. The Console - // shows buttons from this, and the answer path rechecks it at the bus. - const c = s.human || s.reader ? this.#consoleResolution(d) : null; - return c ? { ...this.#decisionView(d), console: c } : this.#decisionView(d); - }); + if (verb === 'inbox') { + const owner=!!(s.human || s.reader); + return this.#store.all('SELECT * FROM decisions WHERE business=? ORDER BY seq',s.business).flatMap(d=>{ + const closed=this.#closed(d.id), pending=autonomyEvent(this.#store,s.business,d.id,['autonomy.pending','autonomy.declined','autonomy.confirmed']); + const waiting=pending?.body.operation==='autonomy.pending' && !closed; + const notice=autonomyEvent(this.#store,s.business,d.id,['autonomy.notice']); + const reversed=autonomyEvent(this.#store,s.business,d.id,['autonomy.reverse.consume']); + if(owner && (waiting || (notice && !reversed)))return [{...this.#decisionView(d),autonomy:{pending:waiting,notice:!!notice,reverseUntil:notice?.body.reverseUntil??null}}]; + if(closed || waiting || d.route_to!==(owner?'human':s.role))return []; + const c=owner?this.#consoleResolution(d):null; + return [c?{...this.#decisionView(d),console:c}:this.#decisionView(d)]; + }).sort((a,b)=>(a.class==='gated'?0:1)-(b.class==='gated'?0:1)||a.seq-b.seq); + } if (verb === 'agents') { const claims = this.#store.all( "SELECT * FROM role_claims c WHERE business=? AND op='claim' AND seq=(SELECT max(seq) FROM role_claims WHERE business=c.business AND role=c.role) ORDER BY role", diff --git a/packages/bus/src/landing.mjs b/packages/bus/src/landing.mjs index f4d05ba66..2b34bcea0 100644 --- a/packages/bus/src/landing.mjs +++ b/packages/bus/src/landing.mjs @@ -1,3 +1,6 @@ +import { decisionAutonomy, resolverSubject } from '../../organization/src/autonomy.mjs'; +import { autonomyEvent } from './autonomy-state.mjs'; +import { decisionResolution } from './autonomy-state.mjs'; // The checks a landing makes on its merge decision and the task's review // (row 137, #1631). Two callers: scripts/land-work/read.mjs, which lands on // canonical refactor from a read-only copy of the bus, and the broker's @@ -96,7 +99,8 @@ export function readLanding(q, business, id, { into, arbiter = false }) { const raise = json(raises[0].body) ?? {}; if (d.action !== MERGE || raise.action !== MERGE) refuse('not-a-merge', `action ${d.action}`); - const closes = queries.closes(q, id); + const resolved=decisionResolution(q,b,id); + const closes=resolved?[resolved]:[]; if (closes.length !== 1 || closes[0].op !== 'resolved') refuse('not-resolved', closes.length ? closes.map((r) => r.op).join(',') : 'open'); const res = closes[0]; @@ -112,6 +116,13 @@ export function readLanding(q, business, id, { into, arbiter = false }) { // A cross-role decision routed to an agent closes only by decision.resolve, which takes it from // the routed role alone (s.role === route_to), so res.by here is the arbiter's run. const arbitrated = arbiter && d.class === 'cross-role' && d.route_to !== 'human' && res.via === 'broker' && !answers.length; + if(arbitrated && !business.roles?.[d.route_to]?.authority?.withinRole?.includes('decision.resolve.technical'))refuse('not-resolver','The technical resolver lost decision.resolve.technical; resolve afresh under current policy.'); + if(arbitrated) { + if(d.route_to===d.raised_by_role || resolverSubject(business,d.route_to,raise.target))refuse('not-resolver','A person decides a resolver subject.'); + const confirmed=autonomyEvent(q,b,id,['autonomy.confirmed']); + if((decisionAutonomy('merge',business.autonomy)==='in the loop' || raise.autonomyLevel==='in the loop') && (!confirmed || confirmed.body.resolution.at!==res.at || confirmed.body.resolution.by!==res.by))refuse('confirmation-required','A person must confirm the arbiter resolution.'); + if(autonomyEvent(q,b,id,['autonomy.reverse.consume']))refuse('decision-reversed','This decision was reversed.'); + } if (!human && !arbitrated) refuse('not-human', `resolved by ${res.by} via ${res.via}, ${answers.length} human answer(s)`); const options = json(d.options) ?? []; diff --git a/packages/bus/src/model-keys.mjs b/packages/bus/src/model-keys.mjs index f141e964b..159ada321 100644 --- a/packages/bus/src/model-keys.mjs +++ b/packages/bus/src/model-keys.mjs @@ -244,7 +244,7 @@ export class ModelKeys { this.#receipt({ action: 'proxy.key.revoked', keyIds: [record.keyId, ...record.retiring.map(r => r.keyId)], remoteState: 'revoked', localState: 'kept' }); } catch (error) { if (error.code === 'proxy-uncertain' || (this.#lock && this.#uncertain())) fail('proxy-uncertain'); fail('credential-revoke-failed'); } } - prepareRevoke(instance) { return this.#exclusive(() => this.#prepareRevoke(instance)); } + prepareRevoke(instance, beforeRevoke = () => {}) { return this.#exclusive(() => { beforeRevoke(); return this.#prepareRevoke(instance); }); } completeRevoke(instance) { try { this.#lock?.assertCertain(); } catch { fail('proxy-uncertain'); } try { @@ -359,7 +359,7 @@ export function modelOrganization({ modelKeys, writer }) { pending.set(business, prepare); const existed = modelKeys.hasRecord(instance); try { - if (isRevoke) { await modelKeys.prepareRevoke(instance); prepare.remoteRevoked = existed; } + if (isRevoke) { await modelKeys.prepareRevoke(instance, () => context?.beforeWrite?.()); prepare.remoteRevoked = existed; } return invoke(business, verb, args, actor, context); } catch (error) { if (isRevoke && prepare.remoteRevoked && (error.code === 'revision-conflict' || error.code === 'revision-conflict-revoked')) modelKeys.revisionConflict(instance); diff --git a/packages/bus/src/refusals.mjs b/packages/bus/src/refusals.mjs index 4115ccb3e..633527c0e 100644 --- a/packages/bus/src/refusals.mjs +++ b/packages/bus/src/refusals.mjs @@ -9,6 +9,10 @@ const group = (codes, rail, nextStep) => { entries.push([code, guidance]); } }; +group("policy-reverse-unavailable", "R3/R12: policy inverse unavailable", "Choose policy in the loop or out of the loop. Near the loop waits for a policy effect executor and a tested reverse."); +group("invalid-autonomy autonomy-precondition reversal-unavailable", "R3/R12: validated owner autonomy and reversible effects", "Use only the four decision classes and three levels. Keep work in the loop until the class has a distinct resolver, tested inverse, notices, review and spend prerequisites; an unsupported effect needs an ordinary new decision."); +group("confirmation-required", "R5/R12: person confirms the arbiter resolution", "The arbiter remains the resolver. Confirm its pending resolution in the owner Console, or return it with a reason for a fresh resolution."); +group("decision-reversed reverse-window-closed", "R2/R12: one-use reverse within the owner's window", "Inspect the reversal trail. After the window, raise an ordinary new decision through the normal approval path; never replay the old approval."); group("definition-shared hire-resolution-required hire-owner-required decision-expired right-required", "R5/R8/R9: digest-bound hire approval by a permitted person", "Use the hire Console path with org.hire. Flagged, credential and root decisions need their owner; expired decisions need a fresh proposal."); group("root-fire-shared", "R3/R8: root fire must revoke every copy's grants", "Other organizations may retain running copies and grants. Ask the owner to coordinate revocation across those organizations before removing the root agent."); group("copy-stale", "R3: root policy changed since this bus booted", "Ask the owner to restart the organization bus to load the approved root policy, then launch a fresh copy."); diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index c558903b1..93b8eac7f 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -1,3 +1,4 @@ +import { autonomyHost } from './autonomy-host.mjs'; import { hireDecisions } from '../../organization/src/hire-decisions.mjs'; import { organizationPlugins } from '../../plugins/src/organization.mjs'; import { pluginService } from '../../plugins/src/tools.mjs'; @@ -890,17 +891,27 @@ 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, 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}); - return {...service,apply:async(id,decision,caller)=>{ + return {...service,reverse:async(id,decision,caller)=>{ + const proposal=service.read(decision); + if(!modelKeys || (proposal.level==='org' && proposal.op==='hire'))return service.reverse(id,decision,caller); + const instance=`${id}/${proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name}`; + let prepared=false; + return service.reverse(id,decision,{...caller, + beforeWrite:()=>{caller.beforeWrite();if(prepared){modelKeys.completeRevoke(instance);prepared=false;}caller.beforeWrite();}, + stop:async(p,guard)=>{await modelKeys.prepareRevoke(instance,guard);prepared=true;return caller.stop(p);}, + }); + },apply:async(id,decision,caller)=>{ const proposal=service.read(decision); const publish=async()=>{ let approved=caller; if(modelKeys && proposal.op==='fire' && proposal.level!=='org') { const instance=`${id}/${proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name}`; - await modelKeys.prepareRevoke(instance); - approved={...caller,beforeWrite:()=>modelKeys.completeRevoke(instance)}; + await modelKeys.prepareRevoke(instance,()=>caller.beforeWrite?.()); + approved={...caller,beforeWrite:()=>{caller.beforeWrite?.();modelKeys.completeRevoke(instance);caller.beforeWrite?.();}}; } const result=await service.apply(id,decision,approved);reload(id);return result; }; diff --git a/packages/bus/tests/autonomy-host.test.mjs b/packages/bus/tests/autonomy-host.test.mjs new file mode 100644 index 000000000..16b5a1ac4 --- /dev/null +++ b/packages/bus/tests/autonomy-host.test.mjs @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync,mkdirSync,writeFileSync,rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {execFileSync} from 'node:child_process'; +import {Store} from '../src/store.mjs'; +import {Broker} from '../src/broker.mjs'; +import {autonomyHost} from '../src/autonomy-host.mjs'; +import {initializeWorking,workingRepo,push,prepareLand,finishLand,dropLand} from '../../seat/src/working.mjs'; +import {gitEnv} from '../../seat/src/checkout.mjs'; +const TASK='vikunja:1/2',SUBJECT={provider:'test',iss:'https://issuer.test',sub:'owner'}; +const git=(cwd,...args)=>execFileSync('git',['-c','user.name=Test','-c','user.email=test@example.invalid',...args],{cwd,encoding:'utf8',env:gitEnv()}).trim(); +async function world(t,level='near the loop') { + const dir=mkdtempSync(join(tmpdir(),'au-')),root=join(dir,'root'),dataRoot=join(dir,'data');mkdirSync(root);mkdirSync(dataRoot,{mode:0o700}); + git(root,'init','-q','-b','main');writeFileSync(join(root,'file'),'before\n');git(root,'add','file');git(root,'commit','-qm','Initial'); + const before=git(root,'rev-parse','HEAD'); + const clone=join(dir,'clone');git(dir,'clone','-q',root,clone);git(clone,'checkout','-qb','feat');writeFileSync(join(clone,'file'),'after\n');git(clone,'commit','-qam','Change');const reviewed=git(clone,'rev-parse','HEAD'); + const bundle=join(dir,'bundle');git(clone,'bundle','create',bundle,'refs/heads/feat','--not','--remotes=origin'); + await initializeWorking({dataRoot,business:'demo',project:'stack',root}); + await push({dataRoot,business:'demo',project:'stack',root,taskRef:TASK,branch:'feat',bundle}); + const authority=(withinRole=[],crossRole=[])=>({withinRole,crossRole}); + const business={id:'demo',human:'jason',projects:{stack:{root}},autonomy:{merge:level,notices:true,reverseWindowHours:1},arbiters:{technical:'cto',delivery:'cto'},roles:{cto:{definition:'cto',authority:authority(['decision.resolve.technical'])},coder:{definition:'coder',authority:authority(['git.push.working','review.request','message.send'])},reviewer:{definition:'reviewer',authority:authority(['review.verdict','message.send'])},gate:{definition:'merge-gate',authority:authority([],['git.merge.protected'])}}}; + let gates=0; + const host=autonomyHost({dataRoot,business:()=>business,processing:{work:fn=>fn({guard(){},signal:undefined})},gate:{classify:async()=>true,run:async()=>{gates++;return {ok:true};}}}); + const store=new Store(dataRoot),broker=new Broker({store,businesses:{demo:business},autonomy:host}),caps={}; + t.after(()=>{store.close();rmSync(dir,{recursive:true,force:true});}); + const call=(who,verb,args={})=>broker.request(caps[who],{verb,args}); + for(const role of Object.keys(business.roles)){caps[role]=broker.bindLaunch({business:'demo',role,run:role+'-run',harness:'pi'});call(role,'role.claim');} + caps.human=broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}); + caps.owner=broker.bindConsole({business:'demo',human:'jason',subject:SUBJECT}); + broker.checkPush(caps.coder,TASK);broker.recordPush(caps.coder,{task_ref:TASK,project:'stack',branch:'feat',ref:'work/vikunja-1-2',commit:reviewed,previous:null}); + const request=call('coder','review.request',{task_ref:TASK,commit:reviewed,to:'reviewer'}); + call('reviewer','review.verdict',{task_ref:TASK,request:request.request,verdict:'approve',body:'Reviewed'}); + const d=call('gate','decision.raise',{action:'git.merge.protected',project:'stack',target:TASK,question:'Land work/vikunja-1-2?',options:[{key:'yes',text:'Land change'},{key:'no',text:'Hold'}],recommendation:'yes',blocking:false}); + call('cto','decision.resolve',{id:d.id,choice:'yes'}); + const land=async()=>{ + const facts=broker.checkLand(caps.gate,{task_ref:TASK,decision:d.id}); + const logs=join(dir,'land');mkdirSync(logs,{recursive:true}); + const prepared=await prepareLand({dataRoot,business:'demo',project:'stack',root,taskRef:TASK,commit:reviewed,dir:logs,name:'original',rootHits:()=>[],message:()=> 'Change'}); + try {await finishLand({dataRoot,business:'demo',project:'stack',land:prepared,record:r=>broker.recordLand(facts.ticket,{commit:r.commit,previous:r.previous,reviewed,base:prepared.base,patch_id:prepared.patch_id})});}finally{await dropLand({dataRoot,business:'demo',project:'stack',worktree:prepared.worktree});} + return prepared.commit; + }; + return {broker,business,store,caps,call,d,before,land,gates:()=>gates,show:(...args)=>git(dir,`--git-dir=${workingRepo(dataRoot,'demo','stack')}`,...args)}; +} +test('AU near merge lands immediately, owner notice reverses through gate and records a real child revert',async t=>{ + const f=await world(t),landed=await f.land(); + assert.equal(f.show('show','main:file'),'after');assert.equal(f.call('human','inbox').find(x=>x.id===f.d.id).autonomy.notice,true); + const reverse=await f.call('owner','decision.reverse.console',{id:f.d.id}); + assert.equal(f.gates(),1);assert.equal(f.show('show','main:file'),'before');assert.equal(f.show('rev-parse','main^'),landed); + assert.match(f.show('log','-1','--format=%B','main'),new RegExp(f.d.id)); + assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1); + assert.equal(reverse.reverted,landed); +}); +test('AU in-loop landing refuses until confirmation; decline requires a new arbiter resolution',async t=>{ + const f=await world(t,'in the loop'); + await assert.rejects(f.land(),e=>['not-resolved','confirmation-required'].includes(e.code)); + f.call('owner','decision.confirm.console',{id:f.d.id,confirm:false,reason:'Review again'}); + assert.throws(()=>f.call('owner','decision.confirm.console',{id:f.d.id,confirm:true}),{code:'decision-not-eligible'}); + f.call('cto','decision.resolve',{id:f.d.id,choice:'yes'});f.call('owner','decision.confirm.console',{id:f.d.id,confirm:true}); + await f.land();assert.equal(f.show('show','main:file'),'after'); + assert.throws(()=>f.call('owner','decision.confirm.console',{id:f.d.id,confirm:true}),{code:'decision-not-eligible'}); +}); +test('AU landing rechecks the resolver right both before land and after the gate',async t=>{ + const f=await world(t);const facts=f.broker.checkLand(f.caps.gate,{task_ref:TASK,decision:f.d.id}); + f.business.roles.cto.authority.withinRole=[];f.broker.reloadBusiness('demo',f.business); + assert.throws(()=>f.broker.checkLand(f.caps.gate,{task_ref:TASK,decision:f.d.id}),{code:'not-resolver'}); + assert.throws(()=>f.broker.recordLand(facts.ticket,{commit:'a'.repeat(40),previous:f.before,reviewed:'b'.repeat(40),base:f.before,patch_id:'c'.repeat(40)}),{code:'not-resolver'}); + assert.equal(f.show('rev-parse','main'),f.before); +}); diff --git a/packages/bus/tests/autonomy.test.mjs b/packages/bus/tests/autonomy.test.mjs new file mode 100644 index 000000000..7c568c953 --- /dev/null +++ b/packages/bus/tests/autonomy.test.mjs @@ -0,0 +1,111 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { fixture } from './spec-fixtures.mjs'; +const SUBJECT = {provider:'test',iss:'https://issuer.test',sub:'owner'}; +async function setup(t, autonomy, autonomyHost=null) { + const f=await fixture(t,{autonomyHost,roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:['git.merge.protected','task.scope.change','policy.change','prd.approve']}}}}); + if(autonomy) {f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);} + f.owner=f.broker.bindConsole({business:'demo',human:'jason',subject:SUBJECT}); + return f; +} +test('AU no setting keeps merge with technical arbiter',async t=>{ + const f=await setup(t);const d=f.raise({action:'git.merge.protected'});assert.equal(d.route_to,'chief'); +}); +test('AU in-loop merge keeps the arbiter resolver, waits for person, decline returns to arbiter',async t=>{ + const f=await setup(t,{merge:'in the loop'}),d=f.raise({action:'git.merge.protected',target:'vikunja:1/2'}); + assert.equal(d.route_to,'chief'); + assert.throws(()=>f.call(f.human,'decision.resolve',{id:d.id,choice:'yes'}),{code:'not-resolver'}); + f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'}); + assert.throws(()=>f.broker.authorize(f.coder,'git.merge.protected',{decision:d.id,target:'vikunja:1/2'}),{code:'confirmation-required'}); + f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:false,reason:'Please revise'}); + assert.equal(f.call(f.chief,'inbox').find(x=>x.id===d.id)?.id,d.id); + f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'}); + f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:true}); + assert.equal(f.broker.authorize(f.coder,'git.merge.protected',{decision:d.id,target:'vikunja:1/2'}).decision,d.id); +}); +test('AU R5 subject resolver or resolver definition routes to person',async t=>{ + const f=await setup(t); + for(const target of ['chief','chief-of-staff'])assert.equal(f.raise({action:'policy.change',target}).route_to,'human'); +}); +test('AU mismatched PRD domain refuses at raise with expected domain',async t=>{ + const f=await setup(t); + assert.throws(()=>f.raise({action:'prd.approve',domain:'delivery'}),e=>e.code==='invalid-domain' && /technical/.test(e.reason)); + assert.equal(f.store.get("SELECT count(*) n FROM decisions WHERE action='prd.approve'").n,0); +}); +test('AU revoked resolver right refuses when an approved decision is consumed',async t=>{ + const f=await setup(t),d=f.raise({action:'task.scope.change',target:'vikunja:1/2'}); + f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'}); + f.business.roles.chief.authority.withinRole=[];f.broker.reloadBusiness('demo',f.business); + assert.throws(()=>f.broker.authorize(f.coder,'task.scope.change',{decision:d.id,target:'vikunja:1/2'}),{code:'not-resolver'}); +}); +test('AU member, agent and dev tester cannot change settings',async t=>{ + const f=await setup(t); + const member=f.broker.bindHireConsole({business:'demo',actor:'member',subject:{...SUBJECT,sub:'member'},rights:['org.hire']}); + for(const cap of [f.coder,member])assert.throws(()=>f.call(cap,'autonomy.set.console',{revision:'0'.repeat(64),autonomy:{merge:'near the loop'}})); + assert.throws(()=>f.call(f.human,'autonomy.set.console',{revision:'0'.repeat(64),autonomy:{merge:'near the loop'}})); +}); +test('AU near-loop merge sends owner a bounded notice; expired reverse names ordinary path',async t=>{ + const f=await setup(t,{merge:'near the loop',notices:true,reverseWindowHours:1}),d=f.raise({action:'git.merge.protected'}); + f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'}); + const n=f.call(f.human,'inbox').find(x=>x.id===d.id); + assert.equal(n.autonomy.notice,true);assert.ok(Date.parse(n.autonomy.reverseUntil)>Date.now()); + const now=Date.now; + try{Date.now=()=>now()+3600001;await assert.rejects(Promise.resolve().then(()=>f.call(f.owner,'decision.reverse.console',{id:d.id})),e=>e.code==='reverse-window-closed' && /ordinary.*decision/.test(e.reason));}finally{Date.now=now;} +}); +test('AU no setting can move each hard wait from a person',async t=>{ + const f=await setup(t,{'cross-role':'out of the loop',merge:'near the loop',authority:'out of the loop'}); + for(const action of ['git.push.protected','host.sudo','credential.mint','spend','deploy','data.delete']){ + if(action==='host.sudo')continue; // Its host grant precondition is covered in hard-waits.test.mjs. + assert.equal(f.raise({action}).route_to,'human'); + } +}); +test('AU owner setting changes have immutable actor trail; bad classes/levels refuse; tester refused',async t=>{ + let f,autonomy={},revision='a'.repeat(64); + const host={get:()=>({autonomy,revision}),set:(_b,a)=>{autonomy=a.autonomy;revision='b'.repeat(64);f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);return {revision,transaction:'transaction-1'};},reversible:()=>true}; + f=await setup(t,null,host); + await f.call(f.owner,'autonomy.set.console',{revision,autonomy:{merge:'in the loop'}}); + assert.equal(f.store.all("SELECT actor_role,actor_run,body FROM events WHERE json_extract(body,'$.operation')='autonomy.set'").length,1); + const row=f.store.get("SELECT body FROM events WHERE json_extract(body,'$.operation')='autonomy.set'");assert.deepEqual(JSON.parse(row.body).subject,SUBJECT); + for(const value of [{unknown:'near the loop'},{merge:'fast'}])await assert.rejects(f.call(f.owner,'autonomy.set.console',{revision,autonomy:value}),{code:'invalid-autonomy'}); + f.business.vars={'bus.tester':true};f.broker.reloadBusiness('demo',f.business);const tester=f.broker.bindTester({business:'demo'}); + assert.throws(()=>f.call(tester,'autonomy.set.console',{revision,autonomy:{}}),{code:'human-required'}); + assert.throws(()=>f.broker.recordEvent(f.coder,{kind:'action.allowed',body:{operation:'autonomy.confirmed',decision:'forged'}}),{code:'reserved-event'}); +}); +test('AU reverse consumes once and rechecks the window at the effect boundary',async t=>{ + let effects=0; + const host={reverse:async({beforeWrite,record})=>{beforeWrite();effects++;return {commit:'b'.repeat(40)};}}; + const f=await setup(t,{merge:'near the loop',notices:true},host),d=f.raise({action:'git.merge.protected'}); + f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'}); + const r=await f.call(f.owner,'decision.reverse.console',{id:d.id});assert.equal(r.reversed,true);assert.equal(effects,1); + await assert.rejects(f.call(f.owner,'decision.reverse.console',{id:d.id}),{code:'decision-reversed'}); + assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1); +}); +test('AU a near-loop reverse that crosses the deadline cannot write',async t=>{ + let effects=0;const now=Date.now; + const f=await setup(t,{merge:'near the loop',notices:true,reverseWindowHours:1},{reverse:async({beforeWrite})=>{Date.now=()=>now()+3600001;beforeWrite();effects++;return {};}}); + const d=f.raise({action:'git.merge.protected'});f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'}); + try{await assert.rejects(f.call(f.owner,'decision.reverse.console',{id:d.id}),{code:'reverse-window-closed'});}finally{Date.now=now;} + assert.equal(effects,0); +}); + +test('AU policy near refuses with a named next step; in and the existing out level remain usable',async t=>{ + let f,autonomy={},revision='a'.repeat(64); + const host={get:()=>({autonomy,revision}),set:(_b,a)=>{autonomy=a.autonomy;revision='b'.repeat(64);f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);return {revision,transaction:'policy-setting'};},reversible:()=>false}; + f=await setup(t,null,host); + await assert.rejects(f.call(f.owner,'autonomy.set.console',{revision,autonomy:{policy:'near the loop'}}),e=>e.code==='policy-reverse-unavailable' && /in the loop or out of the loop/.test(e.nextStep)); + assert.deepEqual(autonomy,{}); + await f.call(f.owner,'autonomy.set.console',{revision,autonomy:{policy:'in the loop'}}); + const d=f.raise({action:'policy.change',target:'policy-1'});f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'}); + assert.throws(()=>f.broker.authorize(f.coder,'policy.change',{decision:d.id,target:'policy-1'}),{code:'confirmation-required'}); + f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:true}); + assert.equal(f.broker.authorize(f.coder,'policy.change',{decision:d.id,target:'policy-1'}).decision,d.id); + await f.call(f.owner,'autonomy.set.console',{revision,autonomy:{policy:'out of the loop'}}); + const out=f.raise({action:'policy.change',target:'policy-2'});f.call(f.chief,'decision.resolve',{id:out.id,choice:'yes'}); + assert.equal(f.broker.authorize(f.coder,'policy.change',{decision:out.id,target:'policy-2'}).decision,out.id); +}); +test('AU near cross-role authorization without a trusted inverse fails closed before consumption',async t=>{ + const f=await setup(t,{'cross-role':'near the loop',notices:true}),d=f.raise({action:'task.scope.change',target:'vikunja:1/2'}); + f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'}); + assert.throws(()=>f.broker.authorize(f.coder,'task.scope.change',{decision:d.id,target:'vikunja:1/2'}),{code:'reversal-unavailable'}); + assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.decision')=? AND json_extract(body,'$.action')='task.scope.change' AND json_extract(body,'$.operation') IS NOT 'decision.raise'",d.id).n,0); +}); diff --git a/packages/bus/tests/console-decide.test.mjs b/packages/bus/tests/console-decide.test.mjs index 48514f799..ded008212 100644 --- a/packages/bus/tests/console-decide.test.mjs +++ b/packages/bus/tests/console-decide.test.mjs @@ -89,7 +89,7 @@ function setup(t) { // #797 / #1839 adds the owner-only artifact read to the existing decision and request controls; // no unsuffixed verb is ever a console verb. test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => { - assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', + assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', 'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']); assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console'))); assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false); @@ -227,7 +227,8 @@ test('ordinary PRD approval and nine non-yes/no choices use the same protocol wi test('ordinary role routes refuse Console resolution and agent answers retain the actual role/run', (t) => { const f = setup(t); for (const route of ['pm', 'chief']) { - const id = grantDecision(f.store, { action: 'policy.change', route, close: null }); + const id = grantDecision(f.store, { action: 'policy.change', route, raiser:route==='chief'?'pm':'chief', close: null }); + refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible'); refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible'); f.call(route, 'decision.resolve', { id, choice: 'yes' }); diff --git a/packages/bus/tests/hard-waits.test.mjs b/packages/bus/tests/hard-waits.test.mjs index e68bd83b5..4e0cf39e2 100644 --- a/packages/bus/tests/hard-waits.test.mjs +++ b/packages/bus/tests/hard-waits.test.mjs @@ -66,7 +66,7 @@ test('REQ-DEC-2: a gated raise citing the current spec goes to the arbiter for i const raise = { action, ...(action === 'role.revoke' ? { target: 'reviewer' } : {}) }; assert.deepEqual(routed(f, f.raise(raise)), { class: 'gated', route_to: 'human' }, action); assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f), domain: 'technical' })), { class: 'gated', route_to: action === 'policy.change' ? 'chief' : 'cto' }, action); - assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: action === 'git.merge.protected' ? 'cto' : 'chief' }, action); + assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: ['git.merge.protected','prd.approve'].includes(action) ? 'cto' : 'chief' }, action); } }); diff --git a/packages/bus/tests/hire-decisions.test.mjs b/packages/bus/tests/hire-decisions.test.mjs index 255a1aac2..d8c8d8b6f 100644 --- a/packages/bus/tests/hire-decisions.test.mjs +++ b/packages/bus/tests/hire-decisions.test.mjs @@ -205,3 +205,34 @@ test('A4 inbox and decision reads expire unopened hires once without changing co } finally {Date.now=original;} assert.deepEqual(readFileSync(f.file),before);assert.equal(f.writes(),0); }); +test('AU a distinct authority resolver applies a plain near-loop hire, while flagged and credential hires stay human',async t=>{ + const f=fixture(t),doc=JSON.parse(readFileSync(f.file,'utf8')); + doc.definitions={resolver:{rev:1,role:{tools:['read'],network:'none',authority:{withinRole:['decision.resolve.technical','decision.resolve.authority'],crossRole:[]},credentials:[]},instructions:'Resolve independent decisions.'}}; + doc.roles.cto={definition:'org:resolver'}; + doc.autonomy={authority:'near the loop',authorityResolver:'cto',notices:true};writeJson(f.file,doc); + const live=loadBusiness('demo',{dir:f.root,rolesDir:f.rolesDir}); + const business={...live,roles:Object.fromEntries(Object.entries(live.roles).map(([n,e])=>[n,{...live.definitions[e.definition],...e}]))}; + business.roles.cto.authority={withinRole:['decision.resolve.authority'],crossRole:[]};f.b.reloadBusiness('demo',business); + const resolver=f.b.bindLaunch({business:'demo',role:'cto',run:'cto-run',harness:'pi'});f.call(resolver,'role.claim'); + const d=await f.raise();assert.equal(d.route_to,'cto'); + await assert.rejects(Promise.resolve().then(()=>f.call(f.caps.chief,'decision.resolve',{id:d.id,choice:'yes'})),{code:'not-resolver'}); + await f.call(resolver,'decision.resolve',{id:d.id,choice:'yes'}); + assert.equal(f.writes(),1);assert.equal(JSON.parse(readFileSync(f.file)).roles.scout.hireDecision,d.id); + for(const change of [{name:'flagged',role:{inline:{...INLINE,tools:['bash']}}},{name:'credential',role:{inline:{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}}}]) { + const decision=await f.raise(change);assert.equal(decision.route_to,'human'); + } +}); + +test('AU R5 a change naming the authority resolver stays with a person; no agent resolves itself',async t=>{ + const f=fixture(t),doc=JSON.parse(readFileSync(f.file,'utf8')); + doc.definitions={judge:{rev:1,role:{tools:['read'],network:'none',authority:{withinRole:['decision.resolve.authority'],crossRole:[]},credentials:[]},instructions:'Resolve independent authority decisions.'}}; + doc.roles.judge={definition:'org:judge',vars:{harness:'pi',model:'claude-opus-5-5'}}; + doc.autonomy={authority:'near the loop',authorityResolver:'judge',notices:true};writeJson(f.file,doc); + const loaded=loadBusiness('demo',{dir:f.root,rolesDir:f.rolesDir}); + f.b.reloadBusiness('demo',{...loaded,roles:Object.fromEntries(Object.entries(loaded.roles).map(([n,e])=>[n,{...loaded.definitions[e.definition],...e}]))}); + const judge=f.b.bindLaunch({business:'demo',role:'judge',run:'judge-run',harness:'pi'});f.call(judge,'role.claim'); + const d=await f.raise({op:'change',name:'judge',role:{definition:'judge',rev:1}}); + assert.ok(d.hire.flagged.some(line=>line.value==='decision.resolve.authority'));assert.equal(d.route_to,'human'); + await assert.rejects(Promise.resolve().then(()=>f.call(judge,'decision.resolve',{id:d.id,choice:'yes'}))); + assert.equal(f.writes(),0); +}); diff --git a/packages/bus/tests/host-fixtures.mjs b/packages/bus/tests/host-fixtures.mjs index 8edd45168..22b24f558 100644 --- a/packages/bus/tests/host-fixtures.mjs +++ b/packages/bus/tests/host-fixtures.mjs @@ -12,15 +12,15 @@ export const HUMAN_YES = { op: 'resolved', by: 'jason', choice: 'yes', via: 'cli // A credential.mint decision with `grant` in its raise body. `close` is the decision_events row, or null // for an open decision. -export function grantDecision(store, { business = 'demo', action = 'credential.mint', route = 'human', domain = 'delivery', grant, close = HUMAN_YES }) { +export function grantDecision(store, { business = 'demo', raiser = 'chief', action = 'credential.mint', route = 'human', domain = 'delivery', grant, close = HUMAN_YES }) { const id = randomUUID(); store.run( 'INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)', - id, AT, business, 'chief', 'chief-run', 'gated', action, route, 'Grant?', OPTIONS, 'yes', 0, + id, AT, business, raiser, raiser+'-run', 'gated', action, route, 'Grant?', OPTIONS, 'yes', 0, ); store.run( "INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.allowed',?,?,?,?)", - randomUUID(), AT, business, 'chief', 'chief-run', id, + randomUUID(), AT, business, raiser, raiser+'-run', id, JSON.stringify({ decision: id, action, operation: 'decision.raise', ...(domain === null ? {} : { domain }), approvalChoice: 'yes', ...(grant ? { grant } : {}) }), ); if (close) diff --git a/packages/bus/tests/model-keys.test.mjs b/packages/bus/tests/model-keys.test.mjs index 5b542688d..c765833cf 100644 --- a/packages/bus/tests/model-keys.test.mjs +++ b/packages/bus/tests/model-keys.test.mjs @@ -346,3 +346,10 @@ for(const level of ['root','project'])test(`A4 ${level} fire removes its implici const human=b.bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}); assert.throws(()=>b.bus.broker.request(human,{verb:'instance.launch',args:{instance}})); }); + +test('AU resolver guard runs inside the model revocation queue before any remote or local effect',async t=>{ + const f=await fixture(t);await f.manager.reconcile();await f.manager.ensure('demo/coder'); + const before=f.credentials.modelRecord('demo/coder'),calls=f.state.calls.length; + await assert.rejects(f.manager.prepareRevoke('demo/coder',()=>{throw Object.assign(new Error('not-resolver'),{code:'not-resolver'});}),{code:'not-resolver'}); + assert.deepEqual(f.credentials.modelRecord('demo/coder'),before);assert.equal(f.state.calls.length,calls); +}); diff --git a/packages/bus/tests/project-runtime.test.mjs b/packages/bus/tests/project-runtime.test.mjs index e9948ab1d..19cb68a10 100644 --- a/packages/bus/tests/project-runtime.test.mjs +++ b/packages/bus/tests/project-runtime.test.mjs @@ -12,6 +12,8 @@ import { fixture } from './spec-fixtures.mjs'; test('Broker keeps the sandbox client import closure: only the listed imports, no lazy project import', () => { const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8'); assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [ + "import { autonomyEvent, decisionResolution } from './autonomy-state.mjs';", + "import { autonomyClass, checkAutonomy, decisionAutonomy, resolverSubject } from '../../organization/src/autonomy.mjs';", // Rows330/331: pure verb/refusal vocabulary, never the host management implementation. "import { ownRails } from '../../organization/src/rails.mjs';", "import { refusalGuidance } from './refusals.mjs';", @@ -45,7 +47,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n ]); assert.doesNotMatch(source, /\bimport\s*\(/); assert.doesNotMatch(readFileSync(new URL('../src/proxy-page-vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/); - assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/); + assert.doesNotMatch(readFileSync(new URL('../src/autonomy-state.mjs', import.meta.url), 'utf8'), /\bimport\b/); + assert.doesNotMatch(readFileSync(new URL('../../organization/src/autonomy.mjs', import.meta.url), 'utf8'), /\bimport\b/); assert.doesNotMatch(readFileSync(new URL('../../organization/src/vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/); assert.doesNotMatch(readFileSync(new URL('../../plugins/src/kinds.mjs', import.meta.url), 'utf8'), /\bimport\b/); }); diff --git a/packages/bus/tests/request-console.test.mjs b/packages/bus/tests/request-console.test.mjs index 7b318d5c6..a0d4928ce 100644 --- a/packages/bus/tests/request-console.test.mjs +++ b/packages/bus/tests/request-console.test.mjs @@ -90,7 +90,7 @@ const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra }); test('the console verb list includes the artifact read and existing owner controls', () => { - assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', + assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', 'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']); }); diff --git a/packages/bus/tests/spec-fixtures.mjs b/packages/bus/tests/spec-fixtures.mjs index 728431e0a..d3111deba 100644 --- a/packages/bus/tests/spec-fixtures.mjs +++ b/packages/bus/tests/spec-fixtures.mjs @@ -7,7 +7,7 @@ import { Broker } from '../src/broker.mjs'; import { initProject, readSpec, writeRecord, listProjectFiles, readProjectFile, writeHumanFile, ProjectError } from '../../project/src/index.mjs'; export const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-C', repo, ...args], { encoding: 'utf8' }).trim(); const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }]; -export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'chief', delivery: 'chief' }, access = null } = {}) { +export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'chief', delivery: 'chief' }, access = null, autonomyHost = null } = {}) { const root = mkdtempSync(join(tmpdir(), 'bus-spec-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); @@ -20,7 +20,7 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech ...roles, } }; const { repo, commit } = await initProject({ business, project: 'stack', dataRoot: root }); - const broker = new Broker({ store, businesses: { demo: business }, access, project: adapter ? { + const broker = new Broker({ store, businesses: { demo: business }, access, autonomy: autonomyHost, project: adapter ? { readSpec: context => readSpec({ ...context, dataRoot: root }), writeRecord: context => writeRecord({ ...context, dataRoot: root }), listFiles: context => listProjectFiles({ ...context, dataRoot: root }), diff --git a/packages/bus/tests/task-store.test.mjs b/packages/bus/tests/task-store.test.mjs index 6cb19c252..0f12b7ba5 100644 --- a/packages/bus/tests/task-store.test.mjs +++ b/packages/bus/tests/task-store.test.mjs @@ -546,3 +546,18 @@ test('review: saturated and adjacent positions refuse without a write', async (t assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:'task:1', group:'adjacent', position:10})), 'task-position-full'); assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, before + 2); }); + +test('AU near cross-role task scope restores prior fields and refuses overwriting a later edit',async t=>{ + const f=await org(t);f.broker.reloadBusiness('acme',business('acme',{autonomy:{'cross-role':'near the loop',notices:true}})); + const owner=f.broker.bindConsole({business:'acme',human:'jason',subject:{provider:'test',iss:'https://issuer.test',sub:'owner'}}); + const made=f.person('tasks.create',{title:'Before',requirement:'REQ-TASK-1'}); + const d=f.approve('coder','task.scope.change',made.task_ref); + await f.call('coder','task.scope.change',{task_ref:made.task_ref,title:'After',decision:d}); + const reversed=await f.broker.request(owner,{verb:'decision.reverse.console',args:{id:d}}); + assert.equal(reversed.reversed,true);assert.equal(f.read()[0].fields.title,'Before'); + const later=f.approve('coder','task.scope.change',made.task_ref); + await f.call('coder','task.scope.change',{task_ref:made.task_ref,title:'Second',decision:later}); + f.person('tasks.edit',{task_ref:made.task_ref,title:'Owner edit'}); + await assert.rejects(f.broker.request(owner,{verb:'decision.reverse.console',args:{id:later}}),{code:'decision-stale'}); + assert.equal(f.read()[0].fields.title,'Owner edit'); +}); diff --git a/packages/business/src/autonomy.mjs b/packages/business/src/autonomy.mjs new file mode 100644 index 000000000..382efad91 --- /dev/null +++ b/packages/business/src/autonomy.mjs @@ -0,0 +1,2 @@ +// Compatibility path for organization autonomy policy. +export * from "../../organization/src/autonomy.mjs"; diff --git a/packages/cli/tests/case2-install.test.mjs b/packages/cli/tests/case2-install.test.mjs index 030352908..aba41a1dd 100644 --- a/packages/cli/tests/case2-install.test.mjs +++ b/packages/cli/tests/case2-install.test.mjs @@ -71,7 +71,7 @@ function world(t, { profile = "library" } = {}) { script(join(bin, "stack-copy"), `echo "stack-copy $*" >>'${log}' [ "$1" = copy ] && [ "$3" = --copies ] || exit 4 mkdir -p "$4/$2/scripts" && : >"$4/$2/scripts/mosaic" && echo "copied: $4/$2" -for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/organization/src/rails.mjs packages/bus/src/refusals.mjs packages/business/src/vocabulary.mjs packages/business/src/selector.mjs packages/organization/src/vocabulary.mjs packages/organization/src/selector.mjs packages/plugins/model-provider/cliproxy/index.mjs; do +for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/organization/src/rails.mjs packages/organization/src/autonomy.mjs packages/bus/src/refusals.mjs packages/business/src/vocabulary.mjs packages/business/src/selector.mjs packages/organization/src/vocabulary.mjs packages/organization/src/selector.mjs packages/plugins/model-provider/cliproxy/index.mjs; do mkdir -p "$4/$2/$(dirname "$file")" && cp '${REPO}'"$file" "$4/$2/$file" || exit 1 done mkdir -p "$4/$2/node_modules" && cp -R '${REPO}node_modules/yaml' "$4/$2/node_modules/yaml"`); diff --git a/packages/organization/README.md b/packages/organization/README.md index 8c49cd991..fea2a57ec 100644 --- a/packages/organization/README.md +++ b/packages/organization/README.md @@ -625,3 +625,32 @@ other two-file journals, a crash or IO failure between replacement and receipt append can leave committed state without its receipt; an observed append failure has `committed: true` and must be inspected before retrying. It never rolls back committed authority silently or fabricates a success receipt. + +### Organization autonomy (AU) + +The optional `autonomy` object selects `in the loop`, `near the loop` or +`out of the loop` for `cross-role`, `merge`, `policy` and `authority`. +Without it, cross-role, merge and policy retain their arbiter routing and +plain hire/fire/change retain person approval. Only the owner Console writes +this setting; every change records the authenticated owner and writer receipt. + +In the loop, the arbiter resolves and a person confirms or returns the decision +with a reason. Near the loop, the agent's resolution takes effect and the owner +receives a Console notice. `reverseWindowHours` is an integer from 1 to 168 +(default 24). `notices: true` is required near the loop. `authorityResolver` +names a distinct organization instance holding `decision.resolve.authority` +withinRole; flagged grants, credential lines and root agents always retain +owner approval. No setting changes hard waits or the PRD request-mode switch. + +Moving toward the loop needs no prerequisites. Moving away requires the class's +resolver, inverse, notices and review/limit prerequisites. Moving from near to +out also requires an owner trail review. A task inverse restores its prior +stored fields if no later edit intervened; a merge inverse lands a gated revert +commit. Hire/fire/change inverses reuse the existing lifecycle and writer paths, +never restore credential bindings, and refuse stale targets. External task +adapters without host-owned inverse evidence refuse near-loop consumption. +After the pinned window, use an ordinary new decision. + +Policy has no effect executor. Per the Lead ruling, its in and existing out +levels work, while near refuses `policy-reverse-unavailable` and directs the +owner to choose in or out. Policy near and its reverse wait for that executor. diff --git a/packages/organization/src/autonomy.mjs b/packages/organization/src/autonomy.mjs new file mode 100644 index 000000000..d1f48f717 --- /dev/null +++ b/packages/organization/src/autonomy.mjs @@ -0,0 +1,30 @@ +// AU: policy data only. Runtime prerequisites and caller identity belong to the host. +export const AUTONOMY_CLASSES = Object.freeze(['cross-role','merge','policy','authority']); +export const AUTONOMY_LEVELS = Object.freeze(['in the loop','near the loop','out of the loop']); +export const AUTONOMY_DEFAULTS = Object.freeze({'cross-role':'out of the loop',merge:'out of the loop',policy:'out of the loop',authority:'in the loop'}); +const refuse = () => { const e=new Error('invalid-autonomy');e.code='invalid-autonomy';throw e; }; +export function checkAutonomy(value) { + if(value===undefined)return undefined; + if(!value || typeof value!=='object' || Array.isArray(value) || ![Object.prototype,null].includes(Object.getPrototypeOf(value)))refuse(); + for(const [key,v]of Object.entries(value)) { + if(AUTONOMY_CLASSES.includes(key)) {if(!AUTONOMY_LEVELS.includes(v))refuse();if(key==='policy' && v==='near the loop')throw Object.assign(new Error('policy-reverse-unavailable'),{code:'policy-reverse-unavailable'});} + else if(key==='reverseWindowHours') {if(!Number.isSafeInteger(v)||v<1||v>168)refuse();} + else if(key==='notices') {if(typeof v!=='boolean')refuse();} + else if(key==='authorityResolver') {if(typeof v!=='string'||!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(v))refuse();} + else refuse(); + } + return structuredClone(value); +} +export function decisionAutonomy(name, setting) { + if(!AUTONOMY_CLASSES.includes(name))refuse(); + checkAutonomy(setting); + return setting?.[name]??AUTONOMY_DEFAULTS[name]; +} +export function autonomyClass(action) { + return action==='git.merge.protected'?'merge':action==='policy.change'?'policy':['role.hire','role.fire','role.change'].includes(action)?'authority':'cross-role'; +} +export function resolverSubject(business, resolver, target) { + if(target==null)return false; + const definition=business.roles?.[resolver]?.definition; + return target===resolver || target===definition || target===definition?.replace(/^org:/,'') || (definition!==undefined && business.roles?.[target]?.definition===definition); +} diff --git a/packages/organization/src/hire-decisions.mjs b/packages/organization/src/hire-decisions.mjs index 138618b8d..1ad79c2a9 100644 --- a/packages/organization/src/hire-decisions.mjs +++ b/packages/organization/src/hire-decisions.mjs @@ -21,6 +21,11 @@ const keys = (x, allowed, required = []) => { if (!isPlainObject(x) || Object.ke const id = x => { if (typeof x !== 'string' || !ID_PATTERN.test(x) || ['human','tester','user'].includes(x)) fail('invalid-request'); }; const requestKeys = ['op','level','project','name','role','credentials','personality','route','reason']; export function authorizeHire(proposal, caller) { + if(caller?.agent===true) { + if(typeof caller.authorityDecision!=='string' || typeof caller.resolver!=='string')fail('human-required'); + if(caller.authorityDecision!==proposal.id || !caller.rights?.includes('decision.resolve.authority') || proposal.flagged.length || proposal.credentialLines.length || proposal.level==='root' || caller.resolver===proposal.name)fail('hire-owner-required'); + return; + } if (!caller || caller.agent !== false) fail('human-required'); if (!caller.rights?.includes(VERB_RIGHTS['hire.resolve.console'])) fail('right-required'); if ((proposal.flagged.length || proposal.credentialLines.length) && caller.owner !== true) fail('hire-owner-required'); @@ -149,7 +154,8 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle } if(input.op==='change' && input.level==='root' && Object.entries(root.document.agents).some(([name,e])=>name!==input.name && e.definition===current.definition))fail('definition-shared'); const targetDefinition=input.op==='change' && current?.definition?.startsWith('org:') ? current.definition.slice(4) : `hire-${decision}`; - const p={id:decision,business,revision,rootRevision:root?.revision??null,request:structuredClone(input),entry,definition:targetDefinition,flagged,credentialLines:lines,expiresAt}; + const prior={instance:current??null,definition:current?.definition?.startsWith('org:')?(input.level==='root'?root.document.definitions:doc.definitions)?.[current.definition.slice(4)]??null:null,launched:doc.launch?.instances?.includes(input.name)??false}; + const p={id:decision,business,prior,revision,rootRevision:root?.revision??null,request:structuredClone(input),entry,definition:targetDefinition,flagged,credentialLines:lines,expiresAt}; mkdirSync(home,{recursive:true,mode:0o700}); const st=lstatSync(home); if(!st.isDirectory() || st.isSymbolicLink() || st.uid!==process.getuid() || st.mode&0o077)fail('organization-refused'); const fd=openSync(location(decision),'wx',0o600); @@ -238,8 +244,31 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle return publish(); } if(r.level==='project') return writer.setProjectAgent(business,{revision:p.revision,project:r.project,name:r.name,entry},caller.actor, - {approvedDefinition:p.entry?{name:p.definition,entry:approvedEntry}:null,decision:p.id,assertClean,beforeWrite:caller.beforeWrite}); - return writer.applyHireDecision(business,p,{actor:caller.actor,assertClean}); + {approvedDefinition:p.entry?{name:p.definition,entry:approvedEntry}:null,decision:p.id,assertClean,beforeWrite:caller.beforeWrite,authorityDecision:caller.authorityDecision}); + return writer.applyHireDecision(business,p,{actor:caller.actor,assertClean,beforeWrite:caller.beforeWrite,authorityDecision:caller.authorityDecision}); } - return {prepare,templates:()=>({templates:templateHashes(rolesDir).map(({template,sha256})=>({name:template,sha256}))}),preview:async(business,decision,caller)=>{const p=await adjusted(business,decision,caller.adjust);if(Date.parse(p.expiresAt)<=(caller.now??Date.now)())fail('decision-expired');authorizeHire(publicOf(p),caller);return publicOf(p);},rootRefusal,rootActive:name=>rootRefusal(name)===null,check:async(...args)=>{await check(...args);},apply,read: decision => publicOf(read(decision))}; + async function reverse(business,decision,caller) { + const p=read(decision);if(p.business!==business || !p.prior || p.request.level==='root')fail('decision-stale'); + const r=p.request,old=writer.read(business),current=r.level==='org'?old.document.roles[r.name]:old.document.projects[r.project]?.agents?.[r.name]; + // A later approved change must not be overwritten by an earlier inverse. + if(r.op==='fire') { + if(r.level==='project'?current!==undefined:old.document.launch.instances.includes(r.name))fail('decision-stale'); + if(r.level==='org') { + const strip=x=>{const {credentials,tracker,...rest}=x??{};return rest;}; + if(canonicalJson(strip(current))!==canonicalJson(strip(p.prior.instance)))fail('decision-stale'); + } + if(p.prior.definition && canonicalJson(old.document.definitions?.[p.prior.instance.definition.slice(4)])!==canonicalJson(p.prior.definition))fail('decision-stale'); + } else if(current?.hireDecision!==decision)fail('decision-stale'); + const guard=()=>{caller.beforeWrite();if(p.prior.definition)scanEntry(p.prior.instance.definition.slice(4),p.prior.definition);if(writer.read(business).revision!==old.revision)fail('decision-stale');}; + guard(); + if(r.op==='hire') { + if(r.level==='org')return caller.fire({...publicOf(p),revision:old.revision}); + await caller.stop(publicOf(p),guard);guard(); + return writer.setProjectAgent(business,{revision:old.revision,project:r.project,name:r.name,entry:null},caller.actor,{beforeWrite:guard}); + } + const stopped=await caller.stop(publicOf(p),guard);guard(); + const result=writer.reverseHireDecision(business,p,{...caller,beforeWrite:guard}); + return {...result,...(r.op==='change' && stopped?.running?{relaunch:publicOf(p)}:{})}; + } + return {prepare,reverse,templates:()=>({templates:templateHashes(rolesDir).map(({template,sha256})=>({name:template,sha256}))}),preview:async(business,decision,caller)=>{const p=await adjusted(business,decision,caller.adjust);if(Date.parse(p.expiresAt)<=(caller.now??Date.now)())fail('decision-expired');authorizeHire(publicOf(p),caller);return publicOf(p);},rootRefusal,rootActive:name=>rootRefusal(name)===null,check:async(...args)=>{await check(...args);},apply,read: decision => publicOf(read(decision))}; } diff --git a/packages/organization/src/instance-settings.mjs b/packages/organization/src/instance-settings.mjs index 3d7da542b..f549c92a6 100644 --- a/packages/organization/src/instance-settings.mjs +++ b/packages/organization/src/instance-settings.mjs @@ -355,13 +355,14 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r // webby-0094 N1: an instance already delisted (a hand edit, or a repeat after step B) still has // its tokens revoked, with no write. A revoke is safe to repeat. if (!old.launch.instances.includes(name)) { + context?.beforeWrite?.(); const revoked = revoke ? revoke(business, name, "fire", services) : null; return { ...read(business, actor), transaction: null, delisted: false, ...(revoked ? { credentials: revoked } : {}) }; } const next = structuredClone(old); next.launch.instances = old.launch.instances.filter(entry => entry !== name); let revoked = null; - const beforeWrite = revoke ? () => { revoked = revoke(business, name, "fire", services); } : null; + const beforeWrite = () => { context?.beforeWrite?.(); if(revoke)revoked = revoke(business, name, "fire", services); context?.beforeWrite?.(); }; const result = commit(business, old, next, { revision, actor, context, beforeWrite }); return { ...result, delisted: true, ...(revoked ? { credentials: revoked } : {}) }; } diff --git a/packages/organization/src/org-rights.mjs b/packages/organization/src/org-rights.mjs index a50837dde..40b777260 100644 --- a/packages/organization/src/org-rights.mjs +++ b/packages/organization/src/org-rights.mjs @@ -32,6 +32,11 @@ export const ROLE_RIGHTS = record({ // M5 must separately exclude credential.mint from decide and assignees from // member task writes; these names alone cannot express action/field restrictions. export const VERB_RIGHTS = record({ + 'autonomy.get.console': null, + 'autonomy.set.console': null, + 'autonomy.review.console': null, + 'decision.confirm.console': null, + 'decision.reverse.console': null, 'project.list': 'org.view', names: 'org.view', 'thread.list': 'thread.read', diff --git a/packages/organization/src/organization-config.mjs b/packages/organization/src/organization-config.mjs index 53a8edf14..94511f335 100644 --- a/packages/organization/src/organization-config.mjs +++ b/packages/organization/src/organization-config.mjs @@ -1,3 +1,4 @@ +import { checkAutonomy } from './autonomy.mjs'; // The organization configuration file, still the v1 business file at // /businesses/.json (REQ-ROLE-3, note section 2, addendum A // sections 7 and 8; rename design r2 stage B: names only, same bytes). Human edits go through the trusted @@ -27,7 +28,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/; // How long a thread's session stays up with no turn before the launcher stops // it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121). export const THREAD_IDLE_SECONDS = 900; -const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance", "definitions"]; +const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance", "definitions", "autonomy"]; // The config directory: next to the system config, $MOSAIC_CONFIG or // ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses. @@ -357,12 +358,20 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo if (!hasModule("system-type", id) && installedNames !== null && !installedNames.has(id)) refuse(`${file} plugins.system-type names unknown module ${id}`); } const usage = document.usage === undefined ? undefined : checkUsageSettings(document.usage); + const autonomy = checkAutonomy(document.autonomy); + if(autonomy?.authorityResolver) { + const entry=roles[autonomy.authorityResolver],right='decision.resolve.authority'; + if(!entry || !definitions[entry.definition].authority.withinRole.includes(right) || + [vars,entry.vars].some(layer=>layer?.['limits.authority']!==undefined && !layer['limits.authority'].includes(right))) { + const e=new Error('invalid-autonomy');e.code='invalid-autonomy';throw e; + } + } const appearance = checkAppearance(document.appearance, `${file} appearance`); const retired = document.retired === undefined ? undefined : checkRetired(document.retired, roles, file); return deepFreeze({ businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, plugins, definitions, - ...(retired ? { retired } : {}), ...(usage === undefined ? {} : { usage }), ...(appearance === undefined ? {} : { appearance }), + ...(autonomy === undefined ? {} : { autonomy }), ...(retired ? { retired } : {}), ...(usage === undefined ? {} : { usage }), ...(appearance === undefined ? {} : { appearance }), }); } diff --git a/packages/organization/src/organization.mjs b/packages/organization/src/organization.mjs index 9a416be02..d2501d316 100644 --- a/packages/organization/src/organization.mjs +++ b/packages/organization/src/organization.mjs @@ -107,6 +107,12 @@ function usageFence(old, document, actor) { fail("organization-usage-human-only"); } +function autonomyFence(old, document, actor) { + if(canonicalJson(old.autonomy)===canonicalJson(document.autonomy))return; + const agents=Object.entries(old.roles??{}).flatMap(([n,e])=>[n,`${e.definition}.${n}`]); + if(actor!==old.human || actor==='tester' || agents.includes(actor))fail('human-required'); +} + function systemTypesFence(old, document, actor) { if (canonicalJson(old.plugins?.['system-type'] ?? []) === canonicalJson(document?.plugins?.['system-type'] ?? [])) return; const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]); @@ -298,6 +304,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = ( modelQuotaFence(old, document, actor); usageFence(old, document, actor); systemTypesFence(old, document, actor); + autonomyFence(old, document, actor); validateBusinessDocument(document, file, { rolesDir, dataRoot }); const bytes = JSON.stringify(document, null, 2) + "\n"; // Check and log exactly what lands on disk, for every setter: a value JSON @@ -308,6 +315,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = ( modelQuotaFence(old, written, actor); usageFence(old, written, actor); systemTypesFence(old, written, actor); + autonomyFence(old, written, actor); validateBusinessDocument(written, file, { rolesDir, dataRoot }); if (beforeWrite) { beforeWrite(); @@ -456,7 +464,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = ( const root = rootForProjectCheck(dir, rolesDir); const guard = (old, document) => { const agents = Object.entries(old.roles ?? {}).flatMap(([n, role]) => [n, `${role?.definition}.${n}`]); - if ((!approval && actor !== old.human) || actor === "tester" || agents.includes(actor)) fail("human-required"); + if ((!approval && actor !== old.human) || actor === "tester" || (approval?.authorityDecision!==approval?.decision && agents.includes(actor))) fail("human-required"); if (entry === null && old.projects?.[project]?.agents?.[name] === undefined) fail("agent-not-found", `projects.${project}.agents.${name} does not exist`); const checked = structuredClone(document); if (approval?.approvedDefinition) { @@ -481,11 +489,11 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = ( // A4 trusted decision executor: one locked publication of the role, route and definition. // The broker has consumed the matching digest before entering this host-only writer. - function applyHireDecision(id, proposal, { actor, assertClean, beforeWrite = null }) { + function applyHireDecision(id, proposal, { actor, assertClean, beforeWrite = null, authorityDecision = null }) { const { request: r, definition, entry, revision } = proposal; if (!proposal.id || r.level !== 'org') fail('invalid-request'); const build = old => { - if (actor === 'tester' || Object.entries(old.roles).some(([n,e]) => actor === n || actor === `${e.definition}.${n}`)) fail('human-required'); + if (actor === 'tester' || (authorityDecision!==proposal.id && Object.entries(old.roles).some(([n,e]) => actor === n || actor === `${e.definition}.${n}`))) fail('human-required'); if ((old.launch?.keep ?? []).includes(r.name)) fail('instance-kept'); if ((old.retired ?? []).includes(r.name)) fail('organization-instance-retired'); if (r.name === old.launch?.by || Object.values(old.arbiters).includes(r.name)) fail('organization-instance-referenced'); @@ -517,6 +525,44 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = ( },context:{decision:proposal.id,digest:proposal.digest??null},detail:{verb:'hire.apply',decision:proposal.id,op:r.op,agent:r.name}}); } + // AU inverse, from an immutable private proposal. It touches only the named instance and + // definition; writer validation, fresh revision and owner fence still apply. Bindings never restore. + function reverseHireDecision(id,p,{actor,beforeWrite}) { + const {document:old,revision}=read(id),r=p.request,prior=p.prior; + if(actor!==old.human || !prior || r.level==='root')fail('human-required'); + if(r.op==='hire')fail('invalid-request'); // Hire reverses through the stop/revoke/fire executor. + const build=current=>{ + const live=r.level==='org'?current.roles[r.name]:current.projects[r.project]?.agents?.[r.name]; + if(r.op==='fire') { + if(r.level==='project'?live!==undefined:current.launch.instances.includes(r.name))fail('decision-stale'); + if(r.level==='org') { + const strip=x=>{const {credentials,tracker,...rest}=x??{};return rest;}; + if(canonicalJson(strip(live))!==canonicalJson(strip(prior.instance)))fail('decision-stale'); + } + if(prior.definition && canonicalJson(current.definitions?.[prior.instance.definition.slice(4)])!==canonicalJson(prior.definition))fail('decision-stale'); + } else if(live?.hireDecision!==p.id)fail('decision-stale'); + const next=structuredClone(current); + if(r.level==='project') { + const entries=next.projects[r.project].agents??{}; + if(prior.instance)entries[r.name]=structuredClone(prior.instance);else delete entries[r.name]; + delete entries[r.name]?.credentials;delete entries[r.name]?.tracker; + next.projects[r.project].agents=entries; + } else { + if(!prior.instance)fail('decision-stale'); + next.roles[r.name]=structuredClone(prior.instance); + delete next.roles[r.name].credentials;delete next.roles[r.name].tracker; + next.launch.instances=next.launch.instances.filter(n=>n!==r.name); + if(prior.launched)next.launch.instances.push(r.name); + } + if(prior.definition) { + const name=prior.instance.definition.slice(4); + next.definitions={...(next.definitions??{}),[name]:{...prior.definition,rev:(current.definitions?.[name]?.rev??0)+1}}; + } + return next; + }; + return commit(id,build,{revision,actor,beforeWrite,pathsOnly:true,guard:(current,next)=>{if(actor!==current.human || canonicalJson(build(current))!==canonicalJson(next))fail('human-required');},detail:{verb:'hire.reverse',decision:p.id,agent:r.name}}); + } + // Runs `fn({ revision, document })` under the writer lock, after recovery, and nothing else may // write this business meanwhile. `fn` must be synchronous: the lock is a directory held for its // duration, and an awaited call would leave a half-checked world to whoever finds the lock gone. @@ -584,5 +630,5 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = ( }); } - return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyHireDecision, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) }; + return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyHireDecision, reverseHireDecision, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) }; } diff --git a/packages/organization/src/rails.mjs b/packages/organization/src/rails.mjs index db7cfd409..5d0138ae5 100644 --- a/packages/organization/src/rails.mjs +++ b/packages/organization/src/rails.mjs @@ -1,15 +1,12 @@ // Caller-only descriptions, shared by launch and live bus reads. Inputs are validated policy, // never credential references, vars, call arguments or file contents. +import { decisionAutonomy } from './autonomy.mjs'; +export { decisionAutonomy } from './autonomy.mjs'; import { GATED_ONLY } from './vocabulary.mjs'; const DOMAINS = Object.freeze({ 'git.merge.protected': 'technical', 'policy.change': 'delivery' }); -// TI extension point for AU: today's fixed levels, without changing any routing. -export function decisionAutonomy(decisionClass) { - return ['cross-role', 'merge', 'policy'].includes(decisionClass) ? 'out of the loop' : 'in the loop'; -} - -export function ownRails({ instance, authority, network, credentials = [], messaging = null, arbiters = {}, launcher = null }) { +export function ownRails({ instance, authority, network, credentials = [], messaging = null, arbiters = {}, launcher = null, autonomy }) { const actions = []; for (const [kind, held] of Object.entries(authority ?? {})) { for (const action of held) { @@ -24,8 +21,8 @@ export function ownRails({ instance, authority, network, credentials = [], messa const resolve = [...(authority?.withinRole ?? []), ...(authority?.crossRole ?? [])] .filter(action => action.startsWith('decision.resolve.')).map(right => { const domain = right.slice('decision.resolve.'.length); - const classes = domain === 'technical' ? ['cross-role', 'merge'] : domain === 'delivery' ? ['cross-role', 'policy'] : []; - return { right, classes: classes.map(name => ({ name, level: decisionAutonomy(name) })) }; + const classes = domain === 'technical' ? ['cross-role', 'merge'] : domain === 'delivery' ? ['cross-role', 'policy'] : domain === 'authority' ? ['authority'] : []; + return { right, classes: classes.map(name => ({ name, level: decisionAutonomy(name, autonomy) })) }; }); return { actions, resolve, network: network ?? 'unknown', diff --git a/packages/organization/src/resolve.mjs b/packages/organization/src/resolve.mjs index 99f55aa1f..75c60d582 100644 --- a/packages/organization/src/resolve.mjs +++ b/packages/organization/src/resolve.mjs @@ -101,7 +101,7 @@ export function resolveInstance({ system, business, project = null, instance, en limits: { tools, network, authority: { withinRole, crossRole } }, // Row 210 (#1707, design D5): a host key is used only by the bus child, never by the session, // so it stays out of the session's credentials and the harness's needs. - rails: ownRails({ instance, authority: { withinRole, crossRole }, network, credentials: definition.credentials, messaging: definition.messaging, arbiters: business.arbiters, launcher: business.launch?.by }), + rails: ownRails({ instance, authority: { withinRole, crossRole }, network, credentials: definition.credentials, messaging: definition.messaging, arbiters: business.arbiters, launcher: business.launch?.by, autonomy: business.autonomy }), credentials: Object.fromEntries(Object.entries(entry.credentials).filter(([kind]) => !isHostKind(kind))), tracker: entry.tracker, launch, diff --git a/packages/organization/src/vocabulary.mjs b/packages/organization/src/vocabulary.mjs index 9e01e06d0..993f37d74 100644 --- a/packages/organization/src/vocabulary.mjs +++ b/packages/organization/src/vocabulary.mjs @@ -60,6 +60,7 @@ export const ACTIONS = Object.freeze([ "prd.approve", "decision.resolve.technical", "decision.resolve.delivery", + "decision.resolve.authority", // Row 285 (C2-08b, #1790): a creative agent writes a supporting Markdown file in the human's library. // Added as a hard wait and routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6; role.mjs grants it within-role // to the creative roles only. @@ -96,6 +97,7 @@ export const ROUTED = Object.freeze([ "prd.approve", "decision.resolve.technical", "decision.resolve.delivery", + "decision.resolve.authority", // PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks. "role.hire", "role.fire", diff --git a/packages/organization/tests/autonomy-inverse.test.mjs b/packages/organization/tests/autonomy-inverse.test.mjs new file mode 100644 index 000000000..a06fe0f77 --- /dev/null +++ b/packages/organization/tests/autonomy-inverse.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; +import {rmSync} from 'node:fs'; +import {join} from 'node:path'; +import {tmp,rolesCopy,businessDoc,writeJson} from './helpers.mjs'; +import {organizationWriter} from '../src/organization.mjs'; +import {hireDecisions} from '../src/hire-decisions.mjs'; +import {instanceSettings} from '../src/instance-settings.mjs'; +const role={tools:['read'],network:'none',authority:{withinRole:[],crossRole:[]},credentials:[],instructions:'Prior role'}; +function setup(t, scanDefinition) { + const root=tmp();t.after(()=>rmSync(root,{recursive:true,force:true}));const rolesDir=rolesCopy(root),doc=businessDoc(root); + doc.plugins={harness:['pi']}; + doc.definitions={scout:{rev:3,role:{tools:role.tools,network:role.network,authority:role.authority,credentials:[]},instructions:role.instructions}}; + doc.roles.scout={definition:'org:scout',vars:{harness:'pi',model:'claude-opus-5-5'}};doc.launch.instances.push('scout'); + writeJson(join(root,'businesses/acme.json'),doc); + const writer=organizationWriter({dir:root,rolesDir}),service=hireDecisions({writer,rolesDir,configDir:root,dataRoot:join(root,'data'),assertClean:()=>{},scanDefinition}); + let stopped=0,revoked=0;const instances=instanceSettings({dir:root,rolesDir,writer,revoke:()=>{revoked++;}}); + const prepare=async request=>{const p=await service.prepare('acme',Object.fromEntries(Object.entries({op:'change',level:'org',name:'scout',role:{inline:{...role,instructions:'Changed role'}},route:{harness:'pi',model:'claude-opus-5-5'},reason:'Test',...request}).filter(([,v])=>v!==undefined)),{id:randomUUID(),expiresAt:'2099-01-01T00:00:00Z'});return p;}; + const caller=p=>({agent:false,rights:['org.hire'],owner:true,instanceOwner:true,actor:'jason',digest:p.digest,subject:{provider:'test',iss:'test',sub:'owner'}}); + const reverse=p=>service.reverse('acme',p.id,{actor:'jason',beforeWrite(){},stop:async()=>{stopped++;return {running:true};},fire:proposal=>instances.fire('acme',{name:proposal.name,revision:proposal.revision},{actor:'jason',context:{decision:proposal.id}})}); + return {writer,service,prepare,caller,reverse,stopped:()=>stopped,revoked:()=>revoked}; +} +test('AU role-change inverse restores prior definition contents and requests relaunch of a running agent',async t=>{ + const f=setup(t),p=await f.prepare({});await f.service.apply('acme',p.id,f.caller(p)); + const r=await f.reverse(p),doc=f.writer.read('acme').document; + assert.equal(doc.definitions.scout.instructions,'Prior role');assert.equal(doc.definitions.scout.rev,5);assert.equal(r.relaunch.name,'scout');assert.equal(f.stopped(),1); +}); +test('AU fire inverse rehires prior definition without restoring revoked bindings',async t=>{ + const f=setup(t),p=await f.prepare({op:'fire',role:undefined,route:undefined});await f.service.apply('acme',p.id,f.caller(p)); + assert.ok(!f.writer.read('acme').document.launch.instances.includes('scout')); + await f.reverse(p);const doc=f.writer.read('acme').document; + assert.ok(doc.launch.instances.includes('scout'));assert.equal(doc.definitions.scout.instructions,'Prior role');assert.equal(doc.roles.scout.credentials,undefined); +}); +test('AU hire inverse uses the normal fire/revoke executor',async t=>{ + const f=setup(t),p=await f.prepare({op:'hire',name:'new-scout'});await f.service.apply('acme',p.id,f.caller(p)); + await f.reverse(p);assert.ok(!f.writer.read('acme').document.launch.instances.includes('new-scout'));assert.equal(f.revoked(),1); +}); +test('AU inverse never overwrites a subsequent role change or relaunched fired agent',async t=>{ + const f=setup(t),p=await f.prepare({});await f.service.apply('acme',p.id,f.caller(p)); + const later=await f.prepare({role:{inline:{...role,instructions:'Later'}}});await f.service.apply('acme',later.id,f.caller(later)); + await assert.rejects(f.reverse(p),{code:'decision-stale'});assert.equal(f.stopped(),0); + const fire=await f.prepare({op:'fire',role:undefined,route:undefined});await f.service.apply('acme',fire.id,f.caller(fire)); + const old=f.writer.read('acme');f.writer.write('acme',{...old.document,launch:{...old.document.launch,instances:[...old.document.launch.instances,'scout']}},{revision:old.revision,actor:'jason'}); + await assert.rejects(f.reverse(fire),{code:'decision-stale'}); +}); + +test('AU inverse scans prior definition against current held credentials before stopping or writing',async t=>{ + let refuse=false; + const f=setup(t,()=>{if(refuse)throw Object.assign(new Error('definition-holds-credential'),{code:'definition-holds-credential'});}); + const p=await f.prepare({});await f.service.apply('acme',p.id,f.caller(p)); + const before=f.writer.read('acme').revision;refuse=true; + await assert.rejects(f.reverse(p),{code:'definition-holds-credential'}); + assert.equal(f.stopped(),0);assert.equal(f.writer.read('acme').revision,before); +}); diff --git a/packages/organization/tests/autonomy.test.mjs b/packages/organization/tests/autonomy.test.mjs new file mode 100644 index 000000000..6e2ff6c34 --- /dev/null +++ b/packages/organization/tests/autonomy.test.mjs @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { decisionAutonomy } from '../src/rails.mjs'; +import { VERB_RIGHTS } from '../src/org-rights.mjs'; + +test('AU absent key preserves fixed levels; present per-class levels are read', () => { + for (const name of ['merge', 'policy', 'cross-role']) assert.equal(decisionAutonomy(name), 'out of the loop'); + assert.equal(decisionAutonomy('authority'), 'in the loop'); + assert.equal(decisionAutonomy('merge', { merge: 'near the loop' }), 'near the loop'); +}); +test('AU unknown class or level refuses instead of inventing a default', () => { + assert.throws(() => decisionAutonomy('unknown'), { code: 'invalid-autonomy' }); + assert.throws(() => decisionAutonomy('merge', { merge: 'automatic' }), { code: 'invalid-autonomy' }); +}); +test('AU owner-only Console verbs have explicit null rights', () => { + for (const verb of ['autonomy.get.console','autonomy.set.console','decision.confirm.console','decision.reverse.console']) assert.equal(VERB_RIGHTS[verb], null, verb); +}); +import { tmp, rolesCopy, businessDoc, writeJson } from './helpers.mjs'; +import { rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { validateBusinessDocument } from '../src/organization-config.mjs'; +import { organizationWriter } from '../src/organization.mjs'; +test('AU setting validates in the organization file; every writer refuses non-owner edits',t=>{ + const root=tmp();t.after(()=>rmSync(root,{recursive:true,force:true})); + const rolesDir=rolesCopy(root),doc=businessDoc(root);doc.autonomy={merge:'in the loop'}; + assert.equal(validateBusinessDocument(doc,join(root,'acme.json'),{rolesDir}).autonomy.merge,'in the loop'); + for(const autonomy of [{unknown:'in the loop'},{merge:'fast'},{deploy:'out of the loop'}])assert.throws(()=>validateBusinessDocument({...doc,autonomy},join(root,'acme.json'),{rolesDir}),{code:'invalid-autonomy'}); + writeJson(join(root,'businesses/acme.json'),doc); + const writer=organizationWriter({dir:root,rolesDir}),old=writer.read('acme'); + for(const actor of ['coder','tester','member'])assert.throws(()=>writer.write('acme',{...doc,autonomy:{merge:'out of the loop'}},{revision:old.revision,actor}),{code:'human-required'}); +}); diff --git a/packages/organization/tests/org-rights.test.mjs b/packages/organization/tests/org-rights.test.mjs index 1bf1ffc57..66997716c 100644 --- a/packages/organization/tests/org-rights.test.mjs +++ b/packages/organization/tests/org-rights.test.mjs @@ -25,6 +25,11 @@ const BUNDLES = { approver: ['board.read', 'decide', 'org.view', 'task.write', 'thread.read', 'thread.write'], }; const VERBS = { + 'autonomy.get.console': null, + 'autonomy.set.console': null, + 'autonomy.review.console': null, + 'decision.confirm.console': null, + 'decision.reverse.console': null, 'project.list': 'org.view', names: 'org.view', 'thread.list': 'thread.read', diff --git a/packages/seat/src/sandbox.mjs b/packages/seat/src/sandbox.mjs index f401bc99e..d463d8741 100644 --- a/packages/seat/src/sandbox.mjs +++ b/packages/seat/src/sandbox.mjs @@ -62,6 +62,8 @@ export const RUNNER_FILES = Object.freeze([ "packages/bus/src/broker.mjs", "packages/bus/src/refusals.mjs", "packages/organization/src/rails.mjs", + "packages/organization/src/autonomy.mjs", + "packages/bus/src/autonomy-state.mjs", "packages/bus/src/proxy-page-vocabulary.mjs", // Row 331 r5: parsing only; common.mjs and PIN remain outside the sandbox. "packages/model-proxy/src/validation.mjs", diff --git a/packages/seat/src/working.mjs b/packages/seat/src/working.mjs index 6e959a82a..bad211985 100644 --- a/packages/seat/src/working.mjs +++ b/packages/seat/src/working.mjs @@ -583,3 +583,27 @@ export async function publishRemote({ repo, fork, user, source, dst, token, tls if (!pushed.ok) refuse(/\((non-fast-forward|fetch first)\)/.test(pushed.out) ? "non-fast-forward" : "push-failed"); return { ref: dst, commit: source, previous }; } + +// AU: a reverse is a new child of main, prepared under its ordinary repository lock. +// The caller runs the normal land gate, then finishLand's compare-and-swap and receipt. +export function prepareRevert({dataRoot,business,project,commit,dir,name,decision,env=process.env,limits=GIT_LIMITS}) { + if(!PROJECT.test(project??'') || !OID.test(commit??'') || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(name??'') || !/^[a-zA-Z0-9-]{1,100}$/.test(decision??''))refuse('invalid-request'); + const repo=workingRepo(dataRoot,business,project),g=bound(env,limits,dataRoot,repo),worktree=join(dir,name); + return locked(repo,async()=>{ + const at=[`--git-dir=${repo}`],old=await tip(repo,'refs/heads/main',g); + if(!old || !(await g([...at,'merge-base','--is-ancestor',commit,old])).ok)refuse('ref-moved'); + const opened=await g([...at,'worktree','add','-q','--detach',worktree,old]); + if(!opened.ok)refuse('git-failed'); + try { + const wt=['-C',worktree]; + if(!(await g([...wt,'revert','--no-commit',commit],{pre:LAND_IDENTITY})).ok)refuse('base-conflict'); + const tree=await g([...wt,'write-tree']);if(!tree.ok)refuse('git-failed'); + const file=join(dir,'reverse-message');writeFileSync(file,`Revert ${commit}\n\nAutonomy-Reverse: ${decision}\n`,{mode:0o600}); + const made=await g([...wt,'commit-tree',tree.out.trim(),'-p',old,'-F',file],{pre:LAND_IDENTITY}); + if(!made.ok || !OID.test(made.out.trim()))refuse('git-failed'); + const next=made.out.trim(); + if(!(await g([...wt,'reset','-q','--soft',next])).ok)refuse('git-failed'); + return {worktree,old,commit:next,reverted:commit}; + } catch(error) {await withoutProcessing(()=>removeWorktree(repo,worktree,g));throw error;} + }); +} diff --git a/packages/seat/tests/working.test.mjs b/packages/seat/tests/working.test.mjs index 8d795276b..c83201f41 100644 --- a/packages/seat/tests/working.test.mjs +++ b/packages/seat/tests/working.test.mjs @@ -654,3 +654,19 @@ test('halted push receipt rolls back only its unrecorded ref and cleans temporar } })), /not recorded after halt/); assert.equal(w.refs(), `refs/heads/main ${w.first}\nrefs/heads/root ${w.first}`); }); +test('AU prepareRevert makes a new commit, finishLand preserves history and rechecks the reverse window',async t=>{ + const w=world(t);await initializeWorking({dataRoot:w.dataRoot,business:'acme',project:'stack',root:w.root}); + git(w.clone,'checkout','-q','-b','feat');const changed=commit(w.clone,'two');await w.go(w.bundle()); + const dir=join(w.dir,'land');mkdirSync(dir); + const landed=await prepareLand({dataRoot:w.dataRoot,business:'acme',project:'stack',root:w.root,taskRef:TASK,commit:changed,dir,name:'original',rootHits:()=>[],message:()=> 'Change'}); + await finishLand({dataRoot:w.dataRoot,business:'acme',project:'stack',land:landed});await dropLand({dataRoot:w.dataRoot,business:'acme',project:'stack',worktree:landed.worktree}); + const {prepareRevert}=await import('../src/working.mjs'); + const reverse=await prepareRevert({dataRoot:w.dataRoot,business:'acme',project:'stack',commit:landed.commit,dir,name:'reverse',decision:'decision-1'}); + assert.equal(git(w.dir,`--git-dir=${w.repo}`,'show',`${reverse.commit}:README`),'one'); + assert.equal(git(w.dir,`--git-dir=${w.repo}`,'rev-parse',`${reverse.commit}^`),landed.commit); + await assert.rejects(finishLand({dataRoot:w.dataRoot,business:'acme',project:'stack',land:reverse,record:()=>{throw Object.assign(new Error('closed'),{code:'reverse-window-closed'});}}),{code:'reverse-window-closed'}); + assert.equal(git(w.dir,`--git-dir=${w.repo}`,'rev-parse','main'),landed.commit); + await finishLand({dataRoot:w.dataRoot,business:'acme',project:'stack',land:reverse}); + assert.match(git(w.dir,`--git-dir=${w.repo}`,'log','-1','--format=%B','main'),/decision-1/); + await dropLand({dataRoot:w.dataRoot,business:'acme',project:'stack',worktree:reverse.worktree}); +}); diff --git a/packages/webui/src/autonomy-http.mjs b/packages/webui/src/autonomy-http.mjs new file mode 100644 index 000000000..be5315c00 --- /dev/null +++ b/packages/webui/src/autonomy-http.mjs @@ -0,0 +1,28 @@ +// AU: owner Console transport only. Identity and organization never come from a JSON body. +export const AUTONOMY_ROUTE=/^\/api\/autonomy(?:\/(confirm|reverse|review))?$/; +const one=(req,k)=>req.headersDistinct[k]?.length===1?req.headersDistinct[k][0]:null; +const json=(res,status,value)=>{res.writeHead(status,{'content-type':'application/json','cache-control':'no-store'});res.end(JSON.stringify(value));}; +export async function autonomyHTTP(req,res,{path,read,write,session,live,origin}) { + if(!session)return json(res,401,{error:'sign-in-required'}); + if(!read)return json(res,503,{error:'not-configured'}); + if(!['GET','POST'].includes(req.method))return json(res,405,{error:'method-not-allowed'}); + const step=AUTONOMY_ROUTE.exec(path)?.[1]??null; + if(step && req.method!=='POST')return json(res,405,{error:'method-not-allowed'}); + const post=req.method==='POST'; + if(post && !write)return json(res,403,{error:'read-only'}); + if(post && (one(req,'origin')!==origin || one(req,'sec-fetch-site')!=='same-origin'))return json(res,403,{error:'cross-origin'}); + let args={}; + if(post) { + if(one(req,'content-type')?.split(';')[0].trim()!=='application/json')return json(res,400,{error:'invalid-request'}); + const chunks=[];let size=0;for await(const chunk of req){size+=chunk.length;if(size>4096)return json(res,413,{error:'invalid-request'});chunks.push(chunk);} + try{args=JSON.parse(Buffer.concat(chunks).toString('utf8'));}catch{return json(res,400,{error:'invalid-request'});} + if(!args || typeof args!=='object' || Array.isArray(args))return json(res,400,{error:'invalid-request'}); + const keys=step==='confirm'?['id','confirm','reason']:step==='reverse'?['id']:step==='review'?['decisionClass']:['revision','autonomy']; + if(Object.keys(args).some(k=>!keys.includes(k)))return json(res,400,{error:'invalid-request'}); + } + if(live()!==session)return json(res,401,{error:'sign-in-required'}); + const subject={provider:session.provider,iss:session.iss,sub:session.sub}; + const verb=step==='confirm'?'decision.confirm.console':step==='reverse'?'decision.reverse.console':step==='review'?'autonomy.review.console':post?'autonomy.set.console':'autonomy.get.console'; + try{return json(res,200,{...await(post?write:read)(verb,args,{subject}),writable:!!write});} + catch(e){const allowed=['human-required','invalid-request','invalid-autonomy','policy-reverse-unavailable','autonomy-precondition','revision-conflict','decision-not-eligible','decision-stale','decision-reversed','reverse-window-closed','reversal-unavailable','not-resolver','gate-missing','gate-failed','base-conflict','not-configured'];const code=allowed.includes(e?.code)?e.code:'outcome-unknown';return json(res,code==='human-required'?403:code==='invalid-request'||code==='invalid-autonomy'?400:409,{error:code});} +} diff --git a/packages/webui/src/public/autonomy.css b/packages/webui/src/public/autonomy.css new file mode 100644 index 000000000..df7106533 --- /dev/null +++ b/packages/webui/src/public/autonomy.css @@ -0,0 +1,10 @@ +[data-autonomy], [data-autonomy-decision]{margin-top:16px} +.autonomy-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin:16px 0} +.autonomy-fields label,[data-autonomy-decision] label{display:flex;flex-direction:column;align-items:stretch;gap:6px;min-width:0} +[data-autonomy] :is(select,input:not([type=checkbox])),[data-autonomy-decision] input{width:100%;box-sizing:border-box;min-width:0;min-height:44px;padding:8px 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--canvas);color:var(--text);font:inherit} +[data-autonomy] .autonomy-checkbox{display:flex;align-items:center;gap:10px;min-height:44px;margin:12px 0} +.autonomy-checkbox input{width:18px;height:18px;margin:0;flex:0 0 auto;accent-color:var(--action)} +[data-autonomy] :is(button,select,input,summary):focus-visible,[data-autonomy-decision] :is(button,input):focus-visible{outline:2px solid var(--focus);outline-offset:2px} +[data-autonomy] [role=status],[data-autonomy-decision] [role=status]{overflow-wrap:anywhere} +[data-autonomy] details button{margin:8px 8px 0 0} +@media(max-width:759px){.autonomy-fields{grid-template-columns:1fr}} diff --git a/packages/webui/src/public/autonomy.js b/packages/webui/src/public/autonomy.js new file mode 100644 index 000000000..17f04fd40 --- /dev/null +++ b/packages/webui/src/public/autonomy.js @@ -0,0 +1,60 @@ +// Owner settings and decision notices. Server and broker establish identity and eligibility. +(() => { + 'use strict'; + const esc=x=>String(x??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + const classes=['cross-role','merge','policy','authority'],levels=['in the loop','near the loop','out of the loop']; + const labels={'cross-role':'Cross-role work',merge:'Merge',policy:'Policy',authority:'Plain hire, fire and role change'}; + const states=new Map(),path=(org,step='')=>`${org?`/api/orgs/${encodeURIComponent(org)}`:'/api'}/autonomy${step?`/${step}`:''}`; + const guidance={ + 'policy-reverse-unavailable':'Policy has no effect executor or reverse yet. Choose in the loop or out of the loop.', + 'autonomy-precondition':'This move needs a separate resolver, a tested reverse, owner notices and the class prerequisites. Out of the loop also needs time near the loop and your review of its trail.', + 'reverse-window-closed':'The reverse window has closed. Raise an ordinary new decision.', + 'revision-conflict':'The organization changed. Reload before saving.', + 'not-resolver':'The resolver no longer holds the required right. Withdraw and raise a fresh decision under current policy.', + 'reversal-unavailable':'This effect cannot be reversed through this path. Use an ordinary new decision.', + }; + async function call(org,step,body) { + const r=await fetch(path(org,step),body===undefined?{cache:'no-store'}:{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)}); + const value=await r.json();if(!r.ok)throw Error(guidance[value.error]??`The bus refused: ${value.error??'outcome unknown'}. Refresh before retrying.`);return value; + } + async function load(org) {try{states.set(org,await call(org,''));}catch(error){states.set(org,{error:error.message});}} + function section(org) { + const s=states.get(org); + if(!s || s.error)return `

Autonomy

${esc(s?.error??'Autonomy could not be read.')}

`; + const v=s.autonomy??{}; + return `

Autonomy

Choose how closely you follow each class of decision. Hard waits, flagged grants and credentials always need a person.

+
${classes.map(k=>``).join('')}
+
+ +

In the loop, the arbiter resolves and you confirm before work takes effect. Near the loop, work takes effect and you can reverse it within the window. Out of the loop, the trail records it without a notice.

+ ${s.writable?'':'

Only the owner can change autonomy.

'} +

+ ${s.writable?`
Review reversals before moving out of the loop

Read the decision trail and its reversals before recording your review.

${classes.map(k=>``).join('')}
`:''}
`; + } + function notice(d) { + if(!d?.autonomy)return ''; + const a=d.autonomy,org=d.business??window.MosaicOrg?.id; + return `

${a.pending?'Confirm the arbiter resolution':'Decision near the loop'}

+ ${a.pending?'

The arbiter resolved this decision. It has no effect until you confirm.

':`

The agent resolved this decision. Reverse is available until ${esc(a.reverseUntil)}. A reverse cannot undo messages, work already done or code already shipped.

`} +

`; + } + document.addEventListener('submit',async e=>{ + if(!e.target.matches('[data-autonomy-form]'))return;e.preventDefault(); + const form=e.target,section=form.closest('[data-autonomy-org]'),org=section.dataset.autonomyOrg,s=states.get(org),fields=new FormData(form); + const autonomy=Object.fromEntries(classes.map(k=>[k,fields.get(k)]));autonomy.notices=fields.has('notices');autonomy.reverseWindowHours=Number(fields.get('reverseWindowHours')); + if(fields.get('authorityResolver'))autonomy.authorityResolver=fields.get('authorityResolver'); + const button=form.querySelector('button[type="submit"]'),status=form.querySelector('[data-autonomy-status]');button.disabled=true; + try{const r=await call(org,'',{revision:s.revision,autonomy});s.revision=r.revision;s.autonomy=autonomy;s.levels=Object.fromEntries(classes.map(k=>[k,autonomy[k]]));status.textContent=`Saved. Receipt ${r.transaction}.`;} + catch(error){status.textContent=error.message;}finally{button.disabled=false;} + }); + document.addEventListener('click',async e=>{ + const button=e.target.closest('[data-autonomy-confirm],[data-autonomy-reverse],[data-autonomy-review]');if(!button)return; + const section=button.closest('[data-autonomy-org]'),org=section.dataset.autonomyOrg,status=section.querySelector('[data-autonomy-status]'); + const review=button.dataset.autonomyReview,reverse=button.hasAttribute('data-autonomy-reverse'); + const body=review?{decisionClass:review}:reverse?{id:section.dataset.autonomyDecision}:{id:section.dataset.autonomyDecision,confirm:button.dataset.autonomyConfirm==='yes',...(button.dataset.autonomyConfirm==='no'?{reason:section.querySelector('[data-autonomy-reason]').value}:{})}; + button.disabled=true; + try{await call(org,review?'review':reverse?'reverse':'confirm',body);status.textContent=review?'Trail review recorded.':reverse?'Reversal recorded.':'Confirmation recorded. Refresh the inbox for current state.';} + catch(error){status.textContent=error.message;}finally{button.disabled=false;} + }); + window.MosaicAutonomy={load,section,notice}; +})(); diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 097d16724..054639e45 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -112,7 +112,7 @@ const mids = new Set(of(tr, 'messages').filter(m => m.decision === id).map(m => m.id)); const dm = of(tr, 'deliveries').find(x => mids.has(x.message) && x.transport === 'discord-dm' && x.op === 'delivered'); return { - id, at: d.at, class: d.class, action: d.action, routeTo: d.route_to, question: d.question, + id, business:d.business, autonomy:row?.autonomy??null, at: d.at, class: d.class, action: d.action, routeTo: d.route_to, question: d.question, options: Array.isArray(d.options) ? d.options : [], recommendation: d.recommendation, taskRef: d.task_ref, requirementRef: d.requirement_ref, blocking: !!d.blocking, raisedBy: { role: d.raised_by_role, run: d.raised_by_run }, authorization: row ? row.authorization : raise ? { action: d.action, target: raise.body.target, approvalChoice: raise.body.approvalChoice } : null, @@ -302,7 +302,7 @@ // Inbox function decisionCard(d) { return `
  • -

    ${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${canDecide && d.console ? ` ${badge(['decision','hire'].includes(d.console.kind) ? 'Answer here' : 'Approve or Deny here', 'ok')}` : ''}

    +

    ${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${d.autonomy?badge(d.autonomy.pending?'Awaiting your confirmation':'Near the loop','attn'):''}${canDecide && d.console ? ` ${badge(['decision','hire'].includes(d.console.kind) ? 'Answer here' : 'Approve or Deny here', 'ok')}` : ''}

    ${approves(d.authorization)}

    ${window.MosaicInbox?.cardMeta?.(d) ?? `

    Raised by ${txt(d.raised_by_role)} ${when(d.at)}

    `}

    ${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}

  • `; } @@ -615,6 +615,7 @@

    Question

    ${txt(d.question)}

    ${window.MosaicInbox?.detailMeta?.(d) ?? ''}
    Class
    ${chip(d.class, d.action)}${d.blocking ? ` ${blockChip('blocking')}` : ''}
    Approval
    ${approves(d.authorization)}
    Routed to
    ${txt(d.routeTo)}
    Raised by
    ${txt(d.raisedBy.role)} ${txt(d.raisedBy.run)}
    Raised
    ${when(d.at)}
    ${d.taskRef ? `
    Task
    ${taskLink(d.taskRef)} ${txt(d.taskRef)}
    ` : ''}${d.requirementRef ? `
    Requirement
    ${txt(d.requirementRef)}
    ` : ''}
    DM
    ${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}
    Id
    ${txt(d.id)}
    ${answer?.id === d.id ? decideHtml() : ''} + ${canDecide?window.MosaicAutonomy?.notice(d)??'':''}

    Options

    ${help}${options}

    Earlier versions

    ${earlier.length ? `
      ${earlier.map(x => `
    • ${txt(short(x.id))} ${badge(x.closed ? x.closed.op : 'open')} ${txt(first(x.question))}${x.closed?.note ? `${txt(x.closed.note)}` : ''}
    • `).join('')}
    ` : '

    None.

    '}

    History

    ${history.length ? `
      ${history.join('')}
    ` : '

    Not seen yet.

    '}

    Full trail for this decision

    `; diff --git a/packages/webui/src/public/index.html b/packages/webui/src/public/index.html index b13004bfe..c9598e0ef 100644 --- a/packages/webui/src/public/index.html +++ b/packages/webui/src/public/index.html @@ -7,7 +7,7 @@ - + diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 60f3f2e88..9ac75976e 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -207,7 +207,7 @@ const org = list?.find(o => o.id === id && !o.invalid); if (!org) return `

    Organization unavailable

    ${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}

    `; const types = window.MosaicSystemTypes || null; - const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id), types ? types.load(id) : null]); + const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id), types ? types.load(id) : null, window.MosaicAutonomy?.load(id)]); const value = i => (reads[i].status === 'fulfilled' ? reads[i].value : null); const business = value(0), settings = value(1), instances = instancesOf(value(2)); const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5)); @@ -267,6 +267,7 @@ ${secH('Settings', 'every setting, with where its value comes from')} ${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null, types })} ${types ? types.section(id, { pane, idPrefix: opts.idPrefix || '' }) : ''} + ${window.MosaicAutonomy?.section(id)??''} `; diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index be55a66da..c3775e1f5 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -1,3 +1,4 @@ +import { AUTONOMY_ROUTE, autonomyHTTP } from './autonomy-http.mjs'; import { proxyHTTP } from './proxy-http.mjs'; import { createServer } from 'node:http'; import { bindTesterSocket, TESTER_ORIGIN } from './tester-socket.mjs'; @@ -78,6 +79,8 @@ const files = new Map([ ['/icons.svg', ['icons.svg', 'image/svg+xml']], ['/project-remote.css', ['project-remote.css', 'text/css; charset=utf-8']], // Phase P2 (#1873): the owner's System types control (dashboard section and project selection). + ['/autonomy.js', ['autonomy.js', 'text/javascript; charset=utf-8']], + ['/autonomy.css', ['autonomy.css', 'text/css; charset=utf-8']], ['/system-types.js', ['system-types.js', 'text/javascript; charset=utf-8']], ['/system-types.css', ['system-types.css', 'text/css; charset=utf-8']], ...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]), @@ -516,7 +519,7 @@ const AGENT_ROUTES = { done: r => /^[a-f0-9]{64}$/.test(r?.revision ?? '') && typeof r.restart === 'boolean' && r.instances && typeof r.instances === 'object' && !Array.isArray(r.instances) }, }; // F3: the writes the owner makes on a selected organization's bus from Settings (dev org Console). -const ORG_OWNER_WRITE = path => path === '/api/settings' || path === '/api/instances' || path === '/api/routes' +const ORG_OWNER_WRITE = path => AUTONOMY_ROUTE.test(path) || path === '/api/settings' || path === '/api/instances' || path === '/api/routes' || Object.hasOwn(SEAT_ROUTES, path) || Object.hasOwn(AGENT_ROUTES, path) || path.startsWith('/api/proxy/') // Phase P2 (#1873): the System types enable switch and a project's selection; the route keeps its own checks. || SYSTEM_TYPES_ROUTE.test(path); @@ -919,7 +922,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF // alone stays open on a loopback Host for the status and release gates (Q-C). if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' }); // No CORS. JSON content type and same-origin checks keep browser forms out. - const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path), systemTypesPath = SYSTEM_TYPES_ROUTE.test(path); + const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path), systemTypesPath = SYSTEM_TYPES_ROUTE.test(path), autonomyPath = AUTONOMY_ROUTE.test(path); if (req.headers.origin && req.headers.origin !== (named ? (sso ? (localOrigin ?? sso.publicOrigin) : `https://${preview.host}`) : `http://${req.headers.host}`)) return decisionPath ? decisionRefuse(res, 'cross-origin') : requestPath ? requestRefuse(res, 'cross-origin') : json(res, 403, { error: 'cross-origin request refused' }); let signer = preview, ssoSession = null; @@ -931,7 +934,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (!session && path !== '/healthz') { if (decisionPath) return decisionRefuse(res, 'sign-in-required'); if (requestPath) return requestRefuse(res, 'sign-in-required'); - if (remotePath || systemTypesPath) return json(res, 401, { error: 'sign-in-required' }); + if (remotePath || systemTypesPath || autonomyPath) return json(res, 401, { error: 'sign-in-required' }); if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' }); res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' }); return res.end(); @@ -1040,6 +1043,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin }); } // Phase P2 (#1873): the owner's System types read and writes on the selected organization. + if(autonomyPath) { + if(tester || granted || !named || sso===null)return json(res,403,{error:'owner-only'}); + return autonomyHTTP(req,res,{path,read:requestRead,write:send?requestWrite:null,session:ssoSession, + live:()=>{const live=sso.session(req);return live?.rootOwner===true?live:null;},origin:localOrigin??sso.publicOrigin}); + } if (systemTypesPath) { if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' }); return systemTypesHTTP(req, res, { path, read: requestRead, write: send ? systemTypesWrite : null, diff --git a/packages/webui/tests/autonomy-browser.test.mjs b/packages/webui/tests/autonomy-browser.test.mjs new file mode 100644 index 000000000..ff55fac98 --- /dev/null +++ b/packages/webui/tests/autonomy-browser.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdirSync} from 'node:fs'; +import {join} from 'node:path'; +import {startServer} from '../src/serve.mjs'; +import {createDevOwner} from '../src/dev-owner.mjs'; +import {browser} from './browser.mjs'; +import {close} from './fixture.mjs'; +const shots='/mnt/storage/scratch/mosaic-lead/reports/org-au-shots'; +const waitFor=(b,expression)=>b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error('autonomy view unavailable '+document.querySelector('[data-autonomy]')?.innerText)})()`); +for(const [width,height]of [[1440,900],[390,844]])for(const theme of ['light','dark'])test(`AU owner settings ${width}x${height} ${theme}`,{timeout:60000},async t=>{ + let writes=0,confirms=0,reverses=0,setting={merge:'out of the loop'},revision='a'.repeat(64); + const call=async(verb,args)=>{ + if(verb==='autonomy.get.console')return {revision,autonomy:setting,levels:{'cross-role':'out of the loop',merge:setting.merge,policy:'out of the loop',authority:'in the loop'}}; + if(verb==='autonomy.set.console'){assert.equal(args.revision,revision);writes++;setting=args.autonomy;revision='b'.repeat(64);return {revision,transaction:'10000000-0000-4000-8000-000000000001'};} + if(verb==='decision.confirm.console'){confirms++;assert.equal(args.confirm,true);return {op:'confirmed'};} + if(verb==='decision.reverse.console'){reverses++;return {reversed:true};} + if(verb==='system-types.console')return {installed:[],enabled:[],project:null}; + throw Object.assign(new Error('not-configured'),{code:'not-configured'}); + }; + const server=await startServer({port:0,sso:createDevOwner(),send:async()=>({}),organizations:{list:async()=>[{id:'acme',label:'Acme',busHealthy:true}],resolve:async()=>({busHealthy:true,requestRead:call,requestWrite:call,send:async()=>({}),reads:{business:async()=>({business:{id:'acme',projects:[],instances:[]}}),settings:async()=>({}),queue:async()=>({rows:[]})},bus:{instances:async()=>({instances:{}}),agents:async()=>[],'thread.claims':async()=>[],tasks:async()=>[],inbox:async()=>[],messages:async()=>({messages:[]})},chat:{projects:async()=>[],threads:async()=>[]}})},log:()=>{}}); + t.after(()=>close(server));const b=await browser({machine:false});t.after(()=>b.close());await b.viewport(width,height); + await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/acme/organization`); + await waitFor(b,`document.querySelector('[data-autonomy-form]')`); + await b.evaluate(`document.documentElement.dataset.mode=${JSON.stringify(theme)};document.querySelector('[data-autonomy-form]').scrollIntoView();true`); + assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form] select[name=merge]').value`),'out of the loop'); + assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`),true); + await b.evaluate(`document.querySelector('[data-autonomy-form] select[name=merge]').value='in the loop';document.querySelector('[data-autonomy-form]').requestSubmit();true`); + await waitFor(b,`/Saved/.test(document.querySelector('[data-autonomy-status]').textContent)`);assert.equal(writes,1);assert.equal(setting.merge,'in the loop'); + mkdirSync(shots,{recursive:true});await b.evaluate(`document.querySelector('[data-autonomy]').scrollIntoView();true`);await b.screenshot(join(shots,`autonomy-${width}-${theme}.png`)); + await b.evaluate(`document.querySelector('[data-autonomy]').innerHTML=MosaicAutonomy.notice({id:'decision-1',business:'acme',autonomy:{pending:true}});document.querySelector('[data-autonomy-confirm=yes]').click();true`); + await waitFor(b,`/Confirmation recorded/.test(document.querySelector('[data-autonomy-status]').textContent)`);assert.equal(confirms,1); + assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`),true);await b.screenshot(join(shots,`confirm-${width}-${theme}.png`)); + await b.evaluate(`document.querySelector('[data-autonomy]').innerHTML=MosaicAutonomy.notice({id:'decision-2',business:'acme',autonomy:{notice:true,reverseUntil:'2099-01-01T00:00:00Z'}});document.querySelector('[data-autonomy-reverse]').click();true`); + await waitFor(b,`/Reversal recorded/.test(document.querySelector('[data-autonomy-status]').textContent)`);assert.equal(reverses,1); + assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`),true);await b.screenshot(join(shots,`reverse-${width}-${theme}.png`)); +}); diff --git a/packages/webui/tests/autonomy-http.test.mjs b/packages/webui/tests/autonomy-http.test.mjs new file mode 100644 index 000000000..655dca628 --- /dev/null +++ b/packages/webui/tests/autonomy-http.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {Readable} from 'node:stream'; +import {autonomyHTTP} from '../src/autonomy-http.mjs'; +const owner={provider:'test',iss:'https://issuer.test',sub:'owner'}; +async function request({method='POST',path='/api/autonomy',body={revision:'a'.repeat(64),autonomy:{merge:'in the loop'}},headers={},session=owner,writable=true,live=()=>session}={}) { + const req=Readable.from([Buffer.from(JSON.stringify(body))]);req.method=method;req.headersDistinct={origin:['http://console.test'],'sec-fetch-site':['same-origin'],'content-type':['application/json'],...headers}; + let status,result,calls=[];const res={writeHead:s=>status=s,end:text=>result=JSON.parse(text)}; + const invoke=async(...args)=>{calls.push(args);return {revision:'b'.repeat(64)};}; + await autonomyHTTP(req,res,{path,session,live,read:invoke,write:writable?invoke:null,origin:'http://console.test'}); + return {status,result,calls}; +} +test('AU HTTP uses the authenticated subject and owner bus verbs',async()=>{ + for(const [path,body,verb] of [['/api/autonomy',{revision:'a'.repeat(64),autonomy:{}},'autonomy.set.console'],['/api/autonomy/confirm',{id:'decision-1',confirm:true},'decision.confirm.console'],['/api/autonomy/reverse',{id:'decision-1'},'decision.reverse.console'],['/api/autonomy/review',{decisionClass:'merge'},'autonomy.review.console']]) { + const r=await request({path,body});assert.equal(r.status,200);assert.deepEqual(r.calls,[[verb,body,{subject:owner}]]); + } +}); +test('AU HTTP member read-only session refuses every write before bus',async()=>{ + const r=await request({writable:false});assert.equal(r.status,403);assert.deepEqual(r.calls,[]); +}); +test('AU HTTP sign-in, cross-origin and stale session refuse without bus effects',async()=>{ + for(const options of [{session:null},{headers:{origin:['https://attacker.test']}},{headers:{origin:['http://console.test','http://console.test']}},{headers:{'sec-fetch-site':['cross-site']}},{live:()=>null}]) { + const r=await request(options);assert.ok([401,403].includes(r.status));assert.deepEqual(r.calls,[]); + } +}); +test('AU HTTP refuses caller-selected identity or organization',async()=>{ + for(const extra of [{actor:'owner'},{business:'other'},{subject:owner}]){const r=await request({body:{revision:'a'.repeat(64),autonomy:{},...extra}});assert.equal(r.status,400);assert.deepEqual(r.calls,[]);} +}); diff --git a/packages/webui/tests/browser.test.mjs b/packages/webui/tests/browser.test.mjs index 036c91f45..0ceac241f 100644 --- a/packages/webui/tests/browser.test.mjs +++ b/packages/webui/tests/browser.test.mjs @@ -19,7 +19,7 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t await b.evaluate('document.querySelector("#hide-offline").click()'); assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4); // Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. U5 (#1873) adds panes.js (Side by side) just before bus.js, which mounts it. P2 (#1873) adds system-types.js after project-remote.js. - assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/system-types.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/panes.js', '/bus.js', '/requests.js']); + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/system-types.js', '/autonomy.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/panes.js', '/bus.js', '/requests.js']); assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1); assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1); assert.equal(await b.evaluate('!!window.injected'), false); diff --git a/packages/webui/tests/fixtures/rename-a/business-response.json b/packages/webui/tests/fixtures/rename-a/business-response.json index c4d518814..f1a045393 100644 --- a/packages/webui/tests/fixtures/rename-a/business-response.json +++ b/packages/webui/tests/fixtures/rename-a/business-response.json @@ -47,6 +47,7 @@ "prd.approve", "decision.resolve.technical", "decision.resolve.delivery", + "decision.resolve.authority", "library.write" ], "vars": { @@ -100,6 +101,7 @@ "policy.change": "gated", "prd.approve": "gated", "decision.resolve.technical": "gated", + "decision.resolve.authority": "gated", "decision.resolve.delivery": "within", "library.write": "gated" }, @@ -149,6 +151,7 @@ "policy.change": "gated", "prd.approve": "gated", "decision.resolve.technical": "within", + "decision.resolve.authority": "gated", "decision.resolve.delivery": "gated", "library.write": "gated" }, @@ -199,6 +202,7 @@ "policy.change": "gated", "prd.approve": "gated", "decision.resolve.technical": "gated", + "decision.resolve.authority": "gated", "decision.resolve.delivery": "gated", "library.write": "gated" }, @@ -246,6 +250,7 @@ "policy.change": "gated", "prd.approve": "gated", "decision.resolve.technical": "gated", + "decision.resolve.authority": "gated", "decision.resolve.delivery": "gated", "library.write": "gated" }, @@ -293,6 +298,7 @@ "policy.change": "gated", "prd.approve": "gated", "decision.resolve.technical": "within", + "decision.resolve.authority": "gated", "decision.resolve.delivery": "within", "library.write": "gated" }, -- 2.54.0 From e3232b814f34dccc8c8f6586ea20e88690d443c1 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 11 Nov 2026 09:46:27 -0600 Subject: [PATCH 2/4] fix(org): enforce autonomy budgets and durable inverse boundaries (#1873) --- packages/bus/src/autonomy-host.mjs | 46 ++++++++++- packages/bus/src/autonomy-state.mjs | 2 + packages/bus/src/broker.mjs | 47 ++++++++--- packages/bus/src/refusals.mjs | 3 +- packages/bus/src/runtime.mjs | 26 +----- .../bus/tests/autonomy-authority.test.mjs | 79 +++++++++++++++++++ packages/bus/tests/autonomy.test.mjs | 59 +++++++++++++- .../bus/tests/fire-revokes-grants.test.mjs | 26 +++++- packages/bus/tests/hire-decisions.test.mjs | 8 +- packages/organization/README.md | 4 +- packages/organization/src/autonomy.mjs | 14 ++++ packages/organization/src/hire-decisions.mjs | 11 +-- .../organization/src/instance-settings.mjs | 2 +- packages/organization/src/organization.mjs | 19 +++-- .../tests/autonomy-inverse.test.mjs | 14 +++- packages/organization/tests/autonomy.test.mjs | 14 ++++ packages/webui/src/autonomy-http.mjs | 2 +- packages/webui/src/public/autonomy.js | 9 ++- .../webui/tests/autonomy-browser.test.mjs | 35 +++++--- packages/webui/tests/autonomy-http.test.mjs | 10 ++- 20 files changed, 352 insertions(+), 78 deletions(-) create mode 100644 packages/bus/tests/autonomy-authority.test.mjs diff --git a/packages/bus/src/autonomy-host.mjs b/packages/bus/src/autonomy-host.mjs index 960d3ddd3..58d1b98c9 100644 --- a/packages/bus/src/autonomy-host.mjs +++ b/packages/bus/src/autonomy-host.mjs @@ -14,10 +14,20 @@ export function autonomyHost({writer,dataRoot,business,hires,reload,processing,e const live=reload(id);return {...result,restart:live===false}; }, reversible:cls=>['cross-role','merge'].includes(cls) || cls==='authority' && typeof hires?.reverse==='function', - async reverse({business:id,decision,decisionClass,raised,effects,actor,beforeWrite,fire,stop,relaunch,record}) { + async reverse({business:id,decision,decisionClass,raised,effects,actor,beforeWrite,beforePublish,fire,stop,complete,relaunch,record}) { if(decisionClass==='authority') { if(!hires?.reverse)refuse('reversal-unavailable'); - const result=await hires.reverse(id,decision,{actor,beforeWrite,fire,stop,relaunch,record});reload(id);if(result.relaunch){beforeWrite();await relaunch(result.relaunch);}return {transaction:result.transaction??null}; + let stopped; + const result=await hires.reverse(id,decision,{actor,beforeWrite,beforePublish,fire,stop:async p=>{const r=await stop(p);stopped=p;return r;},relaunch,record}); + if(stopped)complete?.(stopped,result); + // The writer admitted publication within the window. Reload and relaunch + // complete that committed inverse; crossing the deadline here cannot undo it. + const receipt={committed:true,transaction:result.transaction??null,relaunch:result.relaunch?'pending':'not-needed'}; + try { + if(reload(id)===false)receipt.restart=true; + if(result.relaunch && !receipt.restart){await relaunch(result.relaunch);receipt.relaunch='requested';} + } catch {receipt.restart=true;if(result.relaunch)receipt.relaunch='failed';} + record(receipt);return receipt; } const used=effects.filter(e=>e.operation!=='decision.raise' && !String(e.operation??'').startsWith('autonomy.')); // An unused approval has changed no task or policy. Reversing it revokes that permission. @@ -44,3 +54,35 @@ export function autonomyHost({writer,dataRoot,business,hires,reload,processing,e }, }; } + +// The runtime adapter is shared with integration tests, including key revocation order. +export function authorityHost(service,{modelKeys,reload,proxyOrganization}) { + return { + ...service, + reverse:async(id,decision,caller)=>{ + const proposal=service.read(decision); + if(!modelKeys || (proposal.level==='org' && proposal.op==='hire'))return service.reverse(id,decision,caller); + const instance=`${id}/${proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name}`; + let prepared=false; + return service.reverse(id,decision,{ + ...caller, + beforeWrite:()=>{caller.beforeWrite();if(prepared){modelKeys.completeRevoke(instance);prepared=false;}caller.beforeWrite();}, + stop:async(p,guard)=>{await modelKeys.prepareRevoke(instance,guard);prepared=true;return caller.stop(p);}, + }); + }, + apply:async(id,decision,caller)=>{ + const proposal=service.read(decision); + const publish=async()=>{ + let approved=caller; + if(modelKeys && proposal.op==='fire' && proposal.level!=='org') { + const instance=`${id}/${proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name}`; + await modelKeys.prepareRevoke(instance,()=>caller.beforeWrite?.()); + approved={...caller,beforeWrite:()=>{caller.beforeWrite?.();modelKeys.completeRevoke(instance);caller.beforeWrite?.();}}; + } + const result=await service.apply(id,decision,approved);reload(id);return result; + }; + if(proxyOrganization && proposal.level==='org' && proposal.op==='hire')return proxyOrganization.hire(id,proposal.name,()=>service.check(id,decision,caller),publish); + return publish(); + }, + }; +} diff --git a/packages/bus/src/autonomy-state.mjs b/packages/bus/src/autonomy-state.mjs index 065d4d492..796ef5eb0 100644 --- a/packages/bus/src/autonomy-state.mjs +++ b/packages/bus/src/autonomy-state.mjs @@ -5,6 +5,8 @@ export function autonomyEvent(q, business, decision, operations) { return row?{...row,body:JSON.parse(row.body)}:null; } export function decisionResolution(q, business, decision) { + const terminal=q.get("SELECT * FROM decision_events WHERE decision=? AND op IN ('withdrawn','expired') ORDER BY seq DESC LIMIT 1",decision); + if(terminal)return terminal; const overlay=autonomyEvent(q,business,decision,['autonomy.resolved','autonomy.declined']); if(overlay)return overlay.body.operation==='autonomy.declined'?null:{...overlay.body.resolution,seq:overlay.seq}; return q.get("SELECT * FROM decision_events WHERE decision=? AND op IN ('resolved','withdrawn','expired') ORDER BY seq DESC LIMIT 1",decision); diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 5f8c05924..1bb558cf0 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -1,5 +1,5 @@ import { autonomyEvent, decisionResolution } from './autonomy-state.mjs'; -import { autonomyClass, checkAutonomy, decisionAutonomy, resolverSubject } from '../../organization/src/autonomy.mjs'; +import { autonomyClass, authorityBudgetCovered, checkAutonomy, decisionAutonomy, resolverSubject } from '../../organization/src/autonomy.mjs'; import { ownRails } from '../../organization/src/rails.mjs'; import { refusalGuidance } from './refusals.mjs'; import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs'; @@ -3348,24 +3348,26 @@ export class Broker { const job = (async () => { try { await new Promise((resolve) => setImmediate(resolve)); - await run(); + return await run(); } finally { this.#revoking.delete(key); } })(); this.#grantJobs.add(job); job.catch(() => {}).finally(() => this.#grantJobs.delete(job)); + return job; } // Section 2, the fire's job: step G, then step B and step C. A step G refusal is section 4's record. A // step B or C failure is fire.failed (d6, C1). Step C records the authority the verb checked, with no // holder check, since the chief's run may have ended since. - async #fireJob(sc, args, authority, write, job, set) { + async #fireJob(sc, args, authority, write, job, set,propagate=false) { const name = args.name; let out; try { out = await this.#stepG(sc.business, name, set, { job, reason: args.reason, by: sc.role, flag: { fire: true } }); } catch (e) { this.#store.transaction(() => this.#grantRefused(sc.business, name, 'instance.fire', job, e.grantRefusal, sc.role)); + if(propagate)throw e; return; } let step = 'B'; @@ -3377,6 +3379,7 @@ export class Broker { else this.#recordAuthority(sc, 'role.fire', authority, { reason: args.reason, transaction: result.transaction ?? null }); this.#event(sc, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null, job, grants: out.grants, grantsLeft: out.grantsLeft }, name); }); + return result; } catch (e) { const code = e instanceof BusError ? e.code : 'outcome-unknown'; const list = out.grants.map((g) => g.grant).join(', ') || 'none'; @@ -3389,6 +3392,7 @@ export class Broker { `Fire of ${name}: job ${job} revoked its grants (${list}) but stopped at step ${step} (${code}).${tokens} The instance is still firing. Run the fire again once ${code} is cleared.`, ); }); + if(propagate)throw e; } } // Section 2, the delete's job: step G, then grants.revoked.done and the end message. @@ -6402,6 +6406,7 @@ export class Broker { // Lead ruling: policy has no effect executor. Its existing out and in levels // remain usable; near refuses in checkAutonomy until an inverse exists. if(cls==='policy')continue; + if(cls==='authority' && after==='out of the loop' && before!==after)fail('authority-limits-unavailable'); if(['in the loop','near the loop','out of the loop'].indexOf(after)<=['in the loop','near the loop','out of the loop'].indexOf(before))continue; if(a.autonomy.notices!==true || !this.#autonomyHost.reversible?.(cls,b))fail('autonomy-precondition'); const resolvers=cls==='authority'?[a.autonomy.authorityResolver]:cls==='merge'?[b.arbiters.technical]:cls==='policy'?[b.arbiters.delivery]:Object.values(b.arbiters); @@ -6410,13 +6415,15 @@ 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' && !b.launch?.limits?.tokens)fail('autonomy-precondition'); + if(cls==='authority' && !authorityBudgetCovered(b))fail('autonomy-precondition'); if(after==='out of the loop') { if(before!=='near the loop')fail('autonomy-precondition'); - const review=this.#store.get("SELECT 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); + 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); const latest=this.#store.get("SELECT seq FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.set' ORDER BY seq DESC LIMIT 1",s.business); if(!review || JSON.parse(review.body).settingSeq!==latest?.seq)fail('autonomy-precondition'); - if(cls==='authority' && Object.keys(b.roles).some(name=>!b.launch?.limits?.tokens?.instances?.[name]?.limit))fail('autonomy-precondition'); + const notice=this.#store.get("SELECT seq FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.notice' AND json_extract(body,'$.decisionClass')=? AND seq>? ORDER BY seq DESC LIMIT 1",s.business,cls,latest?.seq??0); + const reversal=this.#store.get("SELECT max(e.seq) seq FROM events e WHERE e.business=? AND e.kind='action.allowed' AND json_extract(e.body,'$.operation') LIKE 'autonomy.reverse%' AND EXISTS(SELECT 1 FROM events n WHERE n.business=e.business AND n.kind='action.allowed' AND json_extract(n.body,'$.operation')='autonomy.notice' AND json_extract(n.body,'$.decisionClass')=? AND json_extract(n.body,'$.decision')=json_extract(e.body,'$.decision'))",s.business,cls); + if(!notice || review.seq<=Math.max(notice.seq,reversal?.seq??0))fail('autonomy-precondition'); } } this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.set.requested',autonomy:a.autonomy,subject:s.console},null)); @@ -6450,7 +6457,12 @@ export class Broker { const effects=this.#store.all("SELECT body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.decision')=? ORDER BY seq",s.business,d.id).map(e=>JSON.parse(e.body)); let recorded=false; const result=await this.#autonomyHost.reverse({business:s.business,decision:d.id,decisionClass:autonomyClass(d.action),action:d.action,raised:this.#raised(s.business,d.id),effects,actor:s.human, - beforeWrite:()=>{this.#autonomyOwner(s);windowOpen();},fire:proposal=>this.#fireApproved(s,proposal,windowOpen),stop:proposal=>this.#stopCopyApproved(s,proposal),relaunch:proposal=>this.#dispatch(s,'instance.launch',{instance:proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name}),record:result=>this.#store.transaction(()=>{windowOpen();recorded=true;this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,result},d.id);})}); + beforeWrite:()=>{this.#autonomyOwner(s);windowOpen();},beforePublish:()=>{this.#autonomyOwner(s);windowOpen();},fire:proposal=>this.#fireApproved(s,proposal,()=>{this.#autonomyOwner(s);windowOpen();},true),stop:proposal=>this.#stopCopyApproved(s,proposal),complete:(proposal,result)=>this.#store.transaction(()=>this.#event(s,'action.allowed',{action:'fire.done',decision:d.id,transaction:result.transaction??null},proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name)),relaunch:proposal=>{ + this.#autonomyOwner(s); + const instance=proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name; + this.#mayHuman(s,'instance.launch',instance);this.#launchable(s,instance,'not-thread-launch'); + return this.#store.transaction(()=>({request:this.#lifecycleRecord(s,'lifecycle.launch',instance,{decision:d.id})})); + },record:result=>this.#store.transaction(()=>{if(!(autonomyClass(d.action)==='authority' && result.committed===true))windowOpen();recorded=true;this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,result},d.id);})}); if(!recorded)this.#store.transaction(()=>{windowOpen();this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,result},d.id);}); return {id:d.id,reversed:true,...result}; } catch(error) { @@ -6488,6 +6500,8 @@ export class Broker { const level=decisionAutonomy('authority',b.autonomy); 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'); 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'; @@ -6516,6 +6530,8 @@ export class Broker { if(this.#closed(d.id))fail('decision-closed'); 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.#business(s.business).roles[s.role]?.authority?.withinRole.includes('decision.resolve.authority'))fail('not-resolver'); }; validate(); @@ -6534,13 +6550,15 @@ export class Broker { // No caller-provided human identity: the trusted writer receives the resolving instance. 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.#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(); try { if(p.op==='fire' && p.level!=='org')await this.#stopCopyApproved(s,p); recheck(); - const result=p.op==='fire' && p.level==='org'?await this.#fireApproved(s,p,recheck):await this.#hires.apply(s.business,d.id,{...caller,beforeWrite:recheck}); + const result=p.op==='fire' && p.level==='org'?await this.#fireApproved(s,p,recheck):await this.#hires.apply(s.business,d.id,{...caller,beforeWrite:recheck,beforePublish:recheck}); this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'hire.apply',decision:d.id,transaction:result.transaction??null,revision:result.revision??null},d.id)); return {...result,id:d.id,op:'resolved',choice:a.choice}; } catch(error) { @@ -6621,13 +6639,14 @@ export class Broker { return {running}; } // The existing row-281 fire executor, now entered only after a consumed human decision. - #fireApproved(s,p,beforeWrite=()=>{}) { + #fireApproved(s,p,beforeWrite=()=>{},waitForCommit=false) { const args={name:p.name,reason:'Approved fire decision'},verb='instance.fire',action='role.fire'; const b=this.#business(s.business),authority={target:p.name}; + const refused=e=>{if(['reverse-window-closed','autonomy-precondition','not-resolver'].includes(e.code))throw e;return this.#organizationFail(e,verb);}; const write=op=>{ - try {beforeWrite();const result=this.#organization(s.business,op,{name:p.name,revision:p.revision},s.human??s.role,{decision:p.id,digest:p.digest,beforeWrite}); - return result?.then?result.catch(e=>this.#organizationFail(e,verb)):result; - } catch(e) {this.#organizationFail(e,verb);} + try {beforeWrite();const result=this.#organization(s.business,op,{name:p.name,revision:p.revision},s.human??s.role,{decision:p.id,digest:p.digest,beforeWrite,beforePublish:beforeWrite}); + return result?.then?result.catch(refused):result; + } catch(e) {refused(e);} }; if (!Object.hasOwn(b.roles, args.name)) fail('unknown-role'); if (END_PROTECTED.includes(b.roles[args.name].definition)) fail('organization-definition-refused'); @@ -6671,7 +6690,8 @@ export class Broker { if (stopped.job) { const { job, set, ...rest } = stopped; const sc = { business: s.business, role: s.role, run: s.run, thread: s.thread, human: s.human }; - this.#startGrantJob(s.business, args.name, verb, () => this.#fireJob(sc, args, authority, write, job, set)); + const pending=this.#startGrantJob(s.business, args.name, verb, () => this.#fireJob(sc, args, authority, write, job, set,waitForCommit)); + if(waitForCommit)return pending; return { ...rest, job, revoking: set.map((g) => g.label), done: false }; } // Step B: the delist. An instance already delisted counts as done. @@ -7312,6 +7332,7 @@ export class Broker { if(d.route_to==='human' || d.action==='prd.approve' || d.class==='within-role')fail('decision-not-eligible'); const pending=autonomyEvent(this.#store,s.business,d.id,['autonomy.pending','autonomy.declined','autonomy.confirmed']); const closed=this.#closed(d.id); + if(closed && ['withdrawn','expired'].includes(closed.op))fail('decision-closed'); if(pending?.body.operation!=='autonomy.pending')fail('decision-not-eligible'); const resolution=pending.body.resolution; if(!resolution || resolution.op!=='resolved' || this.#autonomyLevel(d)!=='in the loop')fail('decision-not-eligible'); diff --git a/packages/bus/src/refusals.mjs b/packages/bus/src/refusals.mjs index 633527c0e..34e5158e7 100644 --- a/packages/bus/src/refusals.mjs +++ b/packages/bus/src/refusals.mjs @@ -9,8 +9,9 @@ const group = (codes, rail, nextStep) => { entries.push([code, guidance]); } }; +group("authority-limits-unavailable", "R3/R12: future authority spend coverage", "Use authority near the loop with an enforced organization budget. Out of the loop waits for enforceable per-agent limits in every approvable definition; current-instance limits do not cover future hires."); group("policy-reverse-unavailable", "R3/R12: policy inverse unavailable", "Choose policy in the loop or out of the loop. Near the loop waits for a policy effect executor and a tested reverse."); -group("invalid-autonomy autonomy-precondition reversal-unavailable", "R3/R12: validated owner autonomy and reversible effects", "Use only the four decision classes and three levels. Keep work in the loop until the class has a distinct resolver, tested inverse, notices, review and spend prerequisites; an unsupported effect needs an ordinary new decision."); +group("invalid-autonomy autonomy-precondition reversal-unavailable", "R3/R12: validated owner autonomy and reversible effects", "Use only the four decision classes and three levels. Keep work in the loop until the class has a distinct resolver, tested inverse, notices, review and spend prerequisites; an unsupported effect needs an ordinary new decision. Authority near needs enabled limit checks and aggregate coverage of new agents. Moving out needs a near-loop resolution followed by the owner’s trail review. A failed inverse may have stopped an agent; inspect its writer receipt and use the normal launch or decision path."); group("confirmation-required", "R5/R12: person confirms the arbiter resolution", "The arbiter remains the resolver. Confirm its pending resolution in the owner Console, or return it with a reason for a fresh resolution."); group("decision-reversed reverse-window-closed", "R2/R12: one-use reverse within the owner's window", "Inspect the reversal trail. After the window, raise an ordinary new decision through the normal approval path; never replay the old approval."); group("definition-shared hire-resolution-required hire-owner-required decision-expired right-required", "R5/R8/R9: digest-bound hire approval by a permitted person", "Use the hire Console path with org.hire. Flagged, credential and root decisions need their owner; expired decisions need a fresh proposal."); diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 93b8eac7f..37f6da205 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -1,4 +1,4 @@ -import { autonomyHost } from './autonomy-host.mjs'; +import { autonomyHost, authorityHost } from './autonomy-host.mjs'; import { hireDecisions } from '../../organization/src/hire-decisions.mjs'; import { organizationPlugins } from '../../plugins/src/organization.mjs'; import { pluginService } from '../../plugins/src/tools.mjs'; @@ -895,29 +895,7 @@ export async function startBroker({ 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}); - return {...service,reverse:async(id,decision,caller)=>{ - const proposal=service.read(decision); - if(!modelKeys || (proposal.level==='org' && proposal.op==='hire'))return service.reverse(id,decision,caller); - const instance=`${id}/${proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name}`; - let prepared=false; - return service.reverse(id,decision,{...caller, - beforeWrite:()=>{caller.beforeWrite();if(prepared){modelKeys.completeRevoke(instance);prepared=false;}caller.beforeWrite();}, - stop:async(p,guard)=>{await modelKeys.prepareRevoke(instance,guard);prepared=true;return caller.stop(p);}, - }); - },apply:async(id,decision,caller)=>{ - const proposal=service.read(decision); - const publish=async()=>{ - let approved=caller; - if(modelKeys && proposal.op==='fire' && proposal.level!=='org') { - const instance=`${id}/${proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name}`; - await modelKeys.prepareRevoke(instance,()=>caller.beforeWrite?.()); - approved={...caller,beforeWrite:()=>{caller.beforeWrite?.();modelKeys.completeRevoke(instance);caller.beforeWrite?.();}}; - } - const result=await service.apply(id,decision,approved);reload(id);return result; - }; - if(proxyOrganization && proposal.level==='org' && proposal.op==='hire')return proxyOrganization.hire(id,proposal.name,()=>service.check(id,decision,caller),publish); - return publish(); - }}; + return authorityHost(service,{modelKeys,reload,proxyOrganization}); })() : null, roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null, secretCheck: (value) => credentials.assertClean(value), diff --git a/packages/bus/tests/autonomy-authority.test.mjs b/packages/bus/tests/autonomy-authority.test.mjs new file mode 100644 index 000000000..905ebee74 --- /dev/null +++ b/packages/bus/tests/autonomy-authority.test.mjs @@ -0,0 +1,79 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync,mkdirSync,rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {Broker} from '../src/broker.mjs'; +import {Store} from '../src/store.mjs'; +import {autonomyHost,authorityHost} from '../src/autonomy-host.mjs'; +import {organizationWriter} from '../../organization/src/organization.mjs'; +import {instanceSettings} from '../../organization/src/instance-settings.mjs'; +import {hireDecisions} from '../../organization/src/hire-decisions.mjs'; +import {loadBusiness} from '../../organization/src/organization-config.mjs'; +import {businessDoc,rolesCopy,writeJson} from '../../organization/tests/helpers.mjs'; +const inline={tools:['read'],network:'none',authority:{withinRole:[],crossRole:[]},credentials:[],instructions:'Changed role'}; +const cap={limit:100000,since:'2026-01-01T00:00:00.000Z'}; +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}}; + 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; + doc.roles.judge={definition:'org:judge',vars:{harness:'pi',model:'claude-opus-5-5'}}; + doc.roles.scout={definition:'org:scout',vars:{harness:'pi',model:'claude-opus-5-5'}}; + doc.launch.instances.push('scout');doc.autonomy={authority:'near the loop',authorityResolver:'judge',notices:true,reverseWindowHours:1}; + writeJson(join(root,'businesses/demo.json'),doc); + let reversing=false,broker,now=Date.now; + const writer=organizationWriter({dir:root,rolesDir,barrier:point=>{if(reversing && point==='prepared' && options.expirePrepared)Date.now=()=>now()+3600001;if(!reversing && point==='prepared' && options.removeResolverPrepared){const b=structuredClone(loaded());b.roles.judge.authority.withinRole=[];broker.reloadBusiness('demo',b);}}}); + const store=new Store(dataRoot),order=[]; + const loaded=()=>{const b=loadBusiness('demo',{dir:root,rolesDir});return {...b,roles:Object.fromEntries(Object.entries(b.roles).map(([n,e])=>[n,{...b.definitions[e.definition],...e}]))};}; + 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 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}); + for(const role of ['chief','judge','scout']){caps[role]=broker.bindLaunch({business:'demo',role,run:role+'-run',harness:'pi'});call(role,'role.claim');} + caps.owner=broker.bindConsole({business:'demo',human:'jason',subject:{provider:'test',iss:'test',sub:'owner'}}); + t.after(()=>{Date.now=now;store.close();rmSync(root,{recursive:true,force:true});}); + const raise=(extra={})=>call('chief','hire.raise',{op:'change',level:'org',name:'scout',role:{inline},route:{harness:'pi',model:'claude-opus-5-5'},reason:'Change the task',...extra}); + const reverse=id=>{reversing=true;return call('owner','decision.reverse.console',{id});}; + return {writer,store,order,broker,call,raise,reverse,loaded}; +} +for(const crossAfterCommit of [false,true])test(`AU authority reverse uses runtime key wrapper and relaunches after commit (deadline crossed: ${crossAfterCommit})`,async t=>{ + const f=world(t,{crossAfterCommit}),d=await f.raise();assert.deepEqual(d.hire.flagged,[]);assert.equal(d.route_to,'judge'); + await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});assert.equal(f.writer.read('demo').document.definitions.scout.rev,4); + const r=await f.reverse(d.id);assert.equal(r.committed,true);assert.equal(r.relaunch,'requested');assert.deepEqual(f.order,['prepare','complete']); + const restored=f.writer.read('demo').document.definitions.scout;assert.equal(restored.rev,5);assert.equal(restored.instructions,'Prior role'); + assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1); + assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse.failed'").n,0); + assert.ok(f.store.get("SELECT 1 FROM events WHERE json_extract(body,'$.action')='lifecycle.launch' AND subject='scout'")); +}); +test('AU a committed authority reverse reports pending recovery when reload refuses',async t=>{ + const f=world(t,{reloadRefused:true}),d=await f.raise();await f.call('judge','decision.resolve',{id:d.id,choice:'yes'}); + const r=await f.reverse(d.id);assert.equal(r.committed,true);assert.equal(r.restart,true);assert.equal(r.relaunch,'pending');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 authority reverse crossing deadline in prepared IO never publishes',async t=>{ + const f=world(t,{expirePrepared:true}),d=await f.raise();await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});const prior=f.writer.read('demo').revision; + await assert.rejects(f.reverse(d.id),{code:'reverse-window-closed'});assert.equal(f.writer.read('demo').revision,prior);f.writer.recover('demo');assert.equal(f.writer.read('demo').revision,prior); +}); +test('AU authority resolver rechecks budget removal after a proposal was raised',async t=>{ + const f=world(t),d=await f.raise(),b=structuredClone(f.loaded());b.plugins['limit-check']=[];f.broker.reloadBusiness('demo',b); + const before=f.writer.read('demo').revision;await assert.rejects(f.call('judge','decision.resolve',{id:d.id,choice:'yes'}),{code:'autonomy-precondition'}); + assert.equal(f.writer.read('demo').revision,before);assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?',d.id).n,0); +}); + +test('AU authority apply rechecks a removed resolve right at publication',async t=>{ + const f=world(t,{removeResolverPrepared:true}),d=await f.raise(),before=f.writer.read('demo').revision; + await assert.rejects(f.call('judge','decision.resolve',{id:d.id,choice:'yes'}),{code:'not-resolver'}); + assert.equal(f.writer.read('demo').revision,before);f.writer.recover('demo');assert.equal(f.writer.read('demo').revision,before); +}); +test('AU authority hire reverse runs normal fire/revoke and records committed delisting',async t=>{ + const f=world(t),d=await f.raise({op:'hire',name:'helper'});await f.call('judge','decision.resolve',{id:d.id,choice:'yes'}); + assert.ok(f.writer.read('demo').document.launch.instances.includes('helper')); + const r=await f.reverse(d.id);assert.equal(r.committed,true);assert.ok(!f.writer.read('demo').document.launch.instances.includes('helper'));assert.deepEqual(f.order,['fire-revoke']); + assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1); +}); diff --git a/packages/bus/tests/autonomy.test.mjs b/packages/bus/tests/autonomy.test.mjs index 7c568c953..dd5040df0 100644 --- a/packages/bus/tests/autonomy.test.mjs +++ b/packages/bus/tests/autonomy.test.mjs @@ -39,10 +39,10 @@ test('AU revoked resolver right refuses when an approved decision is consumed',a assert.throws(()=>f.broker.authorize(f.coder,'task.scope.change',{decision:d.id,target:'vikunja:1/2'}),{code:'not-resolver'}); }); test('AU member, agent and dev tester cannot change settings',async t=>{ - const f=await setup(t); + const revision='a'.repeat(64),f=await setup(t,null,{get:()=>({revision,autonomy:{}})}); const member=f.broker.bindHireConsole({business:'demo',actor:'member',subject:{...SUBJECT,sub:'member'},rights:['org.hire']}); - for(const cap of [f.coder,member])assert.throws(()=>f.call(cap,'autonomy.set.console',{revision:'0'.repeat(64),autonomy:{merge:'near the loop'}})); - assert.throws(()=>f.call(f.human,'autonomy.set.console',{revision:'0'.repeat(64),autonomy:{merge:'near the loop'}})); + for(const cap of [f.coder,member])assert.throws(()=>f.call(cap,'autonomy.set.console',{revision,autonomy:{merge:'near the loop'}}),{code:'human-required'}); + assert.throws(()=>f.call(f.human,'autonomy.set.console',{revision,autonomy:{merge:'near the loop'}}),{code:'human-required'}); }); test('AU near-loop merge sends owner a bounded notice; expired reverse names ordinary path',async t=>{ const f=await setup(t,{merge:'near the loop',notices:true,reverseWindowHours:1}),d=f.raise({action:'git.merge.protected'}); @@ -109,3 +109,56 @@ test('AU near cross-role authorization without a trusted inverse fails closed be assert.throws(()=>f.broker.authorize(f.coder,'task.scope.change',{decision:d.id,target:'vikunja:1/2'}),{code:'reversal-unavailable'}); assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.decision')=? AND json_extract(body,'$.action')='task.scope.change' AND json_extract(body,'$.operation') IS NOT 'decision.raise'",d.id).n,0); }); + + +test('AU a withdrawal after decline is terminal for resolver, owner and confirmation',async t=>{ + const f=await setup(t,{merge:'in the loop'}),d=f.raise({action:'git.merge.protected'}); + f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'}); + f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:false,reason:'Revise'}); + f.call(f.coder,'decision.withdraw',{id:d.id}); + assert.throws(()=>f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'}),{code:'decision-closed'}); + for(const cap of [f.chief,f.human])assert.ok(!f.call(cap,'inbox').some(x=>x.id===d.id)); + assert.throws(()=>f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:true}),e=>['decision-closed','decision-not-eligible'].includes(e.code)); +}); +async function settingsFixture(t) { + let f,autonomy={},revision='a'.repeat(64),writes=0; + const host={get:()=>({autonomy,revision}),set:(_b,a)=>{writes++;autonomy=a.autonomy;revision='b'.repeat(64);f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);return {revision,transaction:'setting-test'};},reversible:()=>true}; + f=await setup(t,null,host); + f.business.roles.judge={definition:'judge',authority:{withinRole:['decision.resolve.authority'],crossRole:[]}}; + f.business.roles.reviewer.authority.withinRole.push('review.verdict'); + f.business.launch={by:'chief',models:{model:{provider:'anthropic',model:'test-model'}},limits:{tokens:{}}}; + f.business.plugins={'limit-check':['token-limit']}; + f.broker.reloadBusiness('demo',f.business); + return {...f,set:value=>f.call(f.owner,'autonomy.set.console',{revision,autonomy:value}),writes:()=>writes}; +} +const authorityNear={authority:'near the loop',authorityResolver:'judge',notices:true}; +const tokenCap={limit:1000,since:'2026-01-01T00:00:00.000Z'}; +test('AU authority near requires an enforced organization budget covering every allowed model, including zero caps',async t=>{ + const f=await settingsFixture(t); + for(const tokens of [{},{instances:{coder:tokenCap}},{providers:{unrelated:tokenCap}}]){ + f.business.launch.limits.tokens=tokens;f.broker.reloadBusiness('demo',f.business); + await assert.rejects(f.set(authorityNear),{code:'autonomy-precondition'}); + } + f.business.launch.limits.tokens={providers:{anthropic:{...tokenCap,limit:0}}}; + f.business.plugins={'limit-check':[]};f.broker.reloadBusiness('demo',f.business); + await assert.rejects(f.set(authorityNear),{code:'autonomy-precondition'}); + f.business.plugins={'limit-check':['token-limit']};f.broker.reloadBusiness('demo',f.business); + await f.set(authorityNear);assert.equal(f.writes(),1); +}); +test('AU authority out refuses until per-definition agent limits exist, even with current agents capped',async t=>{ + const f=await settingsFixture(t); + f.business.launch.limits.tokens={providers:{anthropic:tokenCap},instances:Object.fromEntries(Object.keys(f.business.roles).map(n=>[n,tokenCap]))}; + f.broker.reloadBusiness('demo',f.business);await f.set(authorityNear); + f.call(f.owner,'autonomy.review.console',{decisionClass:'authority'}); + await assert.rejects(f.set({...authorityNear,authority:'out of the loop'}),e=>e.code==='authority-limits-unavailable' && /near the loop/.test(e.nextStep)); + assert.equal(f.writes(),1); +}); +test('AU moving out requires an actual near resolution and a later owner review',async t=>{ + const f=await settingsFixture(t);await f.set({merge:'in the loop'});await f.set({merge:'near the loop',notices:true}); + f.call(f.owner,'autonomy.review.console',{decisionClass:'merge'}); + await assert.rejects(f.set({merge:'out of the loop',notices:true}),{code:'autonomy-precondition'}); + const d=f.raise({action:'git.merge.protected'});f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'}); + await assert.rejects(f.set({merge:'out of the loop',notices:true}),{code:'autonomy-precondition'}); + f.call(f.owner,'autonomy.review.console',{decisionClass:'merge'}); + await f.set({merge:'out of the loop',notices:true}); +}); diff --git a/packages/bus/tests/fire-revokes-grants.test.mjs b/packages/bus/tests/fire-revokes-grants.test.mjs index b1c9e0e86..f98036b0d 100644 --- a/packages/bus/tests/fire-revokes-grants.test.mjs +++ b/packages/bus/tests/fire-revokes-grants.test.mjs @@ -6,6 +6,7 @@ // keys are real local ssh-keygen pairs in the scratch store, and no ssh connection is made. Decisions a // test needs outside the human's yes go in by SQL as fixtures. import test from 'node:test'; +import {autonomyHost} from '../src/autonomy-host.mjs'; import { hireServiceFixture, fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs'; import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; @@ -75,7 +76,7 @@ function makeBusiness(url) { }; } -async function setup(t) { +async function setup(t,autonomy=null) { const dir = mkdtempSync(join(tmpdir(), 'g281-')); t.after(() => rmSync(dir, { recursive: true, force: true })); const gitea = await fakeGiteaApi(t, join(dir, 'tls')); @@ -157,7 +158,7 @@ async function setup(t) { return { revision: REV }; }; const make = () => - new Broker({hires:hireServiceFixture, + new Broker({hires:hireServiceFixture,autonomy, store, businesses: { demo: structuredClone(doc) }, secretCheck: (value) => credentials.assertClean(value), @@ -997,3 +998,24 @@ test("row 239 T4b: an unmarked step B refusal keeps its code, and the notice say assert.ok(message.includes('(revision-conflict). The instance is still firing.'), message); assert.ok(!message.includes('may already be revoked'), message); }); + +for(const expired of [false,true])test(`AU hire inverse waits for grant removal and refuses late publication (expired: ${expired})`,async t=>{ + const host=autonomyHost({hires:{reverse:async(id,decision,caller)=>caller.fire({id:decision,level:'org',name:'coder',revision:REV,digest:'a'.repeat(64)})},reload:()=>true}); + const f=await setup(t,host);await f.approveHost('coder','web1'); + const decision=grantDecision(f.store,{action:'role.hire',route:'pm',close:{op:'resolved',by:'pm-run',choice:'yes',via:'broker'}}); + const at=new Date().toISOString(),deadline=new Date(Date.now()+3600000).toISOString(); + f.store.run("INSERT INTO events(id,at,business,kind,subject,body) VALUES(?,?,?,'action.allowed',?,?)",decision,at,'demo',decision,JSON.stringify({operation:'autonomy.notice',decision,decisionClass:'authority',reverseUntil:deadline})); + f.held.add('web1:remove'); + const owner=fixtureConsole(f.broker),pending=f.broker.request(owner,{verb:'decision.reverse.console',args:{id:decision}}); + // Attach the rejection handler before letting the asynchronous revoke complete. + const outcome=pending.then(value=>({value}),error=>({error})); + await until(()=>f.sent.some(s=>s.startsWith('remove web1'))); + assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,0);assert.ok(f.doc.launch.instances.includes('coder')); + const clock=Date.now; + try { + if(expired)Date.now=()=>Date.parse(deadline)+1; + f.held.delete('web1:remove');const result=await outcome;await f.broker.grantsIdle(); + if(expired){assert.equal(result.error?.code,'reverse-window-closed');assert.ok(f.doc.launch.instances.includes('coder'));} + else{assert.equal(result.error,undefined);assert.equal(result.value.committed,true);assert.match(result.value.transaction,/^t-/);assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);assert.ok(!f.doc.launch.instances.includes('coder'));} + }finally{Date.now=clock;f.held.clear();} +}); diff --git a/packages/bus/tests/hire-decisions.test.mjs b/packages/bus/tests/hire-decisions.test.mjs index d8c8d8b6f..a59971d0d 100644 --- a/packages/bus/tests/hire-decisions.test.mjs +++ b/packages/bus/tests/hire-decisions.test.mjs @@ -209,6 +209,7 @@ test('AU a distinct authority resolver applies a plain near-loop hire, while fla const f=fixture(t),doc=JSON.parse(readFileSync(f.file,'utf8')); doc.definitions={resolver:{rev:1,role:{tools:['read'],network:'none',authority:{withinRole:['decision.resolve.technical','decision.resolve.authority'],crossRole:[]},credentials:[]},instructions:'Resolve independent decisions.'}}; doc.roles.cto={definition:'org:resolver'}; + doc.plugins['limit-check']=['token-limit'];doc.launch.limits={tokens:{providers:{zai:{limit:100000,since:'2026-01-01T00:00:00.000Z'}}}}; doc.autonomy={authority:'near the loop',authorityResolver:'cto',notices:true};writeJson(f.file,doc); const live=loadBusiness('demo',{dir:f.root,rolesDir:f.rolesDir}); const business={...live,roles:Object.fromEntries(Object.entries(live.roles).map(([n,e])=>[n,{...live.definitions[e.definition],...e}]))}; @@ -227,12 +228,13 @@ test('AU R5 a change naming the authority resolver stays with a person; no agent const f=fixture(t),doc=JSON.parse(readFileSync(f.file,'utf8')); doc.definitions={judge:{rev:1,role:{tools:['read'],network:'none',authority:{withinRole:['decision.resolve.authority'],crossRole:[]},credentials:[]},instructions:'Resolve independent authority decisions.'}}; doc.roles.judge={definition:'org:judge',vars:{harness:'pi',model:'claude-opus-5-5'}}; - doc.autonomy={authority:'near the loop',authorityResolver:'judge',notices:true};writeJson(f.file,doc); + doc.plugins['limit-check']=['token-limit'];doc.launch.limits={tokens:{providers:{zai:{limit:100000,since:'2026-01-01T00:00:00.000Z'}}}}; + doc.autonomy={authority:'near the loop',authorityResolver:'judge',notices:true};writeJson(f.file,doc); const loaded=loadBusiness('demo',{dir:f.root,rolesDir:f.rolesDir}); f.b.reloadBusiness('demo',{...loaded,roles:Object.fromEntries(Object.entries(loaded.roles).map(([n,e])=>[n,{...loaded.definitions[e.definition],...e}]))}); const judge=f.b.bindLaunch({business:'demo',role:'judge',run:'judge-run',harness:'pi'});f.call(judge,'role.claim'); - const d=await f.raise({op:'change',name:'judge',role:{definition:'judge',rev:1}}); - assert.ok(d.hire.flagged.some(line=>line.value==='decision.resolve.authority'));assert.equal(d.route_to,'human'); + const d=await f.raise({op:'change',name:'judge',role:{inline:{...INLINE,authority:{withinRole:['decision.resolve.authority'],crossRole:[]},instructions:'Narrow this resolver'}}}); + assert.ok(d.hire.flagged.some(x=>x.value==='decision.resolve.authority'));assert.equal(d.route_to,'human'); await assert.rejects(Promise.resolve().then(()=>f.call(judge,'decision.resolve',{id:d.id,choice:'yes'}))); assert.equal(f.writes(),0); }); diff --git a/packages/organization/README.md b/packages/organization/README.md index fea2a57ec..2460496f4 100644 --- a/packages/organization/README.md +++ b/packages/organization/README.md @@ -644,10 +644,10 @@ owner approval. No setting changes hard waits or the PRD request-mode switch. Moving toward the loop needs no prerequisites. Moving away requires the class's resolver, inverse, notices and review/limit prerequisites. Moving from near to -out also requires an owner trail review. A task inverse restores its prior +out also requires an actual near-loop resolution and a later owner trail review. Authority near requires an enabled limit check and an aggregate quota covering every selectable model (or the shared account when every enabled harness uses it). Empty budgets and existing-instance caps do not cover future hires. Authority out refuses `authority-limits-unavailable` until per-definition limits exist. A task inverse restores its prior stored fields if no later edit intervened; a merge inverse lands a gated revert commit. Hire/fire/change inverses reuse the existing lifecycle and writer paths, -never restore credential bindings, and refuse stale targets. External task +never restore credential bindings, and refuse stale targets. Authority inverses check the window immediately before file publication. After a durable commit, reload or relaunch failures are reported as recovery metadata, with the inverse recorded as committed. A refusal after stop/revoke leaves the agent stopped; inspect its trail before relaunching. External task adapters without host-owned inverse evidence refuse near-loop consumption. After the pinned window, use an ordinary new decision. diff --git a/packages/organization/src/autonomy.mjs b/packages/organization/src/autonomy.mjs index d1f48f717..41701b12f 100644 --- a/packages/organization/src/autonomy.mjs +++ b/packages/organization/src/autonomy.mjs @@ -28,3 +28,17 @@ export function resolverSubject(business, resolver, target) { const definition=business.roles?.[resolver]?.definition; return target===resolver || target===definition || target===definition?.replace(/^org:/,'') || (definition!==undefined && business.roles?.[target]?.definition===definition); } + +// 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) { + 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') && + (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??{}); + return checks.includes('token-limit') && models.length>0 && models.every(({provider,model})=> + quota(tokens?.providers?.[provider]) || quota(tokens?.models?.[provider]?.[model])); +} diff --git a/packages/organization/src/hire-decisions.mjs b/packages/organization/src/hire-decisions.mjs index 1ad79c2a9..35a9b49a2 100644 --- a/packages/organization/src/hire-decisions.mjs +++ b/packages/organization/src/hire-decisions.mjs @@ -244,8 +244,8 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle return publish(); } if(r.level==='project') return writer.setProjectAgent(business,{revision:p.revision,project:r.project,name:r.name,entry},caller.actor, - {approvedDefinition:p.entry?{name:p.definition,entry:approvedEntry}:null,decision:p.id,assertClean,beforeWrite:caller.beforeWrite,authorityDecision:caller.authorityDecision}); - return writer.applyHireDecision(business,p,{actor:caller.actor,assertClean,beforeWrite:caller.beforeWrite,authorityDecision:caller.authorityDecision}); + {approvedDefinition:p.entry?{name:p.definition,entry:approvedEntry}:null,decision:p.id,assertClean,beforeWrite:caller.beforeWrite,beforePublish:caller.beforePublish,authorityDecision:caller.authorityDecision}); + return writer.applyHireDecision(business,p,{actor:caller.actor,assertClean,beforeWrite:caller.beforeWrite,beforePublish:caller.beforePublish,authorityDecision:caller.authorityDecision}); } async function reverse(business,decision,caller) { const p=read(decision);if(p.business!==business || !p.prior || p.request.level==='root')fail('decision-stale'); @@ -259,15 +259,16 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle } if(p.prior.definition && canonicalJson(old.document.definitions?.[p.prior.instance.definition.slice(4)])!==canonicalJson(p.prior.definition))fail('decision-stale'); } else if(current?.hireDecision!==decision)fail('decision-stale'); - const guard=()=>{caller.beforeWrite();if(p.prior.definition)scanEntry(p.prior.instance.definition.slice(4),p.prior.definition);if(writer.read(business).revision!==old.revision)fail('decision-stale');}; + const publicationGuard=()=>{(caller.beforePublish??caller.beforeWrite)();if(p.prior.definition)scanEntry(p.prior.instance.definition.slice(4),p.prior.definition);if(writer.read(business).revision!==old.revision)fail('decision-stale');}; + const guard=()=>{caller.beforeWrite();publicationGuard();}; guard(); if(r.op==='hire') { if(r.level==='org')return caller.fire({...publicOf(p),revision:old.revision}); await caller.stop(publicOf(p),guard);guard(); - return writer.setProjectAgent(business,{revision:old.revision,project:r.project,name:r.name,entry:null},caller.actor,{beforeWrite:guard}); + return writer.setProjectAgent(business,{revision:old.revision,project:r.project,name:r.name,entry:null},caller.actor,{beforeWrite:guard,beforePublish:publicationGuard}); } const stopped=await caller.stop(publicOf(p),guard);guard(); - const result=writer.reverseHireDecision(business,p,{...caller,beforeWrite:guard}); + const result=writer.reverseHireDecision(business,p,{...caller,beforeWrite:guard,beforePublish:publicationGuard}); return {...result,...(r.op==='change' && stopped?.running?{relaunch:publicOf(p)}:{})}; } return {prepare,reverse,templates:()=>({templates:templateHashes(rolesDir).map(({template,sha256})=>({name:template,sha256}))}),preview:async(business,decision,caller)=>{const p=await adjusted(business,decision,caller.adjust);if(Date.parse(p.expiresAt)<=(caller.now??Date.now)())fail('decision-expired');authorizeHire(publicOf(p),caller);return publicOf(p);},rootRefusal,rootActive:name=>rootRefusal(name)===null,check:async(...args)=>{await check(...args);},apply,read: decision => publicOf(read(decision))}; diff --git a/packages/organization/src/instance-settings.mjs b/packages/organization/src/instance-settings.mjs index f549c92a6..838487c21 100644 --- a/packages/organization/src/instance-settings.mjs +++ b/packages/organization/src/instance-settings.mjs @@ -423,7 +423,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r // writer checks again under its lock, before the revoke (row 239), and that check is what keeps // a stale fire or delete from revoking. if (writer.read(business).revision !== revision) throw new OrganizationError("revision-conflict", "organization revision changed; read again"); - const receipt = writer.write(business, next, { revision, actor, context, beforeWrite }); + const receipt = writer.write(business, next, { revision, actor, context, beforeWrite, beforePublish:context?.beforePublish }); return { ...read(business, actor), transaction: receipt.transaction, restart: false }; } diff --git a/packages/organization/src/organization.mjs b/packages/organization/src/organization.mjs index d2501d316..77f8a1cf4 100644 --- a/packages/organization/src/organization.mjs +++ b/packages/organization/src/organization.mjs @@ -282,7 +282,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = ( // the old document, for a writer that builds what it publishes from what it read under the lock. // #1873 A2: `pathsOnly` logs each change's path and presence without its values, for a verb whose // values can hold a var a person typed (a secret pasted in the wrong place must not reach evidence). - function commit(id, document, { revision, actor, detail, context, beforeWrite = null, guard, pathsOnly = false }) { + function commit(id, document, { revision, actor, detail, context, beforeWrite = null, beforePublish = null, guard, pathsOnly = false }) { requireId(actor, "authenticated actor"); requireId(id, "organization id"); let revoked = false; @@ -343,6 +343,13 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = ( rmSync(candidate); fail("revision-conflict", "organization revision changed before publication"); } + // Pure admission check after candidate IO, immediately before publication. + // A refused prepared candidate is never rolled forward by recovery. + try { beforePublish?.(); } catch (error) { + log(id, { ...event, state: 'aborted' }); + rmSync(candidate); + throw error; + } renameSync(candidate, file); syncDir(parent); if (entry) { entry.digest = after; entry.revision++; publishRegistry(); } @@ -484,12 +491,12 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = ( if (entry === null) delete agents[name]; else agents[name] = entry; const { agents: _drop, ...rest } = target; return { ...old, ...(approval?.approvedDefinition ? {definitions:{...(old.definitions??{}),[approval.approvedDefinition.name]:approval.approvedDefinition.entry}} : {}), projects: { ...old.projects, [project]: Object.keys(agents).length ? { ...rest, agents } : rest } }; - }, { revision, actor, guard, beforeWrite:approval?.beforeWrite, pathsOnly: true, detail: { verb: "project.agents.set", project, agent: name, form: entryForm(entry), ...(approval?.decision ? {decision:approval.decision}: {}) } }); + }, { revision, actor, guard, beforeWrite:approval?.beforeWrite, beforePublish:approval?.beforePublish, pathsOnly: true, detail: { verb: "project.agents.set", project, agent: name, form: entryForm(entry), ...(approval?.decision ? {decision:approval.decision}: {}) } }); } // A4 trusted decision executor: one locked publication of the role, route and definition. // The broker has consumed the matching digest before entering this host-only writer. - function applyHireDecision(id, proposal, { actor, assertClean, beforeWrite = null, authorityDecision = null }) { + function applyHireDecision(id, proposal, { actor, assertClean, beforeWrite = null, beforePublish = null, authorityDecision = null }) { const { request: r, definition, entry, revision } = proposal; if (!proposal.id || r.level !== 'org') fail('invalid-request'); const build = old => { @@ -519,7 +526,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = ( } return next; }; - return commit(id, build, {revision,actor,beforeWrite,pathsOnly:true,guard:(old,next)=>{ + return commit(id, build, {revision,actor,beforeWrite,beforePublish,pathsOnly:true,guard:(old,next)=>{ if(canonicalJson(next)!==canonicalJson(build(old)))fail('organization-definitions-only'); projectsProtected(old,next); },context:{decision:proposal.id,digest:proposal.digest??null},detail:{verb:'hire.apply',decision:proposal.id,op:r.op,agent:r.name}}); @@ -527,7 +534,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = ( // AU inverse, from an immutable private proposal. It touches only the named instance and // definition; writer validation, fresh revision and owner fence still apply. Bindings never restore. - function reverseHireDecision(id,p,{actor,beforeWrite}) { + function reverseHireDecision(id,p,{actor,beforeWrite,beforePublish}) { const {document:old,revision}=read(id),r=p.request,prior=p.prior; if(actor!==old.human || !prior || r.level==='root')fail('human-required'); if(r.op==='hire')fail('invalid-request'); // Hire reverses through the stop/revoke/fire executor. @@ -560,7 +567,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = ( } return next; }; - return commit(id,build,{revision,actor,beforeWrite,pathsOnly:true,guard:(current,next)=>{if(actor!==current.human || canonicalJson(build(current))!==canonicalJson(next))fail('human-required');},detail:{verb:'hire.reverse',decision:p.id,agent:r.name}}); + return commit(id,build,{revision,actor,beforeWrite,beforePublish,pathsOnly:true,guard:(current,next)=>{if(actor!==current.human || canonicalJson(build(current))!==canonicalJson(next))fail('human-required');},detail:{verb:'hire.reverse',decision:p.id,agent:r.name}}); } // Runs `fn({ revision, document })` under the writer lock, after recovery, and nothing else may diff --git a/packages/organization/tests/autonomy-inverse.test.mjs b/packages/organization/tests/autonomy-inverse.test.mjs index a06fe0f77..8a24e78a9 100644 --- a/packages/organization/tests/autonomy-inverse.test.mjs +++ b/packages/organization/tests/autonomy-inverse.test.mjs @@ -8,13 +8,13 @@ import {organizationWriter} from '../src/organization.mjs'; import {hireDecisions} from '../src/hire-decisions.mjs'; import {instanceSettings} from '../src/instance-settings.mjs'; const role={tools:['read'],network:'none',authority:{withinRole:[],crossRole:[]},credentials:[],instructions:'Prior role'}; -function setup(t, scanDefinition) { +function setup(t, scanDefinition, barrier) { const root=tmp();t.after(()=>rmSync(root,{recursive:true,force:true}));const rolesDir=rolesCopy(root),doc=businessDoc(root); doc.plugins={harness:['pi']}; doc.definitions={scout:{rev:3,role:{tools:role.tools,network:role.network,authority:role.authority,credentials:[]},instructions:role.instructions}}; doc.roles.scout={definition:'org:scout',vars:{harness:'pi',model:'claude-opus-5-5'}};doc.launch.instances.push('scout'); writeJson(join(root,'businesses/acme.json'),doc); - const writer=organizationWriter({dir:root,rolesDir}),service=hireDecisions({writer,rolesDir,configDir:root,dataRoot:join(root,'data'),assertClean:()=>{},scanDefinition}); + const writer=organizationWriter({dir:root,rolesDir,barrier}),service=hireDecisions({writer,rolesDir,configDir:root,dataRoot:join(root,'data'),assertClean:()=>{},scanDefinition}); let stopped=0,revoked=0;const instances=instanceSettings({dir:root,rolesDir,writer,revoke:()=>{revoked++;}}); const prepare=async request=>{const p=await service.prepare('acme',Object.fromEntries(Object.entries({op:'change',level:'org',name:'scout',role:{inline:{...role,instructions:'Changed role'}},route:{harness:'pi',model:'claude-opus-5-5'},reason:'Test',...request}).filter(([,v])=>v!==undefined)),{id:randomUUID(),expiresAt:'2099-01-01T00:00:00Z'});return p;}; const caller=p=>({agent:false,rights:['org.hire'],owner:true,instanceOwner:true,actor:'jason',digest:p.digest,subject:{provider:'test',iss:'test',sub:'owner'}}); @@ -53,3 +53,13 @@ test('AU inverse scans prior definition against current held credentials before await assert.rejects(f.reverse(p),{code:'definition-holds-credential'}); assert.equal(f.stopped(),0);assert.equal(f.writer.read('acme').revision,before); }); + +test('AU inverse crossing its window during candidate IO never publishes or recovers forward',async t=>{ + let reversing=false,expired=false; + const f=setup(t,undefined,point=>{if(reversing && point==='prepared')expired=true;}); + const p=await f.prepare({});await f.service.apply('acme',p.id,f.caller(p)); + const prior=f.writer.read('acme');reversing=true; + await assert.rejects(f.service.reverse('acme',p.id,{actor:'jason',beforeWrite(){if(expired)throw Object.assign(new Error('reverse-window-closed'),{code:'reverse-window-closed'});},stop:async()=>({running:true})}),{code:'reverse-window-closed'}); + assert.equal(f.writer.read('acme').revision,prior.revision); + f.writer.recover('acme');assert.equal(f.writer.read('acme').revision,prior.revision); +}); diff --git a/packages/organization/tests/autonomy.test.mjs b/packages/organization/tests/autonomy.test.mjs index 6e2ff6c34..556a34ad2 100644 --- a/packages/organization/tests/autonomy.test.mjs +++ b/packages/organization/tests/autonomy.test.mjs @@ -29,3 +29,17 @@ test('AU setting validates in the organization file; every writer refuses non-ow const writer=organizationWriter({dir:root,rolesDir}),old=writer.read('acme'); for(const actor of ['coder','tester','member'])assert.throws(()=>writer.write('acme',{...doc,autonomy:{merge:'out of the loop'}},{revision:old.revision,actor}),{code:'human-required'}); }); + +import {authorityBudgetCovered} from '../src/autonomy.mjs'; +test('AU authority budgets cover all selectable models and only enforced account harnesses',()=>{ + const cap={limit:0,since:'2026-01-01T00:00:00.000Z'}; + const b={plugins:{harness:['pi'],'limit-check':['token-limit']},launch:{models:{one:{provider:'one',model:'m'},two:{provider:'two',model:'n'}},limits:{tokens:{providers:{one:cap}}}}}; + assert.equal(authorityBudgetCovered(b),false); + 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); +}); diff --git a/packages/webui/src/autonomy-http.mjs b/packages/webui/src/autonomy-http.mjs index be5315c00..ec6fe8730 100644 --- a/packages/webui/src/autonomy-http.mjs +++ b/packages/webui/src/autonomy-http.mjs @@ -24,5 +24,5 @@ export async function autonomyHTTP(req,res,{path,read,write,session,live,origin} const subject={provider:session.provider,iss:session.iss,sub:session.sub}; const verb=step==='confirm'?'decision.confirm.console':step==='reverse'?'decision.reverse.console':step==='review'?'autonomy.review.console':post?'autonomy.set.console':'autonomy.get.console'; try{return json(res,200,{...await(post?write:read)(verb,args,{subject}),writable:!!write});} - catch(e){const allowed=['human-required','invalid-request','invalid-autonomy','policy-reverse-unavailable','autonomy-precondition','revision-conflict','decision-not-eligible','decision-stale','decision-reversed','reverse-window-closed','reversal-unavailable','not-resolver','gate-missing','gate-failed','base-conflict','not-configured'];const code=allowed.includes(e?.code)?e.code:'outcome-unknown';return json(res,code==='human-required'?403:code==='invalid-request'||code==='invalid-autonomy'?400:409,{error:code});} + catch(e){const allowed=['authority-limits-unavailable','decision-closed','confirmation-required','not-found','storage-refused','human-required','invalid-request','invalid-autonomy','policy-reverse-unavailable','autonomy-precondition','revision-conflict','decision-not-eligible','decision-stale','decision-reversed','reverse-window-closed','reversal-unavailable','not-resolver','gate-missing','gate-failed','base-conflict','not-configured'];const code=allowed.includes(e?.code)?e.code:'outcome-unknown';return json(res,code==='human-required'?403:code==='invalid-request'||code==='invalid-autonomy'?400:409,{error:code});} } diff --git a/packages/webui/src/public/autonomy.js b/packages/webui/src/public/autonomy.js index 17f04fd40..8e82be842 100644 --- a/packages/webui/src/public/autonomy.js +++ b/packages/webui/src/public/autonomy.js @@ -7,7 +7,12 @@ const states=new Map(),path=(org,step='')=>`${org?`/api/orgs/${encodeURIComponent(org)}`:'/api'}/autonomy${step?`/${step}`:''}`; const guidance={ 'policy-reverse-unavailable':'Policy has no effect executor or reverse yet. Choose in the loop or out of the loop.', - 'autonomy-precondition':'This move needs a separate resolver, a tested reverse, owner notices and the class prerequisites. Out of the loop also needs time near the loop and your review of its trail.', + 'autonomy-precondition':'This move needs a separate resolver, a tested reverse, owner notices and an enforced budget covering new agents for authority. Out of the loop needs an actual near-loop decision followed by your review of its trail. If a reverse stopped an agent, inspect the trail before relaunching it.', + 'authority-limits-unavailable':'Choose near the loop with a budget covering new agents. Authority out of the loop waits for per-definition agent limits.', + 'decision-closed':'This decision is closed. Refresh the inbox; raise a new decision if needed.', + 'confirmation-required':'Confirm the arbiter resolution in the Console before using the decision.', + 'not-found':'This decision could not be found. Refresh the inbox.', + 'storage-refused':'The trail write was refused. Inspect the decision trail before retrying.', 'reverse-window-closed':'The reverse window has closed. Raise an ordinary new decision.', 'revision-conflict':'The organization changed. Reload before saving.', 'not-resolver':'The resolver no longer holds the required right. Withdraw and raise a fresh decision under current policy.', @@ -53,7 +58,7 @@ const review=button.dataset.autonomyReview,reverse=button.hasAttribute('data-autonomy-reverse'); const body=review?{decisionClass:review}:reverse?{id:section.dataset.autonomyDecision}:{id:section.dataset.autonomyDecision,confirm:button.dataset.autonomyConfirm==='yes',...(button.dataset.autonomyConfirm==='no'?{reason:section.querySelector('[data-autonomy-reason]').value}:{})}; button.disabled=true; - try{await call(org,review?'review':reverse?'reverse':'confirm',body);status.textContent=review?'Trail review recorded.':reverse?'Reversal recorded.':'Confirmation recorded. Refresh the inbox for current state.';} + try{const r=await call(org,review?'review':reverse?'reverse':'confirm',body);status.textContent=review?'Trail review recorded.':reverse?(r.restart?'Reversal committed. Reload or restart the organization and inspect the trail before relaunching the agent.':'Reversal recorded.'):(body.confirm?'Confirmation recorded. Refresh the inbox for current state.':'Returned to arbiter. Refresh the inbox for current state.');} catch(error){status.textContent=error.message;}finally{button.disabled=false;} }); window.MosaicAutonomy={load,section,notice}; diff --git a/packages/webui/tests/autonomy-browser.test.mjs b/packages/webui/tests/autonomy-browser.test.mjs index ff55fac98..2126390be 100644 --- a/packages/webui/tests/autonomy-browser.test.mjs +++ b/packages/webui/tests/autonomy-browser.test.mjs @@ -2,23 +2,27 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {mkdirSync} from 'node:fs'; import {join} from 'node:path'; +import {tmpdir} from 'node:os'; import {startServer} from '../src/serve.mjs'; import {createDevOwner} from '../src/dev-owner.mjs'; import {browser} from './browser.mjs'; import {close} from './fixture.mjs'; -const shots='/mnt/storage/scratch/mosaic-lead/reports/org-au-shots'; -const waitFor=(b,expression)=>b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error('autonomy view unavailable '+document.querySelector('[data-autonomy]')?.innerText)})()`); +const shots=join(tmpdir(),'autonomy-shots'); +const waitFor=(b,expression)=>b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error('autonomy view unavailable '+document.body.innerText)})()`); for(const [width,height]of [[1440,900],[390,844]])for(const theme of ['light','dark'])test(`AU owner settings ${width}x${height} ${theme}`,{timeout:60000},async t=>{ - let writes=0,confirms=0,reverses=0,setting={merge:'out of the loop'},revision='a'.repeat(64); + let writes=0,confirms=0,declines=0,reverses=0,setting={merge:'out of the loop'},revision='a'.repeat(64); const call=async(verb,args)=>{ if(verb==='autonomy.get.console')return {revision,autonomy:setting,levels:{'cross-role':'out of the loop',merge:setting.merge,policy:'out of the loop',authority:'in the loop'}}; if(verb==='autonomy.set.console'){assert.equal(args.revision,revision);writes++;setting=args.autonomy;revision='b'.repeat(64);return {revision,transaction:'10000000-0000-4000-8000-000000000001'};} - if(verb==='decision.confirm.console'){confirms++;assert.equal(args.confirm,true);return {op:'confirmed'};} + if(verb==='decision.confirm.console'){if(args.confirm)confirms++;else{declines++;assert.equal(args.reason,'Revise the plan');}return {op:args.confirm?'confirmed':'declined'};} if(verb==='decision.reverse.console'){reverses++;return {reversed:true};} if(verb==='system-types.console')return {installed:[],enabled:[],project:null}; throw Object.assign(new Error('not-configured'),{code:'not-configured'}); }; - const server=await startServer({port:0,sso:createDevOwner(),send:async()=>({}),organizations:{list:async()=>[{id:'acme',label:'Acme',busHealthy:true}],resolve:async()=>({busHealthy:true,requestRead:call,requestWrite:call,send:async()=>({}),reads:{business:async()=>({business:{id:'acme',projects:[],instances:[]}}),settings:async()=>({}),queue:async()=>({rows:[]})},bus:{instances:async()=>({instances:{}}),agents:async()=>[],'thread.claims':async()=>[],tasks:async()=>[],inbox:async()=>[],messages:async()=>({messages:[]})},chat:{projects:async()=>[],threads:async()=>[]}})},log:()=>{}}); + const ids=['10000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000002','10000000-0000-4000-8000-000000000003']; + const rows=ids.map((id,i)=>({id,business:'acme',at:'2026-11-11T12:00:00.000Z',class:'gated',action:'git.merge.protected',route_to:'cto',raised_by_role:'coder',raised_by_run:'coder-run',question:'Land the reviewed change?',options:[{key:'yes',text:'Land'},{key:'no',text:'Revise'}],recommendation:'yes',blocking:false,autonomy:i===2?{notice:true,reverseUntil:'2099-01-01T00:00:00Z'}:{pending:true}})); + const trail=async id=>[{...rows.find(r=>r.id===id),table:'decisions'},{table:'events',kind:'action.allowed',body:{operation:'decision.raise',decision:id,approvalChoice:'yes',target:'vikunja:1/2'}}]; + const server=await startServer({port:0,sso:createDevOwner(),send:async()=>({}),organizations:{list:async()=>[{id:'acme',label:'Acme',busHealthy:true}],resolve:async()=>({busHealthy:true,decisionWrite:async()=>({}),requestRead:call,requestWrite:call,send:async()=>({}),reads:{business:async()=>({business:{id:'acme',projects:[],instances:[]}}),settings:async()=>({}),queue:async()=>({rows:[]})},bus:{instances:async()=>({instances:{}}),agents:async()=>[],'thread.claims':async()=>[],tasks:async()=>[],inbox:async()=>rows,trail,messages:async()=>({messages:[]})},chat:{projects:async()=>[],threads:async()=>[]}})},log:()=>{}}); t.after(()=>close(server));const b=await browser({machine:false});t.after(()=>b.close());await b.viewport(width,height); await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/acme/organization`); await waitFor(b,`document.querySelector('[data-autonomy-form]')`); @@ -28,10 +32,23 @@ for(const [width,height]of [[1440,900],[390,844]])for(const theme of ['light','d await b.evaluate(`document.querySelector('[data-autonomy-form] select[name=merge]').value='in the loop';document.querySelector('[data-autonomy-form]').requestSubmit();true`); await waitFor(b,`/Saved/.test(document.querySelector('[data-autonomy-status]').textContent)`);assert.equal(writes,1);assert.equal(setting.merge,'in the loop'); mkdirSync(shots,{recursive:true});await b.evaluate(`document.querySelector('[data-autonomy]').scrollIntoView();true`);await b.screenshot(join(shots,`autonomy-${width}-${theme}.png`)); - await b.evaluate(`document.querySelector('[data-autonomy]').innerHTML=MosaicAutonomy.notice({id:'decision-1',business:'acme',autonomy:{pending:true}});document.querySelector('[data-autonomy-confirm=yes]').click();true`); + const detail=async(id,selector)=>{ + await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/acme/inbox/${id}`); + await waitFor(b,`document.querySelector(${JSON.stringify(selector)})`); + await b.evaluate(`document.documentElement.dataset.mode=${JSON.stringify(theme)};document.querySelector('[data-autonomy-decision]').scrollIntoView();true`); + assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`),true); + assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-decision]').dataset.autonomyDecision`),id); + }; + await detail(ids[0],'[data-autonomy-confirm=yes]'); + await b.evaluate(`document.querySelector('[data-autonomy-confirm=yes]').click();true`); await waitFor(b,`/Confirmation recorded/.test(document.querySelector('[data-autonomy-status]').textContent)`);assert.equal(confirms,1); - assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`),true);await b.screenshot(join(shots,`confirm-${width}-${theme}.png`)); - await b.evaluate(`document.querySelector('[data-autonomy]').innerHTML=MosaicAutonomy.notice({id:'decision-2',business:'acme',autonomy:{notice:true,reverseUntil:'2099-01-01T00:00:00Z'}});document.querySelector('[data-autonomy-reverse]').click();true`); + await b.screenshot(join(shots,`confirm-${width}-${theme}.png`)); + await detail(ids[1],'[data-autonomy-confirm=no]'); + await b.evaluate(`document.querySelector('[data-autonomy-reason]').value='Revise the plan';document.querySelector('[data-autonomy-confirm=no]').click();true`); + await waitFor(b,`/Returned to arbiter/.test(document.querySelector('[data-autonomy-status]').textContent)`);assert.equal(declines,1); + await b.screenshot(join(shots,`decline-${width}-${theme}.png`)); + await detail(ids[2],'[data-autonomy-reverse]'); + await b.evaluate(`document.querySelector('[data-autonomy-reverse]').click();true`); await waitFor(b,`/Reversal recorded/.test(document.querySelector('[data-autonomy-status]').textContent)`);assert.equal(reverses,1); - assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`),true);await b.screenshot(join(shots,`reverse-${width}-${theme}.png`)); + await b.screenshot(join(shots,`reverse-${width}-${theme}.png`)); }); diff --git a/packages/webui/tests/autonomy-http.test.mjs b/packages/webui/tests/autonomy-http.test.mjs index 655dca628..6a80112be 100644 --- a/packages/webui/tests/autonomy-http.test.mjs +++ b/packages/webui/tests/autonomy-http.test.mjs @@ -3,10 +3,10 @@ import assert from 'node:assert/strict'; import {Readable} from 'node:stream'; import {autonomyHTTP} from '../src/autonomy-http.mjs'; const owner={provider:'test',iss:'https://issuer.test',sub:'owner'}; -async function request({method='POST',path='/api/autonomy',body={revision:'a'.repeat(64),autonomy:{merge:'in the loop'}},headers={},session=owner,writable=true,live=()=>session}={}) { +async function request({method='POST',path='/api/autonomy',body={revision:'a'.repeat(64),autonomy:{merge:'in the loop'}},headers={},session=owner,writable=true,live=()=>session,error=null}={}) { const req=Readable.from([Buffer.from(JSON.stringify(body))]);req.method=method;req.headersDistinct={origin:['http://console.test'],'sec-fetch-site':['same-origin'],'content-type':['application/json'],...headers}; let status,result,calls=[];const res={writeHead:s=>status=s,end:text=>result=JSON.parse(text)}; - const invoke=async(...args)=>{calls.push(args);return {revision:'b'.repeat(64)};}; + const invoke=async(...args)=>{calls.push(args);if(error)throw Object.assign(new Error('unsafe detail'),{code:error});return {revision:'b'.repeat(64)};}; await autonomyHTTP(req,res,{path,session,live,read:invoke,write:writable?invoke:null,origin:'http://console.test'}); return {status,result,calls}; } @@ -26,3 +26,9 @@ test('AU HTTP sign-in, cross-origin and stale session refuse without bus effects test('AU HTTP refuses caller-selected identity or organization',async()=>{ for(const extra of [{actor:'owner'},{business:'other'},{subject:owner}]){const r=await request({body:{revision:'a'.repeat(64),autonomy:{},...extra}});assert.equal(r.status,400);assert.deepEqual(r.calls,[]);} }); + +test('AU HTTP preserves named state and authority refusals without raw details',async()=>{ + for(const code of ['authority-limits-unavailable','decision-closed','confirmation-required','not-found','storage-refused']){ + const r=await request({error:code});assert.equal(r.status,409);assert.deepEqual(r.result,{error:code}); + } +}); -- 2.54.0 From 2d4b07e5162f57e842a0b33f6e0e8b60812c5f84 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 11 Nov 2026 10:00:18 -0600 Subject: [PATCH 3/4] fix(org): retain committed inverse status through completion IO failure (#1873) --- packages/bus/src/autonomy-host.mjs | 6 ++-- packages/bus/src/broker.mjs | 5 +++- .../bus/tests/autonomy-authority.test.mjs | 30 +++++++++++++++++++ packages/organization/README.md | 2 +- packages/webui/src/public/autonomy.js | 2 +- 5 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/bus/src/autonomy-host.mjs b/packages/bus/src/autonomy-host.mjs index 58d1b98c9..3e6e6a80b 100644 --- a/packages/bus/src/autonomy-host.mjs +++ b/packages/bus/src/autonomy-host.mjs @@ -19,15 +19,15 @@ export function autonomyHost({writer,dataRoot,business,hires,reload,processing,e if(!hires?.reverse)refuse('reversal-unavailable'); let stopped; const result=await hires.reverse(id,decision,{actor,beforeWrite,beforePublish,fire,stop:async p=>{const r=await stop(p);stopped=p;return r;},relaunch,record}); - if(stopped)complete?.(stopped,result); // The writer admitted publication within the window. Reload and relaunch // complete that committed inverse; crossing the deadline here cannot undo it. - const receipt={committed:true,transaction:result.transaction??null,relaunch:result.relaunch?'pending':'not-needed'}; + const receipt={committed:true,transaction:result.transaction??null,completion:stopped?'recorded':'not-needed',relaunch:result.relaunch?'pending':'not-needed'}; + try {if(stopped)complete?.(stopped,result);} catch {receipt.completion='failed';receipt.restart=true;} try { if(reload(id)===false)receipt.restart=true; if(result.relaunch && !receipt.restart){await relaunch(result.relaunch);receipt.relaunch='requested';} } catch {receipt.restart=true;if(result.relaunch)receipt.relaunch='failed';} - record(receipt);return receipt; + return receipt; } const used=effects.filter(e=>e.operation!=='decision.raise' && !String(e.operation??'').startsWith('autonomy.')); // An unused approval has changed no task or policy. Reversing it revokes that permission. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 1bb558cf0..7b64da2ea 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -6445,6 +6445,7 @@ export class Broker { windowOpen(); this.#event(s,'action.allowed',{operation:'autonomy.reverse.consume',decision:d.id,subject:s.console},d.id); }); + let committedResult=null; try { if(before && after) return this.#store.transaction(()=>{ windowOpen(); @@ -6463,9 +6464,11 @@ export class Broker { this.#mayHuman(s,'instance.launch',instance);this.#launchable(s,instance,'not-thread-launch'); return this.#store.transaction(()=>({request:this.#lifecycleRecord(s,'lifecycle.launch',instance,{decision:d.id})})); },record:result=>this.#store.transaction(()=>{if(!(autonomyClass(d.action)==='authority' && result.committed===true))windowOpen();recorded=true;this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,result},d.id);})}); - if(!recorded)this.#store.transaction(()=>{windowOpen();this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,result},d.id);}); + if(autonomyClass(d.action)==='authority' && result.committed===true)committedResult=result; + if(!recorded)this.#store.transaction(()=>{if(!committedResult)windowOpen();this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,result},d.id);}); return {id:d.id,reversed:true,...result}; } catch(error) { + if(committedResult)return {id:d.id,reversed:true,...committedResult,trailPending:true}; this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.reverse.failed',decision:d.id,code:error.code??'outcome-unknown'},d.id)); throw error; } diff --git a/packages/bus/tests/autonomy-authority.test.mjs b/packages/bus/tests/autonomy-authority.test.mjs index 905ebee74..e0db934f8 100644 --- a/packages/bus/tests/autonomy-authority.test.mjs +++ b/packages/bus/tests/autonomy-authority.test.mjs @@ -77,3 +77,33 @@ test('AU authority hire reverse runs normal fire/revoke and records committed de const r=await f.reverse(d.id);assert.equal(r.committed,true);assert.ok(!f.writer.read('demo').document.launch.instances.includes('helper'));assert.deepEqual(f.order,['fire-revoke']); assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1); }); + +test('AU completion-event failure after durable authority inverse records committed recovery, never reverse.failed',async t=>{ + const f=world(t),d=await f.raise();await f.call('judge','decision.resolve',{id:d.id,choice:'yes'}); + const run=f.store.run.bind(f.store);let injected=false; + f.store.run=(sql,...args)=>{ + if(!injected && sql.includes('INSERT INTO events') && args.some(x=>typeof x==='string' && x.startsWith('{') && JSON.parse(x).action==='fire.done')){ + injected=true;throw new Error('injected completion event failure'); + } + return run(sql,...args); + }; + const r=await f.reverse(d.id);assert.equal(injected,true);assert.equal(r.committed,true);assert.equal(r.completion,'failed');assert.equal(r.restart,true);assert.equal(r.relaunch,'pending'); + assert.equal(f.writer.read('demo').document.definitions.scout.instructions,'Prior role'); + assert.equal(f.writer.read('demo').document.definitions.scout.rev,5); + assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1); + assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse.failed'").n,0); +}); + +test('AU inverse receipt-write failure returns committed with trailPending and never writes reverse.failed',async t=>{ + const f=world(t),d=await f.raise();await f.call('judge','decision.resolve',{id:d.id,choice:'yes'}); + const run=f.store.run.bind(f.store);let injected=false; + f.store.run=(sql,...args)=>{ + if(sql.includes('INSERT INTO events') && args.some(x=>typeof x==='string' && x.startsWith('{') && JSON.parse(x).operation==='autonomy.reverse')){ + injected=true;throw new Error('injected receipt write failure'); + } + return run(sql,...args); + }; + const r=await f.reverse(d.id);assert.equal(injected,true);assert.equal(r.committed,true);assert.equal(r.trailPending,true);assert.equal(r.relaunch,'requested'); + 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); +}); diff --git a/packages/organization/README.md b/packages/organization/README.md index 2460496f4..b08f0ee97 100644 --- a/packages/organization/README.md +++ b/packages/organization/README.md @@ -647,7 +647,7 @@ resolver, inverse, notices and review/limit prerequisites. Moving from near to out also requires an actual near-loop resolution and a later owner trail review. Authority near requires an enabled limit check and an aggregate quota covering every selectable model (or the shared account when every enabled harness uses it). Empty budgets and existing-instance caps do not cover future hires. Authority out refuses `authority-limits-unavailable` until per-definition limits exist. A task inverse restores its prior stored fields if no later edit intervened; a merge inverse lands a gated revert commit. Hire/fire/change inverses reuse the existing lifecycle and writer paths, -never restore credential bindings, and refuse stale targets. Authority inverses check the window immediately before file publication. After a durable commit, reload or relaunch failures are reported as recovery metadata, with the inverse recorded as committed. A refusal after stop/revoke leaves the agent stopped; inspect its trail before relaunching. External task +never restore credential bindings, and refuse stale targets. Authority inverses check the window immediately before file publication. After a durable commit, completion-event, reload or relaunch failures are reported as recovery metadata. The inverse is recorded as committed; if that trail write itself fails, the response says `trailPending` rather than recording a false reversal failure. A refusal after stop/revoke leaves the agent stopped; inspect its trail before relaunching. External task adapters without host-owned inverse evidence refuse near-loop consumption. After the pinned window, use an ordinary new decision. diff --git a/packages/webui/src/public/autonomy.js b/packages/webui/src/public/autonomy.js index 8e82be842..ce0d276de 100644 --- a/packages/webui/src/public/autonomy.js +++ b/packages/webui/src/public/autonomy.js @@ -58,7 +58,7 @@ const review=button.dataset.autonomyReview,reverse=button.hasAttribute('data-autonomy-reverse'); const body=review?{decisionClass:review}:reverse?{id:section.dataset.autonomyDecision}:{id:section.dataset.autonomyDecision,confirm:button.dataset.autonomyConfirm==='yes',...(button.dataset.autonomyConfirm==='no'?{reason:section.querySelector('[data-autonomy-reason]').value}:{})}; button.disabled=true; - try{const r=await call(org,review?'review':reverse?'reverse':'confirm',body);status.textContent=review?'Trail review recorded.':reverse?(r.restart?'Reversal committed. Reload or restart the organization and inspect the trail before relaunching the agent.':'Reversal recorded.'):(body.confirm?'Confirmation recorded. Refresh the inbox for current state.':'Returned to arbiter. Refresh the inbox for current state.');} + try{const r=await call(org,review?'review':reverse?'reverse':'confirm',body);status.textContent=review?'Trail review recorded.':reverse?(r.restart||r.trailPending?'Reversal committed. Completion needs recovery: inspect the decision trail and the stopped agent before requesting relaunch.':'Reversal recorded.'):(body.confirm?'Confirmation recorded. Refresh the inbox for current state.':'Returned to arbiter. Refresh the inbox for current state.');} catch(error){status.textContent=error.message;}finally{button.disabled=false;} }); window.MosaicAutonomy={load,section,notice}; -- 2.54.0 From 88a94ddd3409d0c77dda1830a3e911fdb85b8d61 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 11 Nov 2026 10:18:52 -0600 Subject: [PATCH 4/4] test(bus): pin the autonomy budget helper import (#1873) --- packages/bus/tests/project-runtime.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bus/tests/project-runtime.test.mjs b/packages/bus/tests/project-runtime.test.mjs index 19cb68a10..95dc6f5ae 100644 --- a/packages/bus/tests/project-runtime.test.mjs +++ b/packages/bus/tests/project-runtime.test.mjs @@ -13,7 +13,7 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8'); assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [ "import { autonomyEvent, decisionResolution } from './autonomy-state.mjs';", - "import { autonomyClass, checkAutonomy, decisionAutonomy, resolverSubject } from '../../organization/src/autonomy.mjs';", + "import { autonomyClass, authorityBudgetCovered, checkAutonomy, decisionAutonomy, resolverSubject } from '../../organization/src/autonomy.mjs';", // Rows330/331: pure verb/refusal vocabulary, never the host management implementation. "import { ownRails } from '../../organization/src/rails.mjs';", "import { refusalGuidance } from './refusals.mjs';", -- 2.54.0