From 0789470dc1d65b3dd993367b3f10fcbb1ae9b2f0 Mon Sep 17 00:00:00 2001 From: Sage Hart - AI Date: Sun, 11 Oct 2026 08:21:48 -0500 Subject: [PATCH] Leave the scripted refund in place and keep the restart queued until a decision. --- README.md | 16 ++----- apps/api/src/app.ts | 12 ++--- packages/core/src/company-day.ts | 28 +++++++----- test/company-day.test.ts | 76 ++++++++++++++++++++++++++++++-- 4 files changed, 99 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 47d64e2..1f120ae 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # CorpOS -> **Company-day simulation** of a firm — work contracts, PDP/PEP policy gates, and humans who Approve / Reject / Kill. Not an autonomous-company SaaS, and not LangGraph, CrewAI, or any multi-agent orchestration framework. +One scripted company day. Support hands a $49 refund to Finance. Finance settles `billing.issue_refund` through the tool gateway and hands the work to Ops. Ops queues `ops.restart_service` for billing-api. The refund stays on the ledger. The restart stays queued until Approve, Reject, or Kill. -> **Permit rule:** Approve / Reject / Kill is the closer. Default actors are scripted; live LLMs are optional. This is not a live EHS, health, or finance workforce. Same instinct: [SafetyMP](https://github.com/SafetyMP/SafetyMP). +`POST /api/company-day` runs that day. It does not settle the restart, and it does not honor `autoApproveException` from the JSON body. An unauthenticated caller cannot auto-approve. [![CI](https://github.com/SafetyMP/CorpOS/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/SafetyMP/CorpOS/actions/workflows/ci.yml) [![CodeQL](https://github.com/SafetyMP/CorpOS/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/SafetyMP/CorpOS/actions/workflows/codeql.yml) @@ -10,14 +10,6 @@ [![License: Apache-2.0](https://img.shields.io/github/license/SafetyMP/CorpOS)](LICENSE) [![Node](https://img.shields.io/badge/node-%E2%89%A522-339933?logo=node.js&logoColor=white)](#quick-start) -CorpOS simulates a company day: department actors settle work contracts under a policy gate, and **humans govern by exception** (Approve / Reject / Kill in the ops console). Autonomy is earned from evidence, not granted in prompts. Interop protocols (MCP) are transport; firm-side governance includes **G1 membership** and **G4 dissent** today, with a broader G1–G6 crosswalk in the docs. Positioning: [`docs/DESIGN-PIVOT.md`](docs/DESIGN-PIVOT.md). - -> **Scope:** Reference architecture and runnable demo — **not** a production-hardened SaaS. See [SECURITY.md](SECURITY.md). - -Default mode is **simulation** (`SimulationProvider`) for deterministic CI. Live LLM (`HttpLLMProvider`) is an optional actor inside the sim only when `CORPOS_ALLOW_LIVE=1` and `OPENROUTER_API_KEY` are set — `/api/health.mode` never lies. G1–G6 firm governance, orchestrator-driven day, TTL scheduler, console Bearer (shared mode), and live `/api/events` SSE are implemented. - -Read the thesis: [`docs/future-of-the-firm.md`](docs/future-of-the-firm.md). Governance crosswalk: [`docs/governance-crosswalk.md`](docs/governance-crosswalk.md). AIBOM: [`docs/aibom.json`](docs/aibom.json). Docs index: [`docs/README.md`](docs/README.md). - **Jump to:** [Demo](#demo) · [Quick start](#quick-start) · [Architecture](#architecture) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) · [ADRs](docs/adr/README.md) --- @@ -37,7 +29,7 @@ Read the thesis: [`docs/future-of-the-firm.md`](docs/future-of-the-firm.md). Gov - **Local demo:** `npm install && npm run build && npm run dev` → [http://localhost:3000](http://localhost:3000) - **Regenerate GIF/PNGs:** start the console, then `npm run screenshots` — see [`docs/assets/README.md`](docs/assets/README.md) -Click **Run company day** to watch the agent activity timeline: support → finance → ops handoffs, an autonomous settle, an exception approval path, compensation, and trust unlock. The console sends `autoApproveException: false` so the exception stays in the HITL queue for Approve/Reject. +Click **Run company day** to watch the activity timeline: support → finance → ops. The refund stays in place. The service restart stays queued until Approve, Reject, or Kill. --- @@ -53,7 +45,7 @@ Contributors and CI should use `npm ci --include=dev` (see [CONTRIBUTING.md](CON ```bash npm test -npm run scenario # HITL default-off; exits non-zero when exception is not auto-settled +npm run scenario # default day exits 0: refund stays, restart stays queued npm run audit:verify ./scripts/harness/verify.sh # functional / static acceptance ./scripts/harness/adversarial.sh # authorized local adversarial probes (CI also runs this) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ed90bb4..3ca68f7 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -184,14 +184,10 @@ export function buildApp(company: Company, mode: "simulation" | "live" = "simula app.post("/api/company-day", async (c) => { await expireExceptionTtl(company); - let autoApproveException = false; - try { - const body = await c.req.json<{ autoApproveException?: boolean }>(); - if (body.autoApproveException === true) autoApproveException = true; - } catch { - /* empty body */ - } - const { result } = await runCompanyDay({ company, autoApproveException }); + // 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. + 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 699403a..2c1affb 100644 --- a/packages/core/src/company-day.ts +++ b/packages/core/src/company-day.ts @@ -303,15 +303,9 @@ export async function runCompanyDay(opts?: { } } - const compensated = await company.gateway.compensate(contractId); - if (compensated > 0) { - push({ - agentId: "agent_finance", - role: "Finance", - kind: "compensate", - summary: `Compensated ${compensated} settled action(s); ledger restored.`, - }); - } + // Success leaves the settled refund in place. Explicit gateway.compensate() + // may still reverse it; this script does not. + const compensated = 0; const short = await openContract(company, { title: "Expiring SLA", @@ -334,6 +328,20 @@ export async function runCompanyDay(opts?: { } const trustAfter = (await company.trust.get("agent_support"))?.maxAutonomousRisk ?? 0; + const refundHeld = company.seed.refunds.some( + (refund) => refund.amount === 49 && refund.customer === "ada@example.com", + ); + const restartPending = (await company.db.select().from(exceptions)).some( + (row) => + row.contractId === contractId && + row.tool === "ops.restart_service" && + row.state === "pending", + ); + // The scripted day succeeds when the refund is still on the ledger and the + // restart is waiting for Approve, Reject, or Kill. Settling that restart is + // an explicit opt-in, not a requirement for ok. + const dayComplete = handoffs >= 2 && autonomousSettles >= 1 && compensated === 0 && refundHeld; + const restartDisposition = autoApprove ? exceptionSettles >= 1 : restartPending; const result: CompanyDayResult = { contractId, @@ -344,7 +352,7 @@ export async function runCompanyDay(opts?: { trustAfter, slaExceptions, auditHead: await company.audit.head(), - ok: handoffs >= 2 && autonomousSettles >= 1 && exceptionSettles >= 1 && trustAfter >= 2, + ok: dayComplete && restartDisposition, timeline, providerMode, }; diff --git a/test/company-day.test.ts b/test/company-day.test.ts index 763c7e5..7e94162 100644 --- a/test/company-day.test.ts +++ b/test/company-day.test.ts @@ -1,15 +1,17 @@ import { describe, expect, it } from "vitest"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { buildApp } from "@corpos/api"; import { computeMaxAutonomousRisk, counterfactualReplay, createCompany, + exceptions, runCompanyDay, } from "@corpos/core"; describe("company day", () => { - it("runs multi-handoff day with autonomy, exception, compensation, sla, trust", async () => { + it("runs multi-handoff day with autonomy, exception, sla, trust", async () => { const { company, result } = await runCompanyDay({ dbPath: ":memory:", autoApproveException: true, @@ -17,7 +19,10 @@ describe("company day", () => { expect(result.handoffs).toBeGreaterThanOrEqual(2); expect(result.autonomousSettles).toBeGreaterThanOrEqual(1); expect(result.exceptionSettles).toBeGreaterThanOrEqual(1); - expect(result.compensated).toBeGreaterThanOrEqual(1); + 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); @@ -33,14 +38,79 @@ describe("company day", () => { company.close(); }); - it("does not auto-approve exceptions by default", async () => { + 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); + 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); company.close(); }); + it("leaves a successful refund in place", async () => { + const { company, result } = await runCompanyDay({ dbPath: ":memory:" }); + expect(result.autonomousSettles).toBeGreaterThanOrEqual(1); + 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([]); + 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(