Skip to content
This repository was archived by the owner on Oct 27, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
81 changes: 51 additions & 30 deletions packages/core/src/company-day.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) =>
Expand All @@ -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(
Expand All @@ -68,6 +84,7 @@ async function enqueueAgentTask(
agentId: string;
title: string;
description: string;
delegationDepth: number;
},
): Promise<string> {
const taskId = newId("task");
Expand All @@ -80,6 +97,7 @@ async function enqueueAgentTask(
state: "queued",
assignedTo: opts.agentId,
attempts: 0,
delegationDepth: opts.delegationDepth,
createdAt: now(),
});
return taskId;
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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));
Expand All @@ -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;
Expand Down Expand Up @@ -347,6 +367,7 @@ export async function runCompanyDay(opts?: {
contractId,
handoffs,
autonomousSettles,
awaitingExceptions,
exceptionSettles,
compensated,
trustAfter,
Expand Down
76 changes: 68 additions & 8 deletions packages/core/src/company.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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() });
}
Expand All @@ -407,6 +451,7 @@ export async function decideException(
by,
votes: votes.length,
need: quorum.n,
of: quorum.m,
});
return { quorumPending: true, votes: votes.length };
}
Expand Down Expand Up @@ -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<string, unknown>,
{
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)
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ async function migrate(client: Client): Promise<void> {
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,
Expand Down Expand Up @@ -168,6 +169,7 @@ async function migrate(client: Client): Promise<void> {
"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);
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/elicitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down
Loading
Loading