From c8805753215f19fac89da2ff66fe8e49a59cee4e Mon Sep 17 00:00:00 2001 From: Sage Hart - AI Date: Sun, 11 Oct 2026 20:53:48 -0500 Subject: [PATCH] Keep refunds settled until a person decides, and re-check depth on reversal. L4 restarts need a 2-of-3 approver quorum, handoffs carry delegation depth, and compensation uses that depth. Co-authored-by: Cursor --- apps/api/src/app.ts | 6 +- packages/core/src/company-day.ts | 81 +++++---- packages/core/src/company.ts | 76 ++++++++- packages/core/src/db.ts | 2 + packages/core/src/elicitation.ts | 8 +- packages/core/src/gateway.ts | 114 ++++++++++--- packages/core/src/orchestrator.ts | 2 +- packages/core/src/policy.ts | 20 ++- packages/core/src/schema.ts | 1 + test/company-day.test.ts | 274 ++++++++++++++++++++++-------- test/governance-r3.test.ts | 87 +++++++++- test/governance.test.ts | 2 +- 12 files changed, 526 insertions(+), 147 deletions(-) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 3ca68f7..2c9ed73 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -183,10 +183,10 @@ export function buildApp(company: Company, mode: "simulation" | "live" = "simula }); app.post("/api/company-day", async (c) => { + if (!requireAuth(c)) return c.json({ error: "dashboard authentication required" }, 401); await expireExceptionTtl(company); - // The JSON body cannot settle the restart. autoApproveException is ignored - // for every caller, including an unauthenticated one. Approve, Reject, or - // Kill is what moves a queued restart. + // Dashboard auth is required. The JSON body cannot settle the restart: + // autoApproveException is ignored. Approve, Reject, or Kill moves the queue. const { result } = await runCompanyDay({ company }); return c.json(result); }); diff --git a/packages/core/src/company-day.ts b/packages/core/src/company-day.ts index 2c1affb..05868d9 100644 --- a/packages/core/src/company-day.ts +++ b/packages/core/src/company-day.ts @@ -30,6 +30,8 @@ export interface CompanyDayResult { contractId: string; handoffs: number; autonomousSettles: number; + /** Human-queued irreversible actions (restart) still waiting on Approve, Reject, or Kill. */ + awaitingExceptions: number; exceptionSettles: number; compensated: number; trustAfter: number; @@ -40,6 +42,12 @@ export interface CompanyDayResult { providerMode: "simulation" | "live"; } +function draftSettleOk(step: unknown): boolean { + if (typeof step !== "object" || !step || !("draftSettled" in step)) return false; + const result = (step as { result?: { ok?: boolean } }).result; + return result?.ok === true; +} + function countHandoffs(steps: unknown[]): number { return steps.filter( (s) => @@ -50,15 +58,23 @@ function countHandoffs(steps: unknown[]): number { async function latestHandoff( company: Company, contractId: string, -): Promise<{ toAgent: string; obligation: string; from: string } | null> { +): Promise<{ toAgent: string; obligation: string; from: string; depth: number } | null> { const ctr = (await company.db.select().from(contracts).where(eq(contracts.id, contractId)))[0]; if (!ctr) return null; const obligations = JSON.parse(ctr.obligations || "[]") as Array<{ toAgent: string; obligation: string; from: string; + depth?: number; }>; - return obligations.length ? obligations[obligations.length - 1]! : null; + const last = obligations.length ? obligations[obligations.length - 1]! : null; + if (!last) return null; + return { + toAgent: last.toAgent, + obligation: last.obligation, + from: last.from, + depth: typeof last.depth === "number" ? last.depth : 0, + }; } async function enqueueAgentTask( @@ -68,6 +84,7 @@ async function enqueueAgentTask( agentId: string; title: string; description: string; + delegationDepth: number; }, ): Promise { const taskId = newId("task"); @@ -80,6 +97,7 @@ async function enqueueAgentTask( state: "queued", assignedTo: opts.agentId, attempts: 0, + delegationDepth: opts.delegationDepth, createdAt: now(), }); return taskId; @@ -218,9 +236,10 @@ export async function runCompanyDay(opts?: { agentId: "agent_finance", title: "Settle refund", description: finHandoff?.obligation ?? "Settle the refund obligation and hand off to ops.", + delegationDepth: finHandoff?.depth ?? 0, }); const finance = await company.orchestrator.enqueueAndRun(financeTaskId); - if (finance.steps.some((s) => typeof s === "object" && s && "draftSettled" in s)) { + if (finance.steps.some(draftSettleOk)) { autonomousSettles++; push({ agentId: "agent_finance", @@ -246,16 +265,11 @@ export async function runCompanyDay(opts?: { agentId: "agent_ops", title: "Confirm service health", description: opsHandoff?.obligation ?? "Restart billing-api if needed.", + delegationDepth: opsHandoff?.depth ?? 0, }); - let opsPromise: Promise<{ - ok: boolean; - awaitingExceptionId?: string; - steps: unknown[]; - }>; - if (autoApprove) { - opsPromise = company.orchestrator.enqueueAndRun(opsTaskId); + const opsPromise = company.orchestrator.enqueueAndRun(opsTaskId); for (let i = 0; i < 200; i++) { const pending = (await company.db.select().from(exceptions)).find( (e) => e.state === "pending" && e.taskId === opsTaskId, @@ -267,21 +281,29 @@ export async function runCompanyDay(opts?: { kind: "exception", summary: "Restart requires human approval — exception queued.", }); - await decideException(company, pending.id, "approved", "carol@corpos.local"); - exceptionSettles++; - push({ - agentId: "agent_ops", - role: "Ops", - kind: "exception", - summary: "Demo auto-approved restart (explicit opt-in); billing-api restarted.", - }); - await company.trust.recordAccept("agent_support"); - push({ - agentId: "agent_support", - role: "Support", - kind: "trust", - summary: "Clean accepts raised Support maxAutonomousRisk.", - }); + const quorum = company.policy.getQuorumConfig(pending.riskLevel); + const voters = ["alice@corpos.local", "bob@corpos.local", "carol@corpos.local"]; + const needed = quorum ? quorum.n : 1; + let decided: { executed?: boolean } = {}; + for (const voter of voters.slice(0, needed)) { + decided = await decideException(company, pending.id, "approved", voter); + } + if (decided.executed) { + exceptionSettles++; + push({ + agentId: "agent_ops", + role: "Ops", + kind: "exception", + summary: "Demo auto-approved restart (explicit opt-in); billing-api restarted.", + }); + await company.trust.recordAccept("agent_support"); + push({ + agentId: "agent_support", + role: "Support", + kind: "trust", + summary: "Clean accepts raised Support maxAutonomousRisk.", + }); + } break; } await new Promise((r) => setTimeout(r, 5)); @@ -296,13 +318,11 @@ export async function runCompanyDay(opts?: { kind: "exception", summary: "Restart requires human approval — exception queued.", }); - void (await company.db - .select() - .from(exceptions) - .where(eq(exceptions.id, ops.awaitingExceptionId))); } } - + const awaitingExceptions = (await company.db.select().from(exceptions)).filter( + (e) => e.contractId === contractId && e.state === "pending" && e.tool === "ops.restart_service", + ).length; // Success leaves the settled refund in place. Explicit gateway.compensate() // may still reverse it; this script does not. const compensated = 0; @@ -347,6 +367,7 @@ export async function runCompanyDay(opts?: { contractId, handoffs, autonomousSettles, + awaitingExceptions, exceptionSettles, compensated, trustAfter, diff --git a/packages/core/src/company.ts b/packages/core/src/company.ts index 3e384ac..0ef9311 100644 --- a/packages/core/src/company.ts +++ b/packages/core/src/company.ts @@ -373,6 +373,26 @@ export async function recordTransparency( }); } +function approverLabels(raw: string): string[] { + try { + const parsed = JSON.parse(raw || "[]") as unknown; + return Array.isArray(parsed) + ? parsed.filter((item): item is string => typeof item === "string") + : []; + } catch { + return []; + } +} + +/** NIST SP 800-53 AC-5: a quorum vote counts only from the designated role, and the eligible pool must be at least M. */ +function holdsQuorumRole(agent: { role: string; approverRoles: string }, role: string): boolean { + return agent.role === role || approverLabels(agent.approverRoles).includes(role); +} + +function voterIs(agent: { id: string; owner: string; principal: string }, by: string): boolean { + return agent.id === by || agent.owner === by || agent.principal === by; +} + export async function decideException( company: Company, exceptionId: string, @@ -393,7 +413,31 @@ export async function decideException( const quorum = company.policy.getQuorumConfig(ex.riskLevel); if (decision === "approved" && quorum) { + const roster = (await company.db.select().from(agents)).filter( + (agent) => agent.active === 1 && holdsQuorumRole(agent, quorum.role), + ); + if (roster.length < quorum.m || roster.length < quorum.n) { + await company.audit.append("exception.quorum_pool", { + exceptionId, + role: quorum.role, + pool: roster.length, + need: quorum.n, + of: quorum.m, + }); + return { quorumPending: true, votes: 0 }; + } + const voter = roster.find((agent) => voterIs(agent, by)); const votes = JSON.parse(ex.votesJson || "[]") as Array<{ by: string; at: string }>; + if (!voter) { + await company.audit.append("exception.quorum_rejected_voter", { + exceptionId, + by, + role: quorum.role, + votes: votes.length, + need: quorum.n, + }); + return { quorumPending: true, votes: votes.length }; + } if (!votes.some((v) => v.by === by)) { votes.push({ by, at: now() }); } @@ -407,6 +451,7 @@ export async function decideException( by, votes: votes.length, need: quorum.n, + of: quorum.m, }); return { quorumPending: true, votes: votes.length }; } @@ -440,23 +485,38 @@ export async function decideException( publishFirmEvent(`exception.${decision}`, { exceptionId, by }); if (decision === "approved" && ex.pauseJson) { - const tool = company.gateway.get(ex.tool); - if (tool) { - await tool.execute(JSON.parse(ex.argsJson), { + const pause = JSON.parse(ex.pauseJson) as { depth?: unknown; delegatedBy?: unknown }; + const gated = await company.gateway.executeApproved( + ex.tool, + JSON.parse(ex.argsJson) as Record, + { agentId: ex.agentId, taskId: ex.taskId, contractId: ex.contractId, tenantId: ex.tenantId, originatingAuthority: by, - delegationDepth: 0, - }); + delegationDepth: typeof pause.depth === "number" ? pause.depth : 0, + delegatedBy: typeof pause.delegatedBy === "string" ? pause.delegatedBy : undefined, + }, + ); + if (gated.decision.effect === "deny" || !gated.result?.ok) { await company.db .update(tasks) - .set({ state: "done", finishedAt: now() }) + .set({ + state: "failed", + finishedAt: now(), + error: gated.decision.reason, + }) .where(eq(tasks.id, ex.taskId)); - company.orchestrator?.resume(ex.taskId, true); - return { resumed: true, executed: true }; + company.orchestrator?.resume(ex.taskId, false); + return { resumed: true, executed: false }; } + await company.db + .update(tasks) + .set({ state: "done", finishedAt: now() }) + .where(eq(tasks.id, ex.taskId)); + company.orchestrator?.resume(ex.taskId, true); + return { resumed: true, executed: true }; } else if (decision === "rejected") { await company.db .update(tasks) diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index c9e4601..d3a1e45 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -60,6 +60,7 @@ async function migrate(client: Client): Promise { state TEXT NOT NULL, assigned_to TEXT, attempts INTEGER NOT NULL DEFAULT 0, + delegation_depth INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, started_at TEXT, finished_at TEXT, @@ -168,6 +169,7 @@ async function migrate(client: Client): Promise { "ALTER TABLE exceptions ADD COLUMN appeal_used INTEGER NOT NULL DEFAULT 0", "ALTER TABLE exceptions ADD COLUMN appealed_at TEXT", "ALTER TABLE exceptions ADD COLUMN votes_json TEXT NOT NULL DEFAULT '[]'", + "ALTER TABLE tasks ADD COLUMN delegation_depth INTEGER NOT NULL DEFAULT 0", ]) { try { await client.execute(sql); diff --git a/packages/core/src/elicitation.ts b/packages/core/src/elicitation.ts index 3a44fcc..d2c3984 100644 --- a/packages/core/src/elicitation.ts +++ b/packages/core/src/elicitation.ts @@ -21,13 +21,19 @@ export async function elicitViaException( if (!ex) return { ok: false, error: "exception not found" }; if (opts?.autoDecide) { - await decideException( + const result = await decideException( company, exceptionId, opts.autoDecide, opts.by ?? "elicitation", opts.autoDecide === "rejected" ? "elicitation reject" : undefined, ); + if (result.quorumPending) { + return { ok: false, error: "quorum pending" }; + } + if (opts.autoDecide === "approved" && !result.executed) { + return { ok: false, error: "approval did not execute" }; + } return { ok: true, decision: opts.autoDecide }; } diff --git a/packages/core/src/gateway.ts b/packages/core/src/gateway.ts index d939320..6c23b67 100644 --- a/packages/core/src/gateway.ts +++ b/packages/core/src/gateway.ts @@ -4,7 +4,15 @@ import type { AuditLog } from "./audit.js"; import type { PolicyEngine } from "./policy.js"; import type { Tool, ToolContext, ToolResult, PolicyDecision } from "./types.js"; import type { ToolMap } from "./tools.js"; -import { compensators, controlState, departments, drafts, spendLedger, agents } from "./schema.js"; +import { + agents, + compensators, + controlState, + departments, + drafts, + spendLedger, + tasks, +} from "./schema.js"; import { newId } from "./id.js"; import { now } from "./types.js"; import { endSpan, startSpan } from "./otel.js"; @@ -54,13 +62,7 @@ export class ToolGateway { endSpan(span, { decisionId: decision.decisionId }); if (decision.effect === "deny") { - const agent = (await this.db.select().from(agents).where(eq(agents.id, ctx.agentId)))[0]; - if (agent) { - await this.db - .update(agents) - .set({ violations: agent.violations + 1 }) - .where(eq(agents.id, agent.id)); - } + await this.recordViolation(ctx.agentId); return { decision }; } if (decision.effect === "approve") { @@ -91,11 +93,32 @@ export class ToolGateway { return { decision, result: await this.execute(name, args, ctx) }; } + /** + * PEP for an already-approved exception. Re-checks policy (kill switch, + * authz, capital cap) and executes only when that check does not deny. + */ + async executeApproved( + name: string, + args: Record, + ctx: ToolContext, + ): Promise { + return this.enforce(name, args, ctx); + } + async settleDraft(draftId: string, ctx: ToolContext): Promise { const draft = (await this.db.select().from(drafts).where(eq(drafts.id, draftId)))[0]; if (!draft || draft.state !== "staged") return { ok: false, error: "draft not found" }; const args = JSON.parse(draft.argsJson) as Record; - const result = await this.execute(draft.tool, args, ctx); + const gated = await this.enforce(draft.tool, args, ctx); + if (gated.decision.effect === "deny" || !gated.result) { + await this.audit.append("draft.settle_denied", { + draftId, + tool: draft.tool, + reason: gated.decision.reason, + }); + return { ok: false, error: gated.decision.reason }; + } + const result = gated.result; await this.db .update(drafts) .set({ state: "settled", settledAt: now() }) @@ -125,21 +148,32 @@ export class ToolGateway { args: Record; result: ToolResult; }; - const tool = this.tools.get(c.kind); - if (!tool) continue; - await tool.execute( - { - ...payload.args, - refundId: (payload.result.data as { refundId?: string } | undefined)?.refundId, - amount: payload.args.amount, - }, - { - agentId: "system", - taskId: "compensate", - contractId, - tenantId: "default", - }, - ); + const execArgs = { + ...payload.args, + refundId: (payload.result.data as { refundId?: string } | undefined)?.refundId, + amount: payload.args.amount, + }; + const draft = (await this.db.select().from(drafts).where(eq(drafts.id, c.draftId)))[0]; + const task = draft?.taskId + ? (await this.db.select().from(tasks).where(eq(tasks.id, draft.taskId)))[0] + : undefined; + const delegationDepth = typeof task?.delegationDepth === "number" ? task.delegationDepth : 0; + const ctx: ToolContext = { + agentId: draft?.agentId ?? "agent_finance", + taskId: draft?.taskId ?? "compensate", + contractId, + tenantId: draft?.tenantId ?? "default", + delegationDepth, + }; + const gated = await this.enforce(c.kind, execArgs, ctx); + if (gated.decision.effect === "deny" || !gated.result) { + await this.audit.append("compensate.denied", { + compensatorId: c.id, + kind: c.kind, + reason: gated.decision.reason, + }); + continue; + } await this.db .update(compensators) .set({ state: "run", runAt: now() }) @@ -168,6 +202,38 @@ export class ToolGateway { await this.audit.append("kill.switch", { killed }); } + /** Second PDP check. Deny skips execution; allow/draft/approve from the ladder does not. */ + private async enforce( + name: string, + args: Record, + ctx: ToolContext, + ): Promise { + const tool = this.tools.get(name); + const decision = await this.policy.evaluate(tool, args, ctx, "enforce"); + await this.audit.append("gateway.decision", { + tool: name, + effect: decision.effect, + reason: decision.reason, + decisionId: decision.decisionId, + authzLayer: decision.authzLayer, + phase: "enforce", + }); + if (decision.effect === "deny") { + await this.recordViolation(ctx.agentId); + return { decision }; + } + return { decision, result: await this.execute(name, args, ctx) }; + } + + private async recordViolation(agentId: string): Promise { + const agent = (await this.db.select().from(agents).where(eq(agents.id, agentId)))[0]; + if (!agent) return; + await this.db + .update(agents) + .set({ violations: agent.violations + 1 }) + .where(eq(agents.id, agent.id)); + } + private async execute( name: string, args: Record, diff --git a/packages/core/src/orchestrator.ts b/packages/core/src/orchestrator.ts index 887acea..b276585 100644 --- a/packages/core/src/orchestrator.ts +++ b/packages/core/src/orchestrator.ts @@ -118,7 +118,7 @@ export class Orchestrator { systemPrompt: `You are ${agent.role} for CorpOS.`, userPrompt: task.description, originatingAuthority: agent.owner, - delegationDepth: 0, + delegationDepth: task.delegationDepth ?? 0, }); endSpan(span, { diff --git a/packages/core/src/policy.ts b/packages/core/src/policy.ts index 325090d..e1a9ee5 100644 --- a/packages/core/src/policy.ts +++ b/packages/core/src/policy.ts @@ -40,8 +40,8 @@ export type QuorumConfig = { n: number; m: number; role: string }; export class PolicyEngine { private rules: PolicyRule[] = []; private mode: EnforcementMode = "strict"; - /** riskLevel → quorum (G3). Default: L4+ requires 2-of-3 approvers when enabled. */ - private quorumByRisk = new Map(); + /** riskLevel → quorum (G3). L4+ defaults to 2-of-3 approvers (NIST SP 800-53 AC-5). */ + private quorumByRisk = new Map([[4, { n: 2, m: 3, role: "approver" }]]); constructor( private db: Db, @@ -87,6 +87,13 @@ export class PolicyEngine { tool: Tool | undefined, args: Record, ctx: ToolContext, + /** + * `propose` is the first PDP check (may stage a draft or queue HITL). + * `enforce` is the PEP re-check immediately before execution: kill switch, + * authz, deny rules, and capital cap still apply, but a prior draft or + * human approval is not turned into another draft or exception. + */ + phase: "propose" | "enforce" = "propose", ): Promise { const ctrl = ( await this.db.select().from(controlState).where(eq(controlState.id, "global")) @@ -173,6 +180,15 @@ export class PolicyEngine { } } + if (phase === "enforce") { + return { + effect: "allow", + reason: "enforcement re-check allow", + decisionId: authz.decisionId, + authzLayer: authz.layer, + }; + } + const effect = matched?.effect ?? ladderEffect(tool.permission.riskLevel, maxAuto, tool.permission.requiresApproval); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 7b4b1a6..67871d2 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -46,6 +46,7 @@ export const tasks = sqliteTable("tasks", { state: text("state").notNull(), assignedTo: text("assigned_to"), attempts: integer("attempts").notNull().default(0), + delegationDepth: integer("delegation_depth").notNull().default(0), createdAt: text("created_at").notNull(), startedAt: text("started_at"), finishedAt: text("finished_at"), diff --git a/test/company-day.test.ts b/test/company-day.test.ts index 7e94162..237b0b2 100644 --- a/test/company-day.test.ts +++ b/test/company-day.test.ts @@ -1,31 +1,47 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { buildApp } from "@corpos/api"; import { + agents, computeMaxAutonomousRisk, + contracts, counterfactualReplay, createCompany, + departments, exceptions, runCompanyDay, + tasks, } from "@corpos/core"; +import { eq } from "drizzle-orm"; describe("company day", () => { - it("runs multi-handoff day with autonomy, exception, sla, trust", async () => { - const { company, result } = await runCompanyDay({ - dbPath: ":memory:", - autoApproveException: true, - }); + const envKeys = ["DASHBOARD_API_TOKEN", "CORPOS_ALLOW_UNAUTHENTICATED"] as const; + const saved: Partial> = {}; + let envStashed = false; + + afterEach(() => { + if (!envStashed) return; + for (const key of envKeys) { + const value = saved[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + envStashed = false; + }); + + it("leaves the refund settled and queues the restart for a human", async () => { + const { company, result } = await runCompanyDay({ dbPath: ":memory:" }); expect(result.handoffs).toBeGreaterThanOrEqual(2); expect(result.autonomousSettles).toBeGreaterThanOrEqual(1); - expect(result.exceptionSettles).toBeGreaterThanOrEqual(1); + expect(result.awaitingExceptions).toBeGreaterThanOrEqual(1); + expect(result.exceptionSettles).toBe(0); expect(result.compensated).toBe(0); - expect(company.seed.refunds).toEqual([ - expect.objectContaining({ amount: 49, customer: "ada@example.com" }), - ]); expect(result.slaExceptions).toBeGreaterThanOrEqual(1); - expect(result.trustAfter).toBeGreaterThanOrEqual(2); expect(result.ok).toBe(true); + expect( + company.seed.refunds.some((r) => r.amount === 49 && r.customer === "ada@example.com"), + ).toBe(true); expect(result.timeline.length).toBeGreaterThanOrEqual(6); expect(result.timeline[0]?.kind).toBe("intake"); const kinds = result.timeline.map((e) => e.kind); @@ -33,84 +49,36 @@ describe("company day", () => { expect(kinds).toContain("autonomous_settle"); expect(kinds).toContain("exception"); expect(kinds).toContain("trust"); + expect(kinds).not.toContain("compensate"); const verify = await company.audit.verify(); expect(verify.ok).toBe(true); company.close(); }); - it("is ok when the refund stays and the restart is pending", async () => { - const { company, result } = await runCompanyDay({ dbPath: ":memory:" }); - expect(result.exceptionSettles).toBe(0); + it("settles an explicitly auto-approved restart and does not reverse the refund", async () => { + const { company, result } = await runCompanyDay({ + dbPath: ":memory:", + autoApproveException: true, + }); + expect(result.exceptionSettles).toBeGreaterThanOrEqual(1); + expect(result.awaitingExceptions).toBe(0); expect(result.compensated).toBe(0); - expect(result.timeline.some((e) => e.summary.includes("Human approved"))).toBe(false); - expect(result.timeline.some((e) => e.summary.includes("Demo auto-approved"))).toBe(false); - expect(company.seed.refunds).toEqual([ - expect.objectContaining({ amount: 49, customer: "ada@example.com" }), - ]); - const restarts = (await company.db.select().from(exceptions)).filter( - (row) => row.contractId === result.contractId && row.tool === "ops.restart_service", - ); - expect(restarts).toHaveLength(1); - expect(restarts[0]?.state).toBe("pending"); expect(result.ok).toBe(true); + expect(company.seed.refunds.some((r) => r.amount === 49)).toBe(true); + expect(result.timeline.some((e) => e.summary.includes("Demo auto-approved"))).toBe(true); company.close(); }); - it("leaves a successful refund in place", async () => { + it("does not auto-approve exceptions by default", async () => { const { company, result } = await runCompanyDay({ dbPath: ":memory:" }); - expect(result.autonomousSettles).toBeGreaterThanOrEqual(1); + expect(result.exceptionSettles).toBe(0); expect(result.compensated).toBe(0); - expect(result.timeline.some((e) => e.kind === "compensate")).toBe(false); - expect(company.seed.refunds).toEqual([ - expect.objectContaining({ id: "ref_1", amount: 49, customer: "ada@example.com" }), - ]); - const reversed = await company.gateway.compensate(result.contractId); - expect(reversed).toBe(1); - expect(company.seed.refunds).toEqual([]); + expect(result.ok).toBe(true); + expect(result.timeline.some((e) => e.summary.includes("Human approved"))).toBe(false); + expect(result.timeline.some((e) => e.summary.includes("Demo auto-approved"))).toBe(false); company.close(); }); - it("does not let an unauthenticated company-day call auto-approve", async () => { - const previous = { - token: process.env.DASHBOARD_API_TOKEN, - allow: process.env.CORPOS_ALLOW_UNAUTHENTICATED, - }; - delete process.env.DASHBOARD_API_TOKEN; - delete process.env.CORPOS_ALLOW_UNAUTHENTICATED; - try { - const company = await createCompany({ dbPath: ":memory:" }); - const app = buildApp(company, "simulation"); - const res = await app.request("/api/company-day", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ autoApproveException: true }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { - exceptionSettles: number; - compensated: number; - timeline: Array<{ summary: string }>; - }; - expect(body.exceptionSettles).toBe(0); - expect(body.compensated).toBe(0); - expect(body.timeline.some((e) => e.summary.includes("Demo auto-approved"))).toBe(false); - const restarts = (await company.db.select().from(exceptions)).filter( - (row) => row.tool === "ops.restart_service", - ); - expect(restarts.length).toBe(1); - expect(restarts[0]?.state).toBe("pending"); - expect(company.seed.refunds).toEqual([ - expect.objectContaining({ amount: 49, customer: "ada@example.com" }), - ]); - company.close(); - } finally { - if (previous.token === undefined) delete process.env.DASHBOARD_API_TOKEN; - else process.env.DASHBOARD_API_TOKEN = previous.token; - if (previous.allow === undefined) delete process.env.CORPOS_ALLOW_UNAUTHENTICATED; - else process.env.CORPOS_ALLOW_UNAUTHENTICATED = previous.allow; - } - }); - it("fail-closed unknown tool and kill switch", async () => { const company = await createCompany({ dbPath: ":memory:" }); const denied = await company.gateway.invoke( @@ -151,6 +119,164 @@ describe("company day", () => { company.close(); }); + it("re-checks policy before settle and compensate", async () => { + const company = await createCompany({ dbPath: ":memory:" }); + const ctx = { + agentId: "agent_finance", + taskId: "t", + contractId: "c_refund", + tenantId: "default", + originatingAuthority: "bob@corpos.local", + }; + const drafted = await company.gateway.invoke( + "billing.issue_refund", + { subscription: "sub_ada_pro", amount: 49, customer: "ada@example.com" }, + ctx, + ); + expect(drafted.decision.effect).toBe("draft"); + expect(drafted.draftId).toBeTruthy(); + + await company.gateway.setKilled(true); + const killedSettle = await company.gateway.settleDraft(drafted.draftId!, ctx); + expect(killedSettle.ok).toBe(false); + expect(company.seed.refunds).toHaveLength(0); + + await company.gateway.setKilled(false); + await company.db + .update(departments) + .set({ capitalSpent: 1990 }) + .where(eq(departments.id, "finance")); + const overCap = await company.gateway.settleDraft(drafted.draftId!, ctx); + expect(overCap.ok).toBe(false); + expect(company.seed.refunds).toHaveLength(0); + + await company.db + .update(departments) + .set({ capitalSpent: 0 }) + .where(eq(departments.id, "finance")); + const settled = await company.gateway.settleDraft(drafted.draftId!, ctx); + expect(settled.ok).toBe(true); + expect(company.seed.refunds).toHaveLength(1); + + company.policy.setRules([ + { tool: "billing.reverse_refund", effect: "deny", reason: "no reverse" }, + ]); + const denied = await company.gateway.compensate("c_refund"); + expect(denied).toBe(0); + expect(company.seed.refunds).toHaveLength(1); + + company.policy.setRules([]); + await company.gateway.setKilled(true); + const killedCompensate = await company.gateway.compensate("c_refund"); + expect(killedCompensate).toBe(0); + expect(company.seed.refunds).toHaveLength(1); + company.close(); + }); + + it("requires auth and ignores client autoApproveException", async () => { + for (const key of envKeys) saved[key] = process.env[key]; + envStashed = true; + delete process.env.CORPOS_ALLOW_UNAUTHENTICATED; + process.env.DASHBOARD_API_TOKEN = "secret"; + + const company = await createCompany({ dbPath: ":memory:" }); + const app = buildApp(company, "simulation"); + const unauth = await app.request("/api/company-day", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ autoApproveException: true }), + }); + expect(unauth.status).toBe(401); + expect(company.seed.refunds).toHaveLength(0); + + const authed = await app.request("/api/company-day", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer secret", + }, + body: JSON.stringify({ autoApproveException: true }), + }); + expect(authed.status).toBe(200); + const body = (await authed.json()) as { + ok: boolean; + compensated: number; + exceptionSettles: number; + awaitingExceptions: number; + autonomousSettles: number; + }; + expect(body.exceptionSettles).toBe(0); + expect(body.compensated).toBe(0); + expect(body.awaitingExceptions).toBeGreaterThanOrEqual(1); + expect(body.autonomousSettles).toBeGreaterThanOrEqual(1); + expect(body.ok).toBe(true); + expect(company.seed.refunds.some((r) => r.amount === 49)).toBe(true); + company.close(); + }); + + it("carries handoff depth into the next agent and the later L2 check", async () => { + const { company, result } = await runCompanyDay({ dbPath: ":memory:" }); + const contract = ( + await company.db.select().from(contracts).where(eq(contracts.id, result.contractId)) + )[0]; + const obligations = JSON.parse(contract?.obligations || "[]") as Array<{ depth?: number }>; + expect(obligations.map((obligation) => obligation.depth)).toEqual([1, 2]); + + const dayTasks = (await company.db.select().from(tasks)).filter( + (task) => task.contractId === result.contractId, + ); + expect(dayTasks.find((task) => task.assignedTo === "agent_finance")?.delegationDepth).toBe(1); + expect(dayTasks.find((task) => task.assignedTo === "agent_ops")?.delegationDepth).toBe(2); + + const pendingRestart = (await company.db.select().from(exceptions)).find( + (row) => row.state === "pending" && row.tool === "ops.restart_service", + ); + const pause = JSON.parse(pendingRestart?.pauseJson || "{}") as { depth?: number }; + expect(pause.depth).toBe(2); + + const support = ( + await company.db.select().from(agents).where(eq(agents.id, "agent_support")) + )[0]; + const denied = await company.gateway.invoke( + "crm.lookup", + { email: "ada@example.com" }, + { + agentId: support?.id ?? "agent_support", + taskId: "t_depth", + contractId: result.contractId, + tenantId: "default", + delegationDepth: 4, + }, + ); + expect(denied.decision.effect).toBe("deny"); + expect(denied.decision.authzLayer).toBe("L2"); + expect(denied.decision.reason).toMatch(/depth/i); + expect(result.ok).toBe(true); + company.close(); + }); + + it("reverses a settled refund at the finance depth and denies past the L2 cap", async () => { + const allowed = await runCompanyDay({ dbPath: ":memory:" }); + const reversed = await allowed.company.gateway.compensate(allowed.result.contractId); + expect(reversed).toBe(1); + expect(allowed.company.seed.refunds.some((r) => r.amount === 49)).toBe(false); + + const capped = await runCompanyDay({ dbPath: ":memory:" }); + const financeTask = (await capped.company.db.select().from(tasks)).find( + (task) => task.contractId === capped.result.contractId && task.assignedTo === "agent_finance", + ); + await capped.company.db + .update(tasks) + .set({ delegationDepth: 4 }) + .where(eq(tasks.id, financeTask!.id)); + const denied = await capped.company.gateway.compensate(capped.result.contractId); + expect(denied).toBe(0); + expect(capped.company.seed.refunds.some((r) => r.amount === 49)).toBe(true); + + allowed.company.close(); + capped.company.close(); + }); + it("knowledge path via real MCP subprocess", async () => { const server = path.resolve( path.dirname(fileURLToPath(import.meta.url)), diff --git a/test/governance-r3.test.ts b/test/governance-r3.test.ts index d0dec77..09bbd05 100644 --- a/test/governance-r3.test.ts +++ b/test/governance-r3.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { appealException, + auditChain, createCompany, decideException, deliberationEntries, @@ -12,7 +13,7 @@ import { transparencyRecords, } from "@corpos/core"; import { eq } from "drizzle-orm"; -import { exceptions, tasks } from "@corpos/core"; +import { agents, exceptions, tasks } from "@corpos/core"; describe("r3 firm governance", () => { it("quorum policy test: L4+ requires N-of-M approvers (R-FIRM-005)", async () => { @@ -48,6 +49,42 @@ describe("r3 firm governance", () => { company.close(); }); + it("ignores a voter outside the quorum role and refuses a pool smaller than M", async () => { + const company = await createCompany({ dbPath: ":memory:" }); + company.policy.setQuorumForRisk(4, { n: 2, m: 3, role: "approver" }); + const provider = new SimulationProvider({ + "Ops Agent": [[tc("ops.restart_service", { service: "billing-api" })]], + }); + company.orchestrator = new Orchestrator(company, { provider, awaitHitl: false }); + await company.db.insert(tasks).values({ + id: "task_role", + contractId: "c_role", + tenantId: "default", + title: "restart", + description: "restart", + state: "queued", + assignedTo: "agent_ops", + attempts: 0, + createdAt: new Date().toISOString(), + }); + const out = await company.orchestrator.enqueueAndRun("task_role"); + const exId = out.awaitingExceptionId!; + const stranger = await decideException(company, exId, "approved", "mallory@example.com"); + expect(stranger.quorumPending).toBe(true); + expect(stranger.votes).toBe(0); + const pending = (await company.db.select().from(exceptions).where(eq(exceptions.id, exId)))[0]; + expect(pending?.state).toBe("pending"); + expect(JSON.parse(pending?.votesJson || "[]")).toEqual([]); + + await company.db.update(agents).set({ active: 0 }).where(eq(agents.id, "agent_finance")); + const shrunk = await decideException(company, exId, "approved", "alice@corpos.local"); + expect(shrunk.quorumPending).toBe(true); + expect(shrunk.executed).toBeFalsy(); + const still = (await company.db.select().from(exceptions).where(eq(exceptions.id, exId)))[0]; + expect(still?.state).toBe("pending"); + company.close(); + }); + it("deliberation trail on exception open/decide (G2)", async () => { const company = await createCompany({ dbPath: ":memory:" }); const provider = new SimulationProvider({ @@ -98,6 +135,7 @@ describe("r3 firm governance", () => { createdAt: new Date().toISOString(), }); const out = await company.orchestrator.enqueueAndRun("task_t"); + await decideException(company, out.awaitingExceptionId!, "approved", "alice@corpos.local"); await decideException(company, out.awaitingExceptionId!, "approved", "carol@corpos.local"); const tr = await company.db.select().from(transparencyRecords); expect(tr.length).toBeGreaterThanOrEqual(1); @@ -172,6 +210,44 @@ describe("r3 firm governance", () => { company.close(); }); + it("approved exception still passes the PEP kill switch", async () => { + const company = await createCompany({ dbPath: ":memory:" }); + const provider = new SimulationProvider({ + "Ops Agent": [[tc("ops.restart_service", { service: "billing-api" })]], + }); + company.orchestrator = new Orchestrator(company, { provider, awaitHitl: false }); + await company.db.insert(tasks).values({ + id: "task_kill", + contractId: "c_kill", + tenantId: "default", + title: "restart", + description: "restart", + state: "queued", + assignedTo: "agent_ops", + attempts: 0, + createdAt: new Date().toISOString(), + }); + const out = await company.orchestrator.enqueueAndRun("task_kill"); + expect(out.awaitingExceptionId).toBeTruthy(); + await company.gateway.setKilled(true); + await decideException(company, out.awaitingExceptionId!, "approved", "alice@corpos.local"); + const decided = await decideException( + company, + out.awaitingExceptionId!, + "approved", + "carol@corpos.local", + ); + expect(decided.executed).toBe(false); + const rows = await company.db.select().from(auditChain); + const ran = rows.filter( + (r) => r.eventType === "tool.execute" && r.payloadJson.includes("ops.restart_service"), + ); + expect(ran).toHaveLength(0); + const task = (await company.db.select().from(tasks).where(eq(tasks.id, "task_kill")))[0]; + expect(task?.state).toBe("failed"); + company.close(); + }); + it("orchestrator waitForResume on HITL (R-WORK-005)", async () => { const company = await createCompany({ dbPath: ":memory:" }); const provider = new SimulationProvider({ @@ -195,6 +271,7 @@ describe("r3 firm governance", () => { (e) => e.state === "pending", ); if (pending) { + await decideException(company, pending.id, "approved", "alice@corpos.local"); await decideException(company, pending.id, "approved", "carol@corpos.local"); break; } @@ -227,8 +304,12 @@ describe("r3 firm governance", () => { autoDecide: "approved", by: "elicit", }); - expect(elicited.ok).toBe(true); - expect(elicited.decision).toBe("approved"); + expect(elicited.ok).toBe(false); + expect(elicited.error).toBe("quorum pending"); + const still = (await company.db.select().from(exceptions)).find( + (row) => row.id === out.awaitingExceptionId, + ); + expect(still?.state).toBe("pending"); company.close(); }); }); diff --git a/test/governance.test.ts b/test/governance.test.ts index 60c090e..8e0a4ec 100644 --- a/test/governance.test.ts +++ b/test/governance.test.ts @@ -65,7 +65,7 @@ describe("governance", () => { it("company day on shared store mutates firm panels see", async () => { resetSpans(); const company = await createCompany({ dbPath: ":memory:" }); - const { result } = await runCompanyDay({ company, autoApproveException: true }); + const { result } = await runCompanyDay({ company }); expect(result.ok).toBe(true); expect(result.handoffs).toBeGreaterThanOrEqual(2); const rows = await company.db.select().from(contracts);