diff --git a/packages/amico-run/src/ledger.ts b/packages/amico-run/src/ledger.ts index 4878961..d71f951 100644 --- a/packages/amico-run/src/ledger.ts +++ b/packages/amico-run/src/ledger.ts @@ -267,6 +267,22 @@ export interface PlanCompiledRecord { /** One ADVISORY todo transition. `open` is the absence of a row; multiple rows per id * resolve last-ts-wins. No `actor` field — no trustworthy actor identity exists here. */ +/** GPU/compute accounting for a remote run (#425, GPU-plane spec): pure + * spend record — no fidelity, never feeds priors. The campaign warrant fold + * sums these (unit-keyed bounds). Emitted by the remote executor at settle. */ +export interface ReceiptRecord { + type: "receipt"; + ts: string; + task_id: string; + executor: "remote"; + gpu_sku?: string; + gpu_seconds?: number; + cost_usd?: number; + problem?: string; + session?: string; + status?: "completed" | "failed" | "aborted"; +} + export interface TodoRecord { type: "todo"; ts: string; @@ -279,6 +295,7 @@ export interface TodoRecord { export type LedgerRecord = | SolveRecord + | ReceiptRecord | VerdictRecord | AttemptErrorRecord | FallbackRecord @@ -297,6 +314,49 @@ export function ledgerPath(): string { return join(studioPathsOrLegacy().ledger, "runs.jsonl"); } +/** GPU spend totals over receipt rows (#425) — the warrant fold's view. + * Pure aggregation: never throws, zeros on empty. by_sku/by_status keyed + * breakdowns; cost_usd omitted per-row when the runner didn't report it. */ +export interface GpuTotals { + receipts: number; + gpu_seconds: number; + cost_usd: number; + by_sku: Record; + by_status: Record; +} + +export function gpuTotals(file = ledgerPath()): GpuTotals { + const t: GpuTotals = { receipts: 0, gpu_seconds: 0, cost_usd: 0, by_sku: {}, by_status: {} }; + let raw: string; + try { + raw = readFileSync(file, "utf8"); + } catch { + return t; // absent ledger = zero spend, not an error + } + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + let rec: LedgerRecord; + try { + rec = JSON.parse(line) as LedgerRecord; + } catch { + continue; // a torn line never breaks accounting + } + if (rec.type !== "receipt") continue; + t.receipts += 1; + if (rec.gpu_seconds !== undefined) t.gpu_seconds += rec.gpu_seconds; + if (rec.cost_usd !== undefined) t.cost_usd += rec.cost_usd; + if (rec.gpu_sku !== undefined) { + const b = t.by_sku[rec.gpu_sku] ?? {}; + if (rec.gpu_seconds !== undefined) b.gpu_seconds = (b.gpu_seconds ?? 0) + rec.gpu_seconds; + if (rec.cost_usd !== undefined) b.cost_usd = (b.cost_usd ?? 0) + rec.cost_usd; + t.by_sku[rec.gpu_sku] = b; + } + if (rec.status !== undefined) t.by_status[rec.status] = (t.by_status[rec.status] ?? 0) + 1; + } + t.cost_usd = Math.round(t.cost_usd * 1e4) / 1e4; + return t; +} + /** Append one record as a single JSONL line. Validates against the `ledger-record` * schema (Task 2 wires this in) before writing so no malformed line ever lands. * Throws when the serialized line exceeds PIPE_BUF (would break O_APPEND diff --git a/packages/amico-run/src/ledger_verb.ts b/packages/amico-run/src/ledger_verb.ts index 29ebdcd..cc09cb6 100644 --- a/packages/amico-run/src/ledger_verb.ts +++ b/packages/amico-run/src/ledger_verb.ts @@ -44,7 +44,7 @@ // §5.1 rule 2 refuses a launch needing a bound the warrant omits — so a // helpfully-filled default would silently widen the warrant. import { readFileSync } from "node:fs"; -import { appendRecord, type ApprovalRecord, type LedgerRecord, type WarrantBounds } from "./ledger.js"; +import { appendRecord, gpuTotals, type ApprovalRecord, type LedgerRecord, type WarrantBounds } from "./ledger.js"; import { bucketN, bucketT, queryDefaults, type QueryKey } from "./ledger_query.js"; import { dispatchTable, type DispatchKey } from "./ledger_dispatch.js"; import type { VerbResult } from "./verbs.js"; @@ -68,6 +68,21 @@ function readStanza(argv: string[]): string | undefined { } // ── append ──────────────────────────────────────────────────────────────────── +export function ledgerGpu(_argv: string[]): VerbResult { + // #425: GPU spend totals over receipt rows — the warrant fold's view. + const t = gpuTotals(); + const hours = t.gpu_seconds / 3600; + const lines = [ + `receipts ${t.receipts}`, + `gpu_seconds ${t.gpu_seconds} (${hours.toFixed(2)} h)`, + `cost_usd ${t.cost_usd}`, + ]; + for (const [sku, b] of Object.entries(t.by_sku)) + lines.push(` ${sku} ${(b.gpu_seconds ?? 0)}s${b.cost_usd !== undefined ? ` · $${b.cost_usd}` : ""}`); + for (const [st, n] of Object.entries(t.by_status)) lines.push(` status ${st}: ${n}`); + return { json: { verb: "ledger", subcommand: "gpu", totals: t, table: lines.join("\n") }, code: 0 }; +} + export function ledgerAppend(argv: string[]): VerbResult { const fail = (error: string): VerbResult => ({ json: { verb: "ledger", subcommand: "append", error }, code: 64 }); @@ -241,6 +256,7 @@ export function ledgerApprove(argv: string[]): VerbResult { export function ledgerVerb(argv: string[]): VerbResult { const sub = argv[0]; const rest = argv.slice(1); + if (sub === "gpu") return ledgerGpu(rest); if (sub === "append") return ledgerAppend(rest); if (sub === "query") return ledgerQuery(rest); if (sub === "dispatch") return ledgerDispatch(rest); diff --git a/packages/amico-run/src/remote_executor.ts b/packages/amico-run/src/remote_executor.ts index a39fc27..6d08510 100644 --- a/packages/amico-run/src/remote_executor.ts +++ b/packages/amico-run/src/remote_executor.ts @@ -9,6 +9,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, utimesSync, writeFileSync } from "node:fs"; import { basename, join, resolve } from "node:path"; import { EventQueue } from "./event_queue.js"; +import { appendRecord, type ReceiptRecord } from "./ledger.js"; import { classifyLine } from "./telemetry.js"; import { appendIndex, @@ -160,6 +161,12 @@ export class RemoteExecutor implements Executor { events.push(classifyLine(line, "stdout")); }; + // #425 GPU receipt: the runner-contract fields ride the finished payload; + // stashed here, consumed at settle. Absent fields → null → no receipt row + // (nothing to account — the pre-contract runner emits none). + type GpuReceipt = { gpu_sku?: string; gpu_seconds?: number; cost_usd?: number }; + let gpuReceipt: GpuReceipt | null = null; + const settle = (status: RunStatus, exitCode: number): void => { if (settled) return; settled = true; @@ -168,6 +175,32 @@ export class RemoteExecutor implements Executor { } catch (e) { process.stderr.write(`amico-run: failed to write FINISHED: ${(e as Error).message}\n`); } + // GPU accounting (#425): the receipt row is spend, not science — + // emitted on FAILED runs too (they burn the same GPU time), never + // feeding priors (no fidelity field exists on it by design). + if (gpuReceipt) { + try { + const rec: ReceiptRecord = { + type: "receipt", + ts: new Date().toISOString(), + task_id: taskId, + executor: "remote", + ...gpuReceipt, + }; + if (status === "completed" || status === "failed" || status === "aborted") rec.status = status; + appendRecord(rec); // validates against the ledger-record schema + atomicWriteFile(runDir, "receipt.toml", [ + "# GPU receipt (runner contract #424) — mirrored from the cloud finished payload", + `task_id = "${taskId}"`, + ...(rec.gpu_sku ? [`gpu_sku = ${JSON.stringify(rec.gpu_sku)}`] : []), + ...(rec.gpu_seconds !== undefined ? [`gpu_seconds = ${rec.gpu_seconds}`] : []), + ...(rec.cost_usd !== undefined ? [`cost_usd = ${rec.cost_usd}`] : []), + `status = "${status}"`, + ].join("\n") + "\n"); + } catch (e) { + process.stderr.write(`amico-run: failed to emit GPU receipt stanza: ${(e as Error).message}\n`); + } + } events.push({ kind: "finished", status, exitCode }); events.close(); resolveFinished({ status, exitCode }); @@ -322,6 +355,16 @@ export class RemoteExecutor implements Executor { settle("failed", EXIT_INFERRED); return; } + const fin = s.finished as + | { status?: string; gpu_sku?: unknown; gpu_seconds?: unknown; cost_usd?: unknown } + | undefined; + if (fin && gpuReceipt === null) { + const sku = typeof fin.gpu_sku === "string" && fin.gpu_sku !== "" ? fin.gpu_sku : undefined; + const secs = typeof fin.gpu_seconds === "number" && Number.isFinite(fin.gpu_seconds) && fin.gpu_seconds > 0 ? fin.gpu_seconds : undefined; + const cost = typeof fin.cost_usd === "number" && Number.isFinite(fin.cost_usd) && fin.cost_usd > 0 ? fin.cost_usd : undefined; + if (sku !== undefined || secs !== undefined || cost !== undefined) + gpuReceipt = { ...(sku !== undefined ? { gpu_sku: sku } : {}), ...(secs !== undefined ? { gpu_seconds: secs } : {}), ...(cost !== undefined ? { cost_usd: cost } : {}) }; + } const f = s.finished?.status; if (f === "completed" || f === "failed" || f === "aborted") settle(f, EXIT[f]); }; diff --git a/packages/amico-run/test/fake_cloud.ts b/packages/amico-run/test/fake_cloud.ts index d8b88ba..81a22db 100644 --- a/packages/amico-run/test/fake_cloud.ts +++ b/packages/amico-run/test/fake_cloud.ts @@ -16,7 +16,14 @@ export interface FakeIter { export interface FakeState { task_status: "Pending" | "Running"; - finished?: { status: "completed" | "failed" | "aborted" }; + /** finished with the runner-contract receipt fields (#424/#425) — the + * client parses defensively; absent fields → no receipt ledger row. */ + finished?: { + status: "completed" | "failed" | "aborted"; + gpu_sku?: string; + gpu_seconds?: number; + cost_usd?: number; + }; liveness: "alive" | "gone"; iters: FakeIter[]; // Δ4 stats: full history each poll (client dedups on high-water) frame?: { iter: number; png_base64: string }; // Δ4 frames: newest only diff --git a/packages/amico-run/test/ledger.test.ts b/packages/amico-run/test/ledger.test.ts index ffa316f..b0892eb 100644 --- a/packages/amico-run/test/ledger.test.ts +++ b/packages/amico-run/test/ledger.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { appendRecord, readRecords, ledgerPath, PIPE_BUF, type SolveRecord } from "../src/ledger.js"; +import { appendRecord, readRecords, ledgerPath, PIPE_BUF, gpuTotals, type SolveRecord, type LedgerRecord } from "../src/ledger.js"; let dir: string; const prevEnv = process.env.AMICO_LEDGER; @@ -19,6 +19,12 @@ afterEach(() => { else process.env.AMICO_LEDGER = prevEnv; }); + +function writeTmpLedger(rows: LedgerRecord[]): string { + for (const r of rows) appendRecord(r); + return ledgerPath(); +} + const solve = (over: Partial = {}): SolveRecord => ({ type: "solve", ts: "2026-07-22T00:00:00Z", @@ -89,3 +95,25 @@ describe("ledger core", () => { expect(Buffer.byteLength(line + "\n", "utf8")).toBeLessThan(PIPE_BUF); }); }); + +// ── ledger gpu (#425): sum the receipt rows — the warrant fold's view ────── +describe("ledger gpu totals (receipt rows, #425)", () => { + it("sums gpu_seconds/cost across receipt rows, breaks down by SKU, counts by status", () => { + const f = writeTmpLedger([ + { type: "receipt", ts: "2026-08-18T10:00:00Z", task_id: "a", executor: "remote", gpu_sku: "H100-80GB", gpu_seconds: 900, cost_usd: 2.7, status: "completed" }, + { type: "receipt", ts: "2026-08-18T11:00:00Z", task_id: "b", executor: "remote", gpu_sku: "H100-80GB", gpu_seconds: 300, cost_usd: 0.9, status: "failed" }, + { type: "receipt", ts: "2026-08-18T12:00:00Z", task_id: "c", executor: "remote", gpu_sku: "A100-40GB", gpu_seconds: 600 }, + { type: "solve", ts: "2026-08-18T13:00:00Z", structure_hash: "x", problem_hash: "y", kind: "control", tier: "hpc", summary: { platform: "p", template: "t", trajectory: "g", N: 10, T: 10, goal: "g", solver: "s", strategy: "s" }, source: "user", outcome: { converged: true, fidelity: 0.99, iterations: 5 } }, + ]); + const t = gpuTotals(f); + expect(t.receipts).toBe(3); + expect(t.gpu_seconds).toBe(1800); + expect(t.cost_usd).toBeCloseTo(3.6); + expect(t.by_sku).toEqual({ "H100-80GB": { gpu_seconds: 1200, cost_usd: 3.6 }, "A100-40GB": { gpu_seconds: 600 } }); + expect(t.by_status).toEqual({ completed: 1, failed: 1 }); + }); + it("an empty or receipt-less ledger is zeros, never a throw", () => { + const t = gpuTotals(writeTmpLedger([])); + expect(t).toEqual({ receipts: 0, gpu_seconds: 0, cost_usd: 0, by_sku: {}, by_status: {} }); + }); +}); diff --git a/packages/amico-run/test/remote_executor.test.ts b/packages/amico-run/test/remote_executor.test.ts index 5bdf3e8..e6ab8db 100644 --- a/packages/amico-run/test/remote_executor.test.ts +++ b/packages/amico-run/test/remote_executor.test.ts @@ -397,3 +397,48 @@ describe("stats records arrive in either shape", () => { }); }); }); + +// ── GPU receipt accounting (#425): remote settle → receipt ledger row ────── +describe("RemoteExecutor — GPU receipt accounting (#425)", () => { + it("a completed run whose finished payload carries GPU fields emits a receipt ledger row + mirror receipt.toml", async () => { + await withCloud(async (fake) => { + fake.state.finished = { status: "completed", gpu_sku: "H100-80GB", gpu_seconds: 900, cost_usd: 2.7 }; + const ledgerFile = join(tmpRoot(), `ledger-${Date.now()}.jsonl`); + process.env.AMICO_LEDGER = ledgerFile; + try { + const h = await ex(fake, { pollMs: 5, warmingBudgetMs: 5000, lostAfterMs: 5000 }) + .submit(fakeJulia(tmpRoot(), "solve.jl", "// julia body"), { runsRoot: join(tmpRoot(), "runs"), lab: "t" }); + for await (const _ of h.events) void _; + const rows = readFileSync(ledgerFile, "utf8").trim().split("\n").map((l) => JSON.parse(l)); + const receipt = rows.find((r) => r.type === "receipt"); + expect(receipt).toMatchObject({ + task_id: fake.taskId, executor: "remote", + gpu_sku: "H100-80GB", gpu_seconds: 900, cost_usd: 2.7, + }); + // the mirror carries the durable artifact too + const mirror = join(h.runDir, "receipt.toml"); + expect(readFileSync(mirror, "utf8")).toContain('gpu_sku = "H100-80GB"'); + } finally { + delete process.env.AMICO_LEDGER; + } + }); + }); + it("a finished payload WITHOUT GPU fields emits no receipt row (nothing to account)", async () => { + await withCloud(async (fake) => { + fake.state.finished = { status: "completed" }; + const ledgerFile = join(tmpRoot(), `ledger-${Date.now()}.jsonl`); + process.env.AMICO_LEDGER = ledgerFile; + try { + const h = await ex(fake, { pollMs: 5, warmingBudgetMs: 5000, lostAfterMs: 5000 }) + .submit(fakeJulia(tmpRoot(), "solve.jl", "// julia body"), { runsRoot: join(tmpRoot(), "runs"), lab: "t2" }); + for await (const _ of h.events) void _; + if (existsSync(ledgerFile)) { + const rows = readFileSync(ledgerFile, "utf8").trim().split("\n").map((l) => JSON.parse(l)); + expect(rows.some((r) => r.type === "receipt")).toBe(false); + } + } finally { + delete process.env.AMICO_LEDGER; + } + }); + }); +}); diff --git a/packages/schema/schemas/ledger-record.schema.json b/packages/schema/schemas/ledger-record.schema.json index 1188b69..5eea692 100644 --- a/packages/schema/schemas/ledger-record.schema.json +++ b/packages/schema/schemas/ledger-record.schema.json @@ -2,17 +2,35 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://amico.harmoniqs.co/schema/ledger-record/v1", "title": "amico run-ledger record", - "description": "One append-only line in ~/.amico/ledger/runs.jsonl. A oneOf discriminated on `type` over the twelve record kinds (solve|verdict|attempt_error|fallback|override|burn|dispatch|approval|spec_review|plan_compiled|todo|claim). Ops-data, not vault knowledge. Registered in @amicode/schema SCHEMAS ONLY — NOT SUPPORTED_VERSIONS_BY_KIND (no top-level properties.schema_version; a oneOf like this would crash the version-map builder at module load, same as problemspec).", + "description": "One append-only line in ~/.amico/ledger/runs.jsonl. A oneOf discriminated on `type` over the twelve record kinds (solve|verdict|attempt_error|fallback|override|burn|dispatch|approval|spec_review|plan_compiled|todo|claim). Ops-data, not vault knowledge. Registered in @amicode/schema SCHEMAS ONLY \u2014 NOT SUPPORTED_VERSIONS_BY_KIND (no top-level properties.schema_version; a oneOf like this would crash the version-map builder at module load, same as problemspec).", "$defs": { "bounds": { "type": "object", "additionalProperties": false, - "description": "WarrantBounds — what an approval authorises. Hoisted out of the approval branch (was inline) so the spec-review `budget` lens can validate an AUTHORED budget against this schema rather than a prose restatement of it; validateBounds() in the schema package is that accessor. Behaviour is byte-identical to the inline form it replaces.", + "description": "WarrantBounds \u2014 what an approval authorises. Hoisted out of the approval branch (was inline) so the spec-review `budget` lens can validate an AUTHORED budget against this schema rather than a prose restatement of it; validateBounds() in the schema package is that accessor. Behaviour is byte-identical to the inline form it replaces.", "properties": { - "max_solves": { "type": "integer", "minimum": 1 }, - "tier": { "type": "string", "minLength": 1 }, - "max_size_class": { "enum": ["SMALL", "MEDIUM"], "description": "G-8: bounds the COST PROXY that actually exists. estimate.ts computes memory (sizeClass/score/estimatedBytes) and nothing in amico-run estimates wall-clock, so an earlier max_duration_s bound had no signal behind it and was removed rather than left implying a guarantee about time. A real duration estimator is the deferred C2 work." }, - "device": { "enum": ["none", "ro", "rw"] } + "max_solves": { + "type": "integer", + "minimum": 1 + }, + "tier": { + "type": "string", + "minLength": 1 + }, + "max_size_class": { + "enum": [ + "SMALL", + "MEDIUM" + ], + "description": "G-8: bounds the COST PROXY that actually exists. estimate.ts computes memory (sizeClass/score/estimatedBytes) and nothing in amico-run estimates wall-clock, so an earlier max_duration_s bound had no signal behind it and was removed rather than left implying a guarantee about time. A real duration estimator is the deferred C2 work." + }, + "device": { + "enum": [ + "none", + "ro", + "rw" + ] + } } } }, @@ -21,87 +39,236 @@ "title": "solve", "type": "object", "additionalProperties": false, - "required": ["type", "ts", "structure_hash", "problem_hash", "kind", "tier", "summary", "source", "outcome"], + "required": [ + "type", + "ts", + "structure_hash", + "problem_hash", + "kind", + "tier", + "summary", + "source", + "outcome" + ], "properties": { - "type": { "const": "solve" }, - "ts": { "type": "string" }, - "session": { "type": "string" }, - "problem": { "type": "string", "description": "problem slug" }, - "structure_hash": { "type": "string" }, - "problem_hash": { "type": "string" }, - "kind": { "type": "string" }, - "tier": { "type": "string" }, + "type": { + "const": "solve" + }, + "ts": { + "type": "string" + }, + "session": { + "type": "string" + }, + "problem": { + "type": "string", + "description": "problem slug" + }, + "structure_hash": { + "type": "string" + }, + "problem_hash": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "tier": { + "type": "string" + }, "summary": { "type": "object", "additionalProperties": true, - "required": ["platform", "template", "trajectory", "N", "T", "goal", "solver", "strategy"], + "required": [ + "platform", + "template", + "trajectory", + "N", + "T", + "goal", + "solver", + "strategy" + ], "properties": { - "platform": { "type": "string" }, - "template": { "type": "string" }, - "trajectory": { "type": "string" }, - "N": { "type": "number" }, - "T": { "type": "number" }, - "goal": { "type": "string" }, - "solver": { "type": "string" }, - "strategy": { "type": "string" }, - "levels": { "type": "number" } + "platform": { + "type": "string" + }, + "template": { + "type": "string" + }, + "trajectory": { + "type": "string" + }, + "N": { + "type": "number" + }, + "T": { + "type": "number" + }, + "goal": { + "type": "string" + }, + "solver": { + "type": "string" + }, + "strategy": { + "type": "string" + }, + "levels": { + "type": "number" + } } }, - "warm_start": { "type": ["string", "null"] }, - "source": { "enum": ["user", "replay", "simulated"] }, + "warm_start": { + "type": [ + "string", + "null" + ] + }, + "source": { + "enum": [ + "user", + "replay", + "simulated" + ] + }, "outcome": { "type": "object", "additionalProperties": true, - "required": ["converged", "fidelity", "iterations"], + "required": [ + "converged", + "fidelity", + "iterations" + ], "properties": { - "converged": { "type": "boolean" }, - "fidelity": { "type": "number" }, - "iterations": { "type": "number" }, - "wall_s": { "type": "number" } + "converged": { + "type": "boolean" + }, + "fidelity": { + "type": "number" + }, + "iterations": { + "type": "number" + }, + "wall_s": { + "type": "number" + } } }, - "versions": { "type": "object", "additionalProperties": { "type": "string" } }, - "plan_hash": { "type": "string", "minLength": 1, "description": "The approved plan this solve ran under, copied from solvespec.plan_hash at emission. LOAD-BEARING: warrant_context.solvesUnderPlan() counts solve rows by this field to enforce a warrant's max_solves bound, so a solve branch without it makes that bound inert — the counter is always 0 and the bound never trips (the defect this property fixes). Absent for an ungated free-set launch, which carries no plan_hash by design; minLength guards an empty hash matching every warrant, mirroring solvespec.plan_hash." } + "versions": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "plan_hash": { + "type": "string", + "minLength": 1, + "description": "The approved plan this solve ran under, copied from solvespec.plan_hash at emission. LOAD-BEARING: warrant_context.solvesUnderPlan() counts solve rows by this field to enforce a warrant's max_solves bound, so a solve branch without it makes that bound inert \u2014 the counter is always 0 and the bound never trips (the defect this property fixes). Absent for an ungated free-set launch, which carries no plan_hash by design; minLength guards an empty hash matching every warrant, mirroring solvespec.plan_hash." + } } }, { "title": "verdict", - "description": "A gate outcome. Two shapes share this branch: a SOLVE re-rollout verdict (keyed on problem_hash, the original form) and a PLAN-STEP gate verdict (keyed on plan_hash + step_id). The step form exists because plan-step state is DERIVED from these rows rather than written by a verb — an agent cannot forge `passed` without forging a gate verdict (spec-20260728 §4.4). problem_hash is required only for the solve form; a plan step's gate need not be solve-shaped (a schema-lint gate on a bookkeeping step has no problem hash).", + "description": "A gate outcome. Two shapes share this branch: a SOLVE re-rollout verdict (keyed on problem_hash, the original form) and a PLAN-STEP gate verdict (keyed on plan_hash + step_id). The step form exists because plan-step state is DERIVED from these rows rather than written by a verb \u2014 an agent cannot forge `passed` without forging a gate verdict (spec-20260728 \u00a74.4). problem_hash is required only for the solve form; a plan step's gate need not be solve-shaped (a schema-lint gate on a bookkeeping step has no problem hash).", "type": "object", "additionalProperties": false, - "required": ["type", "ts", "verdict"], - "if": { "not": { "required": ["step_id"] } }, - "then": { "required": ["problem_hash"] }, + "required": [ + "type", + "ts", + "verdict" + ], + "if": { + "not": { + "required": [ + "step_id" + ] + } + }, + "then": { + "required": [ + "problem_hash" + ] + }, "properties": { - "type": { "const": "verdict" }, - "ts": { "type": "string" }, - "problem_hash": { "type": "string" }, - "structure_hash": { "type": "string" }, - "verdict": { "enum": ["agree", "disagree", "exhausted", "bypassed"], "description": "`exhausted` is per-step gate exhaustion (failure at the top reachable escalation rung). It lives here because the fleet registry's `blocked` is SESSION-scoped and cannot carry a per-step outcome. `bypassed` is the terminal row for a step the compiled plan declared `optional: true` and the walk did not need: it is the SECOND HALF of the `skipped` producer, because `optional: true` alone is a PERMISSION, not an event. Without it `skipped` was unreachable while §4.5's completion rule admitted it — three revisions running. A `bypassed` row against a step the plan did NOT mark optional is a derivation error, not a skip." }, - "fidelity_rerolled": { "type": "number" }, - "fidelity_reported": { "type": "number" }, - "plan_hash": { "type": "string", "minLength": 1, "description": "Set on a plan-step verdict. With step_id this is the join plan-step state is derived from; OPTIONAL so every pre-existing solve verdict keeps validating." }, - "step_id": { "type": "string", "minLength": 1, "description": "The compiled plan step this verdict is about. Derivation keys on (plan_hash, step_id) — never step_id alone, or a recompiled plan's identically-named steps would alias onto the old plan's rows and read as already complete." }, - "source": { "enum": ["user", "replay", "simulated"], "description": "Lane. Step-state derivation filters source=user so a simulated gym verdict cannot masquerade as real progress in the very derivation the lane separation protects." } + "type": { + "const": "verdict" + }, + "ts": { + "type": "string" + }, + "problem_hash": { + "type": "string" + }, + "structure_hash": { + "type": "string" + }, + "verdict": { + "enum": [ + "agree", + "disagree", + "exhausted", + "bypassed" + ], + "description": "`exhausted` is per-step gate exhaustion (failure at the top reachable escalation rung). It lives here because the fleet registry's `blocked` is SESSION-scoped and cannot carry a per-step outcome. `bypassed` is the terminal row for a step the compiled plan declared `optional: true` and the walk did not need: it is the SECOND HALF of the `skipped` producer, because `optional: true` alone is a PERMISSION, not an event. Without it `skipped` was unreachable while \u00a74.5's completion rule admitted it \u2014 three revisions running. A `bypassed` row against a step the plan did NOT mark optional is a derivation error, not a skip." + }, + "fidelity_rerolled": { + "type": "number" + }, + "fidelity_reported": { + "type": "number" + }, + "plan_hash": { + "type": "string", + "minLength": 1, + "description": "Set on a plan-step verdict. With step_id this is the join plan-step state is derived from; OPTIONAL so every pre-existing solve verdict keeps validating." + }, + "step_id": { + "type": "string", + "minLength": 1, + "description": "The compiled plan step this verdict is about. Derivation keys on (plan_hash, step_id) \u2014 never step_id alone, or a recompiled plan's identically-named steps would alias onto the old plan's rows and read as already complete." + }, + "source": { + "enum": [ + "user", + "replay", + "simulated" + ], + "description": "Lane. Step-state derivation filters source=user so a simulated gym verdict cannot masquerade as real progress in the very derivation the lane separation protects." + } } }, { "title": "attempt_error", "type": "object", "additionalProperties": false, - "required": ["type", "ts", "errors"], + "required": [ + "type", + "ts", + "errors" + ], "properties": { - "type": { "const": "attempt_error" }, - "ts": { "type": "string" }, - "session": { "type": "string" }, + "type": { + "const": "attempt_error" + }, + "ts": { + "type": "string" + }, + "session": { + "type": "string" + }, "errors": { "type": "array", "items": { "type": "object", "additionalProperties": true, "properties": { - "path": { "type": "string" }, - "msg": { "type": "string" } + "path": { + "type": "string" + }, + "msg": { + "type": "string" + } } } } @@ -111,47 +278,111 @@ "title": "fallback", "type": "object", "additionalProperties": false, - "required": ["type", "ts", "from_tier", "reason"], + "required": [ + "type", + "ts", + "from_tier", + "reason" + ], "properties": { - "type": { "const": "fallback" }, - "ts": { "type": "string" }, - "from_tier": { "type": "string" }, - "reason": { "type": "string" } + "type": { + "const": "fallback" + }, + "ts": { + "type": "string" + }, + "from_tier": { + "type": "string" + }, + "reason": { + "type": "string" + } } }, { "title": "override", "type": "object", "additionalProperties": false, - "required": ["type", "ts", "param", "recommended", "applied", "structure_hash", "auto_accepted"], + "required": [ + "type", + "ts", + "param", + "recommended", + "applied", + "structure_hash", + "auto_accepted" + ], "properties": { - "type": { "const": "override" }, - "ts": { "type": "string" }, - "param": { "type": "string" }, - "recommended": { "type": ["number", "string", "boolean", "null"] }, - "applied": { "type": ["number", "string", "boolean", "null"] }, - "structure_hash": { "type": "string" }, - "auto_accepted": { "type": "boolean" } + "type": { + "const": "override" + }, + "ts": { + "type": "string" + }, + "param": { + "type": "string" + }, + "recommended": { + "type": [ + "number", + "string", + "boolean", + "null" + ] + }, + "applied": { + "type": [ + "number", + "string", + "boolean", + "null" + ] + }, + "structure_hash": { + "type": "string" + }, + "auto_accepted": { + "type": "boolean" + } } }, { "title": "burn", "type": "object", "additionalProperties": false, - "required": ["type", "ts", "class", "mechanism"], + "required": [ + "type", + "ts", + "class", + "mechanism" + ], "properties": { - "type": { "const": "burn" }, - "ts": { "type": "string" }, - "class": { "type": "string" }, - "mechanism": { "type": "string" }, - "receipt": { "type": "string" }, - "fixture": { "type": "string" }, - "prevention": { "type": "string" } + "type": { + "const": "burn" + }, + "ts": { + "type": "string" + }, + "class": { + "type": "string" + }, + "mechanism": { + "type": "string" + }, + "receipt": { + "type": "string" + }, + "fixture": { + "type": "string" + }, + "prevention": { + "type": "string" + } } }, { "title": "dispatch", - "description": "One gated dispatch attempt — one row per gate (fleet spec §6.3 Rev 5). Tier dispatch rides THIS ledger; no second store exists. `work_id` is the finest work identity (structure_hash for solve-shaped rows, experiment kind for task rows); `task_type` is the coarser fallback tier and carries the sim/hw axis, so `experiment-sim` and `experiment-hw` are separate values by construction — the enum below is what stops a bare `experiment` row from pooling simulated pass-rates into hardware routing. Experiment rows: attempt_index = 1, tokens = 0 (excluded from cost estimation).", + "description": "One gated dispatch attempt \u2014 one row per gate (fleet spec \u00a76.3 Rev 5). Tier dispatch rides THIS ledger; no second store exists. `work_id` is the finest work identity (structure_hash for solve-shaped rows, experiment kind for task rows); `task_type` is the coarser fallback tier and carries the sim/hw axis, so `experiment-sim` and `experiment-hw` are separate values by construction \u2014 the enum below is what stops a bare `experiment` row from pooling simulated pass-rates into hardware routing. Experiment rows: attempt_index = 1, tokens = 0 (excluded from cost estimation).", "type": "object", "additionalProperties": false, "required": [ @@ -168,8 +399,12 @@ "source" ], "properties": { - "type": { "const": "dispatch" }, - "ts": { "type": "string" }, + "type": { + "const": "dispatch" + }, + "ts": { + "type": "string" + }, "task_type": { "enum": [ "triage", @@ -184,146 +419,496 @@ "converse" ] }, - "work_id": { "type": "string", "minLength": 1 }, - "model": { "type": "string", "pattern": "^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$" }, - "variant": { "type": "string", "minLength": 1 }, - "gate": { "type": "string", "minLength": 1 }, - "pass": { "type": "boolean" }, - "tokens": { "type": "integer", "minimum": 0 }, - "attempt_index": { "type": "integer", "minimum": 1 }, - "plan_hash": { "type": "string", "minLength": 1, "description": "Set when this dispatch is a plan step; with step_id it lets step-state derivation see a step as `running` before any terminal verdict exists." }, - "step_id": { "type": "string", "minLength": 1, "description": "The compiled plan step this dispatch is for." }, - "source": { "enum": ["user", "replay", "simulated"] } + "work_id": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$" + }, + "variant": { + "type": "string", + "minLength": 1 + }, + "gate": { + "type": "string", + "minLength": 1 + }, + "pass": { + "type": "boolean" + }, + "tokens": { + "type": "integer", + "minimum": 0 + }, + "attempt_index": { + "type": "integer", + "minimum": 1 + }, + "plan_hash": { + "type": "string", + "minLength": 1, + "description": "Set when this dispatch is a plan step; with step_id it lets step-state derivation see a step as `running` before any terminal verdict exists." + }, + "step_id": { + "type": "string", + "minLength": 1, + "description": "The compiled plan step this dispatch is for." + }, + "source": { + "enum": [ + "user", + "replay", + "simulated" + ] + } } }, { "title": "spec_review", - "description": "One completed adversarial review of a Spec (spec-20260728 §5). Finding BODIES are NOT here: a 3-round 3-critic review's prose would exceed PIPE_BUF and appendRecord throws above it — after the model spend — so bodies go to a sidecar and this row carries a digest. Every free-text field is maxLength-capped for the same reason, because per-lens reasons originate in a subprocess's stderr.", + "description": "One completed adversarial review of a Spec (spec-20260728 \u00a75). Finding BODIES are NOT here: a 3-round 3-critic review's prose would exceed PIPE_BUF and appendRecord throws above it \u2014 after the model spend \u2014 so bodies go to a sidecar and this row carries a digest. Every free-text field is maxLength-capped for the same reason, because per-lens reasons originate in a subprocess's stderr.", "type": "object", "additionalProperties": false, - "required": ["type", "ts", "spec_id", "design_hash", "rounds", "review_verdict", "lens_registry_version", "lens_status", "critics", "findings_count", "blocking_count", "source"], + "required": [ + "type", + "ts", + "spec_id", + "design_hash", + "rounds", + "review_verdict", + "lens_registry_version", + "lens_status", + "critics", + "findings_count", + "blocking_count", + "source" + ], "properties": { - "type": { "const": "spec_review" }, - "ts": { "type": "string" }, - "spec_id": { "type": "string", "minLength": 1, "maxLength": 200, "description": "The spec's IMMUTABLE identity, authored once. design_hash alone is not an identity — two specs sharing an acceptance list would collide in the findings namespace and in the recompiled-plan lookup." }, - "design_hash": { "type": "string", "pattern": "^[0-9a-f]{64}$", "description": "sha256hex(canonicalJson({task_type, acceptance, budget?})) — the DECISION SURFACE only. Bare hex, matching structureHash/problemHash; NOT the gate's sha256:-prefixed spec_hash, which is a different population entirely (the canonical solvespec)." }, - "rounds": { "type": "integer", "minimum": 1, "maximum": 3, "description": "Round budget is 3; the schema enforces it so an unbounded loop cannot record itself as legitimate." }, - "review_verdict": { "enum": ["approved", "approved-mechanical", "degraded", "blocking", "exhausted"], "description": "NOT named `verdict`: the ledger already has a `verdict` KIND whose `verdict` field is agree|disagree, and both live in the same runs.jsonl. approved-mechanical = tier 1 clean with no critic binary; degraded = a selected critic timed out or emitted unparseable output. Both exit 0, but plan compile refuses them without --allow-unreviewed." }, - "lens_registry_version": { "type": "string", "minLength": 1, "maxLength": 64 }, + "type": { + "const": "spec_review" + }, + "ts": { + "type": "string" + }, + "spec_id": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "The spec's IMMUTABLE identity, authored once. design_hash alone is not an identity \u2014 two specs sharing an acceptance list would collide in the findings namespace and in the recompiled-plan lookup." + }, + "design_hash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "sha256hex(canonicalJson({task_type, acceptance, budget?})) \u2014 the DECISION SURFACE only. Bare hex, matching structureHash/problemHash; NOT the gate's sha256:-prefixed spec_hash, which is a different population entirely (the canonical solvespec)." + }, + "rounds": { + "type": "integer", + "minimum": 1, + "maximum": 3, + "description": "Round budget is 3; the schema enforces it so an unbounded loop cannot record itself as legitimate." + }, + "review_verdict": { + "enum": [ + "approved", + "approved-mechanical", + "degraded", + "blocking", + "exhausted" + ], + "description": "NOT named `verdict`: the ledger already has a `verdict` KIND whose `verdict` field is agree|disagree, and both live in the same runs.jsonl. approved-mechanical = tier 1 clean with no critic binary; degraded = a selected critic timed out or emitted unparseable output. Both exit 0, but plan compile refuses them without --allow-unreviewed." + }, + "lens_registry_version": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, "lens_status": { "type": "array", "description": "Per-lens outcome. `not-applicable` and `unverified` are distinct from a clean `ran` on purpose: collapsing them is how a blocking lens that could not run reads as a pass.", "items": { "type": "object", "additionalProperties": false, - "required": ["lens", "status"], + "required": [ + "lens", + "status" + ], "properties": { - "lens": { "type": "string", "minLength": 1, "maxLength": 64 }, - "status": { "enum": ["ran", "not-applicable", "skipped", "unverified"] }, - "reason": { "type": "string", "maxLength": 200 } + "lens": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "status": { + "enum": [ + "ran", + "not-applicable", + "skipped", + "unverified" + ] + }, + "reason": { + "type": "string", + "maxLength": 200 + } } } }, "critics": { "type": "array", - "description": "PRESENT-and-empty is the offline sentinel — an absent key would be indistinguishable from a row written before this field existed.", + "description": "PRESENT-and-empty is the offline sentinel \u2014 an absent key would be indistinguishable from a row written before this field existed.", "items": { "type": "object", "additionalProperties": false, - "required": ["model", "variant"], + "required": [ + "model", + "variant" + ], "properties": { - "model": { "type": "string", "pattern": "^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$", "description": "Read back from the CHILD's own output, not assumed from argv — otherwise the collusion mitigation stamps a request rather than a fact." }, - "variant": { "type": "string", "minLength": 1, "maxLength": 32 } + "model": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$", + "description": "Read back from the CHILD's own output, not assumed from argv \u2014 otherwise the collusion mitigation stamps a request rather than a fact." + }, + "variant": { + "type": "string", + "minLength": 1, + "maxLength": 32 + } } } }, - "findings_count": { "type": "integer", "minimum": 0 }, - "blocking_count": { "type": "integer", "minimum": 0 }, - "findings_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$", "description": "sha256hex(canonicalJson(findings)) — of the CANONICAL findings array, not of the sidecar file's bytes. Those are different strings." }, - "findings_ref": { "type": "string", "minLength": 1, "maxLength": 512 }, - "source": { "enum": ["user", "replay", "simulated"] } + "findings_count": { + "type": "integer", + "minimum": 0 + }, + "blocking_count": { + "type": "integer", + "minimum": 0 + }, + "findings_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "sha256hex(canonicalJson(findings)) \u2014 of the CANONICAL findings array, not of the sidecar file's bytes. Those are different strings." + }, + "findings_ref": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "source": { + "enum": [ + "user", + "replay", + "simulated" + ] + } } }, { "title": "plan_compiled", - "description": "A Plan compiled from a Spec (spec-20260728 §5). Load-bearing beyond bookkeeping: this row is the design_hash -> plan_hash binding, without which the launch gate cannot distinguish `the plan was recompiled, re-approve` from `never approved` and every recompile surfaces as a bare denial.", + "description": "A Plan compiled from a Spec (spec-20260728 \u00a75). Load-bearing beyond bookkeeping: this row is the design_hash -> plan_hash binding, without which the launch gate cannot distinguish `the plan was recompiled, re-approve` from `never approved` and every recompile surfaces as a bare denial.", "type": "object", "additionalProperties": false, - "required": ["type", "ts", "plan_hash", "spec_id", "design_hash", "step_count", "source"], + "required": [ + "type", + "ts", + "plan_hash", + "spec_id", + "design_hash", + "step_count", + "source" + ], "properties": { - "type": { "const": "plan_compiled" }, - "ts": { "type": "string" }, - "plan_hash": { "type": "string", "minLength": 1 }, - "spec_id": { "type": "string", "minLength": 1, "maxLength": 200 }, - "design_hash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "type": { + "const": "plan_compiled" + }, + "ts": { + "type": "string" + }, + "plan_hash": { + "type": "string", + "minLength": 1 + }, + "spec_id": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "design_hash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, "compiled_by": { "type": "object", "additionalProperties": false, - "required": ["model", "variant"], + "required": [ + "model", + "variant" + ], "properties": { - "model": { "type": "string", "pattern": "^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$" }, - "variant": { "type": "string", "minLength": 1, "maxLength": 32 } + "model": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$" + }, + "variant": { + "type": "string", + "minLength": 1, + "maxLength": 32 + } } }, - "step_count": { "type": "integer", "minimum": 0 }, - "advisory_count": { "type": "integer", "minimum": 0 }, - "suggested_ttl_s": { "type": "integer", "minimum": 1, "description": "A RECOMMENDATION derived from step_count. `amico ledger approve` reads it to default --expires-in and remains the sole writer of expires_at — compile must not own a warrant's lifetime, or --recompile would silently re-set how long a human's authorization lasts." }, - "allow_unreviewed": { "type": "boolean", "description": "True when compiled from a spec whose review was approved-mechanical or degraded. Recorded so the surface can say so." }, - "source": { "enum": ["user", "replay", "simulated"] } + "step_count": { + "type": "integer", + "minimum": 0 + }, + "advisory_count": { + "type": "integer", + "minimum": 0 + }, + "suggested_ttl_s": { + "type": "integer", + "minimum": 1, + "description": "A RECOMMENDATION derived from step_count. `amico ledger approve` reads it to default --expires-in and remains the sole writer of expires_at \u2014 compile must not own a warrant's lifetime, or --recompile would silently re-set how long a human's authorization lasts." + }, + "allow_unreviewed": { + "type": "boolean", + "description": "True when compiled from a spec whose review was approved-mechanical or degraded. Recorded so the surface can say so." + }, + "source": { + "enum": [ + "user", + "replay", + "simulated" + ] + } } }, { "title": "todo", - "description": "One ADVISORY todo transition (spec-20260728 §5). There is deliberately no step-todo row: step state is DERIVED from verdict rows, which is what makes forging `passed` require forging a gate verdict. `open` is the ABSENCE of a row; multiple rows per id resolve last-ts-wins; fixed -> obsolete is legal. No `actor` field, because no trustworthy actor identity exists at this layer and recording one would be theatre.", + "description": "One ADVISORY todo transition (spec-20260728 \u00a75). There is deliberately no step-todo row: step state is DERIVED from verdict rows, which is what makes forging `passed` require forging a gate verdict. `open` is the ABSENCE of a row; multiple rows per id resolve last-ts-wins; fixed -> obsolete is legal. No `actor` field, because no trustworthy actor identity exists at this layer and recording one would be theatre.", "type": "object", "additionalProperties": false, - "required": ["type", "ts", "plan_hash", "id", "state", "source"], - "if": { "properties": { "state": { "const": "waived" } }, "required": ["state"] }, - "then": { "required": ["reason"] }, + "required": [ + "type", + "ts", + "plan_hash", + "id", + "state", + "source" + ], + "if": { + "properties": { + "state": { + "const": "waived" + } + }, + "required": [ + "state" + ] + }, + "then": { + "required": [ + "reason" + ] + }, "properties": { - "type": { "const": "todo" }, - "ts": { "type": "string" }, - "plan_hash": { "type": "string", "minLength": 1 }, - "id": { "type": "string", "minLength": 1, "maxLength": 64 }, - "state": { "enum": ["fixed", "waived", "obsolete"] }, - "reason": { "type": "string", "minLength": 1, "maxLength": 200, "description": "Required iff state = waived, so waive-spam is visible in the record rather than silent." }, - "source": { "enum": ["user", "replay", "simulated"] } + "type": { + "const": "todo" + }, + "ts": { + "type": "string" + }, + "plan_hash": { + "type": "string", + "minLength": 1 + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "state": { + "enum": [ + "fixed", + "waived", + "obsolete" + ] + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Required iff state = waived, so waive-spam is visible in the record rather than silent." + }, + "source": { + "enum": [ + "user", + "replay", + "simulated" + ] + } } }, { "title": "approval", - "description": "A capability warrant (spec-20260727-164748 §5): the record that lets a gated launch through amico-run's --spec gate. `plan_hash` is what was approved; `bounds` is what it authorises. DELIBERATELY UNSIGNED — the threat model is drift, not an adversary (spec §3), and the product agent holds unrestricted bash, so a signature would defend against an attacker this layer could not stop anyway. Absent bounds keys do NOT default to allow: §5.1 rule 2 refuses a launch needing a bound the warrant omits, which is why an empty `bounds` object is legal (it authorises nothing beyond the ungated free set) rather than a hole. `device` uses the fleet spec §2.1 permission vocabulary.", + "description": "A capability warrant (spec-20260727-164748 \u00a75): the record that lets a gated launch through amico-run's --spec gate. `plan_hash` is what was approved; `bounds` is what it authorises. DELIBERATELY UNSIGNED \u2014 the threat model is drift, not an adversary (spec \u00a73), and the product agent holds unrestricted bash, so a signature would defend against an attacker this layer could not stop anyway. Absent bounds keys do NOT default to allow: \u00a75.1 rule 2 refuses a launch needing a bound the warrant omits, which is why an empty `bounds` object is legal (it authorises nothing beyond the ungated free set) rather than a hole. `device` uses the fleet spec \u00a72.1 permission vocabulary.", "type": "object", "additionalProperties": false, - "required": ["type", "ts", "plan_hash", "bounds", "expires_at", "issued_by"], + "required": [ + "type", + "ts", + "plan_hash", + "bounds", + "expires_at", + "issued_by" + ], "properties": { - "type": { "const": "approval" }, - "ts": { "type": "string" }, - "plan_hash": { "type": "string", "minLength": 1 }, - "bounds": { "$ref": "#/$defs/bounds" }, - "expires_at": { "type": "string" }, - "issued_by": { "type": "string", "minLength": 1 } + "type": { + "const": "approval" + }, + "ts": { + "type": "string" + }, + "plan_hash": { + "type": "string", + "minLength": 1 + }, + "bounds": { + "$ref": "#/$defs/bounds" + }, + "expires_at": { + "type": "string" + }, + "issued_by": { + "type": "string", + "minLength": 1 + } } }, { "title": "claim", - "description": "Coordination claim (spec-20260804-014823 §3): lease-bound claim on a work_id. Server-side issued_at/lease_expires are the sole serializer; validated on write; lapsed is derived. Idempotent on content hash. Org-scoped via token; only verified results publish.", + "description": "Coordination claim (spec-20260804-014823 \u00a73): lease-bound claim on a work_id. Server-side issued_at/lease_expires are the sole serializer; validated on write; lapsed is derived. Idempotent on content hash. Org-scoped via token; only verified results publish.", + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "ts", + "work_id", + "agent_id", + "user", + "org", + "host", + "lease_expires" + ], + "properties": { + "type": { + "const": "claim" + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "work_id": { + "type": "string", + "minLength": 1, + "description": "work_id v1 canonical work identity" + }, + "agent_id": { + "type": "string", + "minLength": 1 + }, + "user": { + "type": "string", + "minLength": 1 + }, + "org": { + "type": "string", + "minLength": 1 + }, + "host": { + "type": "string", + "minLength": 1 + }, + "lease_expires": { + "type": "string", + "format": "date-time" + }, + "variant_axis": { + "type": "string", + "description": "multistart axis when claiming variant" + }, + "run_id": { + "type": "string", + "minLength": 1 + }, + "outcome": { + "enum": [ + "claimed", + "solved", + "failed", + "abandoned" + ], + "description": "terminal state; lapsed is derived, not stored" + }, + "issued_at": { + "type": "string", + "format": "date-time", + "description": "server-side time, sole serializer" + } + } + }, + { + "title": "receipt", + "description": "GPU/compute accounting for a remote run (#425, GPU-plane spec): pure spend record \u2014 no fidelity, never feeds priors. Emitted by the remote executor at settle from the runner's finished payload; the campaign warrant fold sums these (unit-keyed bounds, telaio #5). A receipt names WHAT consumed (task_id) and WHERE (executor: remote) \u2014 local runs burn nothing billable and produce none.", "type": "object", "additionalProperties": false, - "required": ["type", "ts", "work_id", "agent_id", "user", "org", "host", "lease_expires"], + "required": [ + "type", + "ts", + "task_id", + "executor" + ], "properties": { - "type": { "const": "claim" }, - "ts": { "type": "string", "format": "date-time" }, - "work_id": { "type": "string", "minLength": 1, "description": "work_id v1 canonical work identity" }, - "agent_id": { "type": "string", "minLength": 1 }, - "user": { "type": "string", "minLength": 1 }, - "org": { "type": "string", "minLength": 1 }, - "host": { "type": "string", "minLength": 1 }, - "lease_expires": { "type": "string", "format": "date-time" }, - "variant_axis": { "type": "string", "description": "multistart axis when claiming variant" }, - "run_id": { "type": "string", "minLength": 1 }, - "outcome": { "enum": ["claimed", "solved", "failed", "abandoned"], "description": "terminal state; lapsed is derived, not stored" }, - "issued_at": { "type": "string", "format": "date-time", "description": "server-side time, sole serializer" } + "type": { + "const": "receipt" + }, + "ts": { + "type": "string" + }, + "task_id": { + "type": "string", + "minLength": 1 + }, + "executor": { + "const": "remote" + }, + "gpu_sku": { + "type": "string", + "minLength": 1 + }, + "gpu_seconds": { + "type": "number", + "exclusiveMinimum": 0 + }, + "cost_usd": { + "type": "number", + "exclusiveMinimum": 0 + }, + "problem": { + "type": "string", + "minLength": 1 + }, + "session": { + "type": "string" + }, + "status": { + "enum": [ + "completed", + "failed", + "aborted" + ] + } } } ] diff --git a/packages/schema/test/fixtures/valid/ledger-record-receipt.toml b/packages/schema/test/fixtures/valid/ledger-record-receipt.toml new file mode 100644 index 0000000..a607866 --- /dev/null +++ b/packages/schema/test/fixtures/valid/ledger-record-receipt.toml @@ -0,0 +1,8 @@ +# A GPU receipt (#425): remote accounting, never a prior. +type = "receipt" +ts = "2026-08-18T12:00:00.000Z" +task_id = "419a57e6" +executor = "remote" +gpu_sku = "H100-80GB" +gpu_seconds = 1800.0 +cost_usd = 5.4 diff --git a/packages/schema/test/ledger-record.test.ts b/packages/schema/test/ledger-record.test.ts index 357d916..db03832 100644 --- a/packages/schema/test/ledger-record.test.ts +++ b/packages/schema/test/ledger-record.test.ts @@ -227,3 +227,39 @@ describe("ledger-record schema — discriminator", () => { expect(validate({ type: "nonesuch", ts: "t" }, "ledger-record").ok).toBe(false); }); }); + +// ── the receipt record (#425): GPU accounting for remote runs ───────────── +describe("the receipt record (GPU accounting, #425)", () => { + const receipt = (over: Record = {}) => ({ + type: "receipt", + ts: "2026-08-18T12:00:00.000Z", + task_id: "419a57e6", + executor: "remote", + gpu_sku: "H100-80GB", + gpu_seconds: 1800, + cost_usd: 5.4, + ...over, + }); + it("accepts a complete GPU receipt", () => { + expect(validate(receipt(), "ledger-record")).toMatchObject({ ok: true }); + }); + it("accepts a partial receipt — the runner may report sku-only or seconds-only", () => { + expect(validate(receipt({ gpu_seconds: undefined, cost_usd: undefined }), "ledger-record")).toMatchObject({ ok: true }); + expect(validate(receipt({ gpu_sku: undefined, gpu_seconds: undefined }), "ledger-record")).toMatchObject({ ok: true }); + }); + it("requires type/ts/task_id/executor — a receipt names WHAT consumed and WHERE", () => { + for (const k of ["ts", "task_id", "executor"] as const) + expect(validate({ ...receipt(), [k]: undefined }, "ledger-record").ok, k).toBe(false); + }); + it("gpu_seconds/cost_usd are positive numbers when present", () => { + expect(validate(receipt({ gpu_seconds: -1 }), "ledger-record").ok).toBe(false); + expect(validate(receipt({ gpu_seconds: "1800" }), "ledger-record").ok).toBe(false); + expect(validate(receipt({ cost_usd: -0.01 }), "ledger-record").ok).toBe(false); + }); + it("executor is an enum — local receipts make no sense (nothing burns)", () => { + expect(validate(receipt({ executor: "local" }), "ledger-record").ok).toBe(false); + }); + it("optional problem/session stamps for attribution", () => { + expect(validate(receipt({ problem: "cx-cat-cat-cavity", session: "s1" }), "ledger-record")).toMatchObject({ ok: true }); + }); +});