diff --git a/packages/app/src/pages/session/v2/adversarial-ui-e2e.test.ts b/packages/app/src/pages/session/v2/adversarial-ui-e2e.test.ts new file mode 100644 index 0000000000..c13ac92baa --- /dev/null +++ b/packages/app/src/pages/session/v2/adversarial-ui-e2e.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test" +import { ProvenanceMatrix } from "@opencode-ai/schema/provenance-matrix" +import { + applyLedgerKeyDown, + buildLedgerView, + describeLedgerStatus, + LEDGER_CAPABILITY_LABELS, + ledgerCapabilityLabel, + type LedgerStatusInput, + type LedgerStatusKind, +} from "./lineage-ledger-view" + +/** + * The ui harness of the adversarial full-provenance matrix (amicode#1084). + * + * This cross-harness E2E fixture RUNS the ui-owned matrix rows against the REAL + * unified lineage-ledger view-model (#1082) — the same pure reducer + status + * descriptors the Files Changed surface renders — and records each row that + * produced its declared observable result as green. It proves the unified surface + * is operable by keyboard and legible in BOTH supported themes (its distinctions + * are text + icon, never color), then feeds the green set to the shared gate + * (`ProvenanceMatrix.gate`) exactly as the engine harness does. + */ + +const green = new Set() +const ui = ProvenanceMatrix.subset("ui") + +// ── AC7a: keyboard operation ───────────────────────────────────────────────── + +function keyEvent(key: string) { + let prevented = false + return { event: { key, preventDefault: () => (prevented = true) }, prevented: () => prevented } +} + +function runKeyboard(): void { + // Enter and Space on a focused row toggle it and prevent default (no page scroll). + for (const key of ["Enter", " "]) { + const e = keyEvent(key) + expect(applyLedgerKeyDown(e.event, { focusKind: "row", id: "res-1", expanded: false })).toEqual({ + type: "toggle", + id: "res-1", + }) + expect(e.prevented()).toBe(true) + } + // Escape inside expanded content collapses and returns focus to the row. + expect(applyLedgerKeyDown(keyEvent("Escape").event, { focusKind: "content", id: "res-1", expanded: true })).toEqual({ + type: "collapse", + id: "res-1", + refocus: "res-1", + }) + // Escape on an expanded row collapses it. + expect(applyLedgerKeyDown(keyEvent("Escape").event, { focusKind: "row", id: "res-1", expanded: true })).toEqual({ + type: "collapse", + id: "res-1", + refocus: "res-1", + }) + // Unrelated keys and collapsed-row escape are no-ops — the surface never traps the keyboard. + expect(applyLedgerKeyDown(keyEvent("a").event, { focusKind: "row", id: "res-1", expanded: false })).toEqual({ + type: "none", + }) + expect(applyLedgerKeyDown(keyEvent("Escape").event, { focusKind: "row", id: "res-1", expanded: false })).toEqual({ + type: "none", + }) +} + +// ── AC7b: legibility across both supported themes (text + icon, never color) ── + +const STATUS_CASES: Array<{ input: LedgerStatusInput; kind: LedgerStatusKind }> = [ + { input: { netState: "added", outcome: "succeeded" }, kind: "added" }, + { input: { netState: "modified", outcome: "succeeded" }, kind: "modified" }, + { input: { netState: "deleted", outcome: "succeeded" }, kind: "deleted" }, + { input: { netState: "reverted", outcome: "succeeded" }, kind: "reverted" }, + { input: { netState: "conflicted", outcome: "succeeded" }, kind: "conflicted" }, + { input: { netState: "unavailable", outcome: "succeeded", evidenceState: "unavailable" }, kind: "unavailable" }, + { input: { outcome: "opaque", netState: "opaque" }, kind: "opaque" }, + { input: { outcome: "partial", netState: "added" }, kind: "partial" }, +] + +function runThemes(): void { + const descriptors = STATUS_CASES.map((c) => describeLedgerStatus(c.input)) + // Each named state resolves to its kind. + for (const [index, c] of STATUS_CASES.entries()) expect(descriptors[index].kind).toBe(c.kind) + // Distinctions are TEXT + ICON — unique per state — so the surface is legible without relying on color. + expect(new Set(descriptors.map((d) => d.label)).size).toBe(STATUS_CASES.length) + expect(new Set(descriptors.map((d) => d.icon)).size).toBe(STATUS_CASES.length) + for (const d of descriptors) { + expect(d.label.length).toBeGreaterThan(0) + expect(d.icon.length).toBeGreaterThan(0) + // Tone is a semantic keyword, never a raw color literal — so no theme owns the meaning. + expect(["success", "danger", "warning", "neutral"]).toContain(d.tone) + expect(d.tone).not.toMatch(/#|rgb|hsl/i) + } + // Theme-independent by construction: the descriptor carries no theme branch or inline color. + expect(describeLedgerStatus({ netState: "deleted", outcome: "succeeded" })).toEqual( + describeLedgerStatus({ netState: "deleted", outcome: "succeeded" }), + ) + // Screen-reader legibility (theme-agnostic): the accessible name carries execution + assessment + evidence. + const accessible = describeLedgerStatus({ outcome: "succeeded", netState: "added", evidenceState: "available" }).accessibleName.toLowerCase() + for (const token of ["succeeded", "added", "available"]) expect(accessible).toContain(token) + // The capability label is fixed explanatory text selected only by capability — surfaced on the built view. + for (const mode of ["full", "partial", "legacy"] as const) + expect(ledgerCapabilityLabel(mode)).toEqual(LEDGER_CAPABILITY_LABELS[mode]) + expect(buildLedgerView({ capability: { mode: "legacy" }, records: [] }).capability).toEqual( + LEDGER_CAPABILITY_LABELS.legacy, + ) +} + +// ── Register the ui per-row tests ──────────────────────────────────────────── + +describe("adversarial matrix — ui harness (unified Files Changed surface)", () => { + for (const row of ui.cases) { + test(row.id, () => { + if (row.id === "a11y:keyboard") runKeyboard() + else if (row.id === "a11y:themes") runThemes() + else throw new Error(`no ui executor for row ${row.id}`) + green.add(row.id) + }) + } +}) + +describe("adversarial matrix — the ui-subset release gate (AC8)", () => { + test("every required ui case ran green — all_required_passed, enablement STILL off", () => { + const result = ProvenanceMatrix.gate(ui, green) + expect(result.missingRequired).toEqual([]) + expect(result.all_required_passed).toBe(true) + expect(result.defaultEnablement).toBe("off") + }) + + test("dropping any one required ui case blocks the gate", () => { + const dropped = ProvenanceMatrix.requiredCaseIDs(ui)[0] + const minusOne = new Set(green) + minusOne.delete(dropped) + expect(ProvenanceMatrix.gate(ui, minusOne).all_required_passed).toBe(false) + }) +}) diff --git a/packages/app/src/pages/session/v2/lineage-ledger-panel.css b/packages/app/src/pages/session/v2/lineage-ledger-panel.css new file mode 100644 index 0000000000..c887b1eee6 --- /dev/null +++ b/packages/app/src/pages/session/v2/lineage-ledger-panel.css @@ -0,0 +1,125 @@ +/* Unified lineage-ledger panel (amicode#1082). Geometry + color come only from + * design-system tokens, so both themes are equal citizens and the status color + * is redundant to the icon + text label (color is never the only signal). */ + +[data-component="lineage-ledger"] { + display: flex; + flex-direction: column; + gap: var(--space-2, 8px); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-capability"] { + display: flex; + flex-direction: column; + gap: var(--space-1, 4px); + padding: var(--space-3, 12px); + border: 1px solid var(--v2-border-border-base); + border-radius: var(--radius-lg, 12px); + background: var(--v2-background-bg-layer-01); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-capability"][data-mode="legacy"], +[data-component="lineage-ledger"] [data-slot="lineage-ledger-capability"][data-mode="partial"] { + border-color: var(--v2-border-border-warning); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-filter"] { + padding: var(--space-1, 4px) var(--space-2, 8px); + border: 1px solid var(--v2-border-border-base); + border-radius: var(--radius-md, 8px); + background: var(--v2-background-bg-layer-02); + color: var(--v2-text-text-base); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-filter"]:focus-visible { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: 1px; +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-resources"] { + display: flex; + flex-direction: column; + gap: var(--space-1, 4px); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-resource-row"] { + display: flex; + align-items: center; + gap: var(--space-2, 8px); + width: 100%; + padding: var(--space-1, 4px) var(--space-2, 8px); + border-radius: var(--radius-md, 8px); + background: transparent; + text-align: left; + cursor: pointer; +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-resource-row"]:hover { + background: var(--v2-overlay-simple-overlay-hover); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-resource-row"]:focus-visible { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: -1px; +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-path"] { + flex: 1 1 auto; + min-width: 0; +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-status"] { + display: inline-flex; + align-items: center; + gap: var(--space-1, 4px); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-status"][data-tone="success"] { + color: var(--v2-state-fg-success); +} +[data-component="lineage-ledger"] [data-slot="lineage-ledger-status"][data-tone="danger"] { + color: var(--v2-state-fg-danger); +} +[data-component="lineage-ledger"] [data-slot="lineage-ledger-status"][data-tone="warning"] { + color: var(--v2-state-fg-warning); +} +[data-component="lineage-ledger"] [data-slot="lineage-ledger-status"][data-tone="neutral"] { + color: var(--v2-text-text-muted); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-history"] { + display: flex; + flex-direction: column; + gap: var(--space-1, 4px); + padding: var(--space-1, 4px) var(--space-2, 8px) var(--space-2, 8px) var(--space-5, 20px); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-unknown"] { + display: flex; + flex-direction: column; + gap: var(--space-1, 4px); + padding: var(--space-2, 8px); + border: 1px solid var(--v2-border-border-warning); + border-radius: var(--radius-lg, 12px); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-unknown-item"] { + display: flex; + align-items: center; + gap: var(--space-2, 8px); +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-more"] { + align-self: flex-start; + padding: var(--space-1, 4px) var(--space-2, 8px); + border: 1px solid var(--v2-border-border-base); + border-radius: var(--radius-md, 8px); + background: var(--v2-background-bg-layer-01); + color: var(--v2-text-text-base); + cursor: pointer; +} + +[data-component="lineage-ledger"] [data-slot="lineage-ledger-more"]:focus-visible { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: 1px; +} diff --git a/packages/app/src/pages/session/v2/lineage-ledger-panel.test.tsx b/packages/app/src/pages/session/v2/lineage-ledger-panel.test.tsx new file mode 100644 index 0000000000..1fb332f24a --- /dev/null +++ b/packages/app/src/pages/session/v2/lineage-ledger-panel.test.tsx @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +// The lineage-ledger panel is authored in Solid JSX; bun's test transpiler does +// not run Solid's dom-expressions transform, so (as elsewhere in this repo — +// see vscode-explorer-file-icon.test.tsx) the component's behavioral substance +// lives in pure, directly-tested functions (lineage-ledger-view.test.ts) and +// this test proves the component wires the accessibility contract those +// functions require. The Work Column design contract (amicode#1082 AC8) is: +// Tab reaches each filter/row/continuation/unknown item, Enter/Space toggles a +// resource, Escape returns focus to its row, and each status has an accessible +// name plus a non-color signal. +const source = readFileSync(resolve(__dirname, "lineage-ledger-panel.tsx"), "utf8") + +describe("LineageLedgerPanel — render + accessibility contract", () => { + test("renders from the projected view-model only (no tool/watcher inference — AC1)", () => { + expect(source).toContain("./lineage-ledger-view") + expect(source).toContain("props.view()") + expect(source).not.toMatch(/accumulate-diffs|toolDiffs|externalFileStatus|watcher/i) + }) + + test("surfaces the capability label as accessible status text (AC7)", () => { + expect(source).toContain('data-slot="lineage-ledger-capability"') + expect(source).toContain('role="status"') + expect(source).toContain("capability().label") + expect(source).toContain("capability().description") + }) + + test("each canonical resource is one focusable, toggleable row (AC2/AC8)", () => { + expect(source).toContain('data-slot="lineage-ledger-resource"') + expect(source).toContain("aria-expanded") + expect(source).toContain("aria-controls") + // a real + + +
+ + {(entry) => ( +
+
+ {`#${entry.sequence} ${entry.operation ?? "operation"} → ${entry.outcome ?? "unknown"}`} + + {` (${entry.resource})`} + +
+ + {(assessment) => ( +
+ {`rev ${assessment.revision}: ${assessment.netState ?? "unknown"} · evidence ${assessment.evidenceState ?? "unavailable"}`} +
+ )} +
+
+ )} +
+
+
+ + )} + + + + +
+
+ {"Unknown Mutation Receipts"} +
+ + {(item) => ( +
+ + {`#${item.sequence} ${item.operation ?? "operation"} → ${item.outcome ?? "opaque"}`} + + {item.origin} + +
+ )} +
+
+
+ + + + + + ) +} diff --git a/packages/app/src/pages/session/v2/lineage-ledger-view.test.ts b/packages/app/src/pages/session/v2/lineage-ledger-view.test.ts new file mode 100644 index 0000000000..5b6278b66b --- /dev/null +++ b/packages/app/src/pages/session/v2/lineage-ledger-view.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, test } from "bun:test" +import { + applyLedgerKeyDown, + buildLedgerView, + describeLedgerStatus, + LEDGER_CAPABILITY_LABELS, + ledgerCapabilityLabel, + pageReceiptHistory, + type LedgerProjectionRecord, + type LedgerStatusKind, +} from "./lineage-ledger-view" + +// A projected ledger record mirrors the #1078 `browser`-boundary projection of one +// host receipt: operation context (origin/state + display-safe session/resource +// keys), the immutable receipt fact, an optional assessment revision, and derived +// counts. Host-only fields (ids/rootID/sessionID/canonical path/evidence/redaction) +// are absent by construction — the browser boundary denies them. +function record(input: Partial): LedgerProjectionRecord { + return input +} + +describe("buildLedgerView — no-fallback ownership (AC1)", () => { + test("derives a resource's origin only from projected operation data, never inferred", () => { + const view = buildLedgerView({ + capability: { mode: "full" }, + records: [ + record({ + operation: { origin: "agent", session: "root", state: "committed" }, + receipt: { id: "r1", sequence: 1, resource: "src/a.ts", operation: "create", outcome: "succeeded", timeCreated: 10 }, + assessment: { receiptID: "r1", netState: "added", evidenceState: "available", confidence: "observed", revision: 1, timeCreated: 11 }, + }), + ], + }) + expect(view.resources).toHaveLength(1) + expect(view.resources[0].origins).toEqual(["agent"]) + // The row exposes no tool/watcher-owned attribution field at all. + expect(view.resources[0]).not.toHaveProperty("tool") + expect(view.resources[0]).not.toHaveProperty("watcher") + }) +}) + +describe("buildLedgerView — one canonical resource row (AC2)", () => { + test("aggregates final state, origin mix, source provenance, receipt count, evidence state", () => { + const view = buildLedgerView({ + capability: { mode: "full" }, + records: [ + record({ + operation: { origin: "user", session: "root" }, + receipt: { id: "r1", sequence: 1, resource: "src/a.ts", operation: "create", outcome: "succeeded", timeCreated: 1 }, + assessment: { receiptID: "r1", netState: "added", evidenceState: "available", revision: 1, timeCreated: 2 }, + }), + record({ + operation: { origin: "agent", session: "child" }, + receipt: { id: "r2", sequence: 2, resource: "src/a.ts", operation: "edit", outcome: "succeeded", timeCreated: 3 }, + assessment: { receiptID: "r2", netState: "modified", evidenceState: "available", revision: 1, timeCreated: 4 }, + }), + ], + }) + expect(view.resources).toHaveLength(1) + const row = view.resources[0] + expect(row.displayPath).toBe("src/a.ts") + expect(row.receiptCount).toBe(2) + expect(row.origins.sort()).toEqual(["agent", "user"]) + expect(row.sources.sort()).toEqual(["child", "root"]) + expect(row.status.kind).toBe("modified") // final assessment + expect(row.evidenceState).toBe("available") + }) +}) + +describe("buildLedgerView — chronological history preserves immutable facts (AC3)", () => { + test("expands receipts in sequence with assessment revisions interleaved after their receipt", () => { + const view = buildLedgerView({ + capability: { mode: "full" }, + records: [ + record({ + operation: { origin: "agent" }, + receipt: { id: "r1", sequence: 1, resource: "a.ts", operation: "create", outcome: "succeeded", timeCreated: 1 }, + assessment: { receiptID: "r1", netState: "added", evidenceState: "available", revision: 1, timeCreated: 2 }, + }), + // later reassessment of the same receipt — a new revision, not a rewrite + record({ + assessment: { receiptID: "r1", netState: "reverted", evidenceState: "available", revision: 2, timeCreated: 5 }, + }), + record({ + operation: { origin: "agent" }, + receipt: { id: "r2", sequence: 2, resource: "a.ts", operation: "edit", outcome: "succeeded", timeCreated: 3 }, + }), + ], + }) + const row = view.resources[0] + expect(row.history.map((h) => h.receiptID)).toEqual(["r1", "r2"]) + expect(row.history[0].assessments.map((a) => a.revision)).toEqual([1, 2]) + }) + + test("a duplicate receipt record never rewrites the first-seen immutable execution fact", () => { + const view = buildLedgerView({ + capability: { mode: "full" }, + records: [ + record({ + receipt: { id: "r1", sequence: 1, resource: "a.ts", operation: "create", outcome: "succeeded", timeCreated: 1 }, + }), + record({ + receipt: { id: "r1", sequence: 1, resource: "a.ts", operation: "create", outcome: "TAMPERED", timeCreated: 99 }, + }), + ], + }) + expect(view.resources[0].history[0].outcome).toBe("succeeded") + expect(view.resources[0].history[0].timeCreated).toBe(1) + }) + + test("a zero-net final state never erases the resource's historical receipt row", () => { + const view = buildLedgerView({ + capability: { mode: "full" }, + records: [ + record({ + receipt: { id: "r1", sequence: 1, resource: "tmp.ts", operation: "create", outcome: "succeeded", timeCreated: 1 }, + assessment: { receiptID: "r1", netState: "added", evidenceState: "available", revision: 1, timeCreated: 2 }, + }), + record({ + receipt: { id: "r2", sequence: 2, resource: "tmp.ts", operation: "delete", outcome: "succeeded", timeCreated: 3 }, + assessment: { receiptID: "r2", netState: "deleted", evidenceState: "available", revision: 1, timeCreated: 4 }, + }), + ], + }) + expect(view.resources).toHaveLength(1) + expect(view.resources[0].receiptCount).toBe(2) + expect(view.resources[0].status.kind).toBe("deleted") + }) +}) + +describe("buildLedgerView — rename chain is one identity with aliases (AC2/AC3)", () => { + test("groups receipts sharing a server resource key across differing display paths", () => { + const view = buildLedgerView({ + capability: { mode: "full" }, + records: [ + record({ + resourceKey: "res-1", + receipt: { id: "r1", sequence: 1, resource: "old.ts", operation: "create", outcome: "succeeded", timeCreated: 1 }, + }), + record({ + resourceKey: "res-1", + receipt: { id: "r2", sequence: 2, resource: "new.ts", operation: "move", outcome: "succeeded", timeCreated: 2 }, + }), + ], + }) + expect(view.resources).toHaveLength(1) + expect(view.resources[0].displayPath).toBe("new.ts") + expect(view.resources[0].aliases).toEqual(["old.ts"]) + }) +}) + +describe("buildLedgerView — unknown mutation receipts are a dedicated uncertainty group (AC4)", () => { + test("a receipt with no resource identity becomes an uncertainty item, never a fabricated resource row", () => { + const view = buildLedgerView({ + capability: { mode: "full" }, + records: [ + record({ + operation: { origin: "agent" }, + receipt: { id: "u1", sequence: 1, operation: "shell", outcome: "opaque", timeCreated: 1 }, + }), + record({ + receipt: { id: "r1", sequence: 2, resource: "a.ts", operation: "edit", outcome: "succeeded", timeCreated: 2 }, + }), + ], + }) + expect(view.resources).toHaveLength(1) + expect(view.resources.some((r) => r.displayPath === "")).toBe(false) + expect(view.unknown).toHaveLength(1) + expect(view.unknown[0].receiptID).toBe("u1") + // No patch/resource is fabricated for an unknown item. + expect(view.unknown[0]).not.toHaveProperty("patch") + expect(view.unknown[0]).not.toHaveProperty("resource") + }) + + test("unknown items do not consume resource-page capacity", () => { + const view = buildLedgerView({ + capability: { mode: "full" }, + page: { size: 2 }, + records: [ + record({ receipt: { id: "u1", sequence: 1, operation: "shell", outcome: "opaque", timeCreated: 1 } }), + record({ receipt: { id: "u2", sequence: 2, operation: "mcp", outcome: "opaque", timeCreated: 2 } }), + record({ receipt: { id: "r1", sequence: 3, resource: "a.ts", operation: "edit", outcome: "succeeded", timeCreated: 3 } }), + record({ receipt: { id: "r2", sequence: 4, resource: "b.ts", operation: "edit", outcome: "succeeded", timeCreated: 4 } }), + ], + }) + expect(view.resources).toHaveLength(2) + expect(view.unknown).toHaveLength(2) + expect(view.page.total).toBe(2) // resource total, unknowns excluded + expect(view.page.nextCursor).toBeUndefined() + }) +}) + +describe("describeLedgerStatus — distinguishable states without color alone (AC5)", () => { + const cases: Array<{ input: Parameters[0]; kind: LedgerStatusKind }> = [ + { input: { netState: "added", outcome: "succeeded" }, kind: "added" }, + { input: { netState: "modified", outcome: "succeeded" }, kind: "modified" }, + { input: { netState: "deleted", outcome: "succeeded" }, kind: "deleted" }, + { input: { netState: "reverted", outcome: "succeeded" }, kind: "reverted" }, + { input: { netState: "conflicted", outcome: "succeeded" }, kind: "conflicted" }, + { input: { netState: "unavailable", outcome: "succeeded", evidenceState: "unavailable" }, kind: "unavailable" }, + { input: { outcome: "opaque", netState: "opaque" }, kind: "opaque" }, + { input: { outcome: "partial", netState: "added" }, kind: "partial" }, + ] + + test("every named state yields a unique label + icon pair", () => { + const descriptors = cases.map((c) => describeLedgerStatus(c.input)) + for (const [i, c] of cases.entries()) expect(descriptors[i].kind).toBe(c.kind) + expect(new Set(descriptors.map((d) => d.label)).size).toBe(cases.length) + expect(new Set(descriptors.map((d) => d.icon)).size).toBe(cases.length) + }) + + test("distinguishers are text + icon (not color): tone is a semantic keyword, never a raw color", () => { + for (const c of cases) { + const d = describeLedgerStatus(c.input) + expect(d.label.length).toBeGreaterThan(0) + expect(d.icon.length).toBeGreaterThan(0) + expect(["success", "danger", "warning", "neutral"]).toContain(d.tone) + expect(d.tone).not.toMatch(/#|rgb|hsl/i) + } + }) + + test("accessible name always contains execution, assessment, and evidence state", () => { + const d = describeLedgerStatus({ outcome: "succeeded", netState: "added", evidenceState: "available" }) + expect(d.accessibleName.toLowerCase()).toContain("succeeded") + expect(d.accessibleName.toLowerCase()).toContain("added") + expect(d.accessibleName.toLowerCase()).toContain("available") + }) + + test("an unavailable assessment never hides the immutable execution fact", () => { + // real net state present + evidence unavailable → keep the execution/net fact, note evidence + const d = describeLedgerStatus({ outcome: "succeeded", netState: "modified", evidenceState: "unavailable" }) + expect(d.kind).toBe("modified") + expect(d.accessibleName.toLowerCase()).toContain("succeeded") + expect(d.accessibleName.toLowerCase()).toContain("unavailable") + }) + + test("theme-independent: the descriptor carries no theme branch or inline color", () => { + const light = describeLedgerStatus({ netState: "deleted", outcome: "succeeded" }) + const dark = describeLedgerStatus({ netState: "deleted", outcome: "succeeded" }) + expect(light).toEqual(dark) + }) +}) + +describe("buildLedgerView — paging preserves grouping and origin context (AC6)", () => { + test("pages resources while keeping each row's grouping and origins intact", () => { + const records: LedgerProjectionRecord[] = [] + for (let i = 0; i < 5; i++) { + records.push( + record({ + resourceKey: `res-${i}`, + operation: { origin: "agent", session: "root" }, + receipt: { id: `r${i}`, sequence: i + 1, resource: `f${i}.ts`, operation: "create", outcome: "succeeded", timeCreated: i }, + assessment: { receiptID: `r${i}`, netState: "added", evidenceState: "available", revision: 1, timeCreated: i }, + }), + ) + } + const first = buildLedgerView({ capability: { mode: "full" }, page: { size: 2 }, records }) + expect(first.resources).toHaveLength(2) + expect(first.page.total).toBe(5) + expect(first.page.nextCursor).toBe(2) + expect(first.resources[0].origins).toEqual(["agent"]) + + const next = buildLedgerView({ capability: { mode: "full" }, page: { size: 2, cursor: first.page.nextCursor }, records }) + expect(next.resources).toHaveLength(2) + expect(next.page.nextCursor).toBe(4) + }) + + test("pageReceiptHistory independently pages an expanded resource's history", () => { + const view = buildLedgerView({ + capability: { mode: "full" }, + records: Array.from({ length: 4 }, (_, i) => + record({ + resourceKey: "res-1", + receipt: { id: `r${i}`, sequence: i + 1, resource: "a.ts", operation: "edit", outcome: "succeeded", timeCreated: i }, + }), + ), + }) + const paged = pageReceiptHistory(view.resources[0], { cursor: 0, limit: 2 }) + expect(paged.entries.map((e) => e.receiptID)).toEqual(["r0", "r1"]) + expect(paged.nextCursor).toBe(2) + }) +}) + +describe("ledgerCapabilityLabel — full/partial/legacy fixed text (AC7)", () => { + test("each mode has fixed explanatory text selected only by capability", () => { + expect(ledgerCapabilityLabel("full")).toEqual(LEDGER_CAPABILITY_LABELS.full) + expect(ledgerCapabilityLabel("partial")).toEqual(LEDGER_CAPABILITY_LABELS.partial) + expect(ledgerCapabilityLabel("legacy")).toEqual(LEDGER_CAPABILITY_LABELS.legacy) + }) + + test("legacy and partial never imply full historical provenance", () => { + expect(LEDGER_CAPABILITY_LABELS.legacy.description.toLowerCase()).toContain("not") + expect(LEDGER_CAPABILITY_LABELS.partial.description.toLowerCase()).toMatch(/before|not|upgrad/) + expect(LEDGER_CAPABILITY_LABELS.full.description.toLowerCase()).not.toContain("legacy") + }) + + test("the built view surfaces the capability label for accessible status text", () => { + const view = buildLedgerView({ capability: { mode: "legacy" }, records: [] }) + expect(view.capability).toEqual(LEDGER_CAPABILITY_LABELS.legacy) + }) +}) + +describe("applyLedgerKeyDown — keyboard operation (AC8)", () => { + function ev(key: string) { + let prevented = false + return { event: { key, preventDefault: () => (prevented = true) }, prevented: () => prevented } + } + + test("Enter and Space on a resource row toggle it and prevent default", () => { + for (const key of ["Enter", " "]) { + const e = ev(key) + const action = applyLedgerKeyDown(e.event, { focusKind: "row", id: "res-1", expanded: false }) + expect(action).toEqual({ type: "toggle", id: "res-1" }) + expect(e.prevented()).toBe(true) + } + }) + + test("Escape inside expanded content collapses and returns focus to its row", () => { + const e = ev("Escape") + const action = applyLedgerKeyDown(e.event, { focusKind: "content", id: "res-1", expanded: true }) + expect(action).toEqual({ type: "collapse", id: "res-1", refocus: "res-1" }) + expect(e.prevented()).toBe(true) + }) + + test("Escape on an expanded row collapses it", () => { + const action = applyLedgerKeyDown(ev("Escape").event, { focusKind: "row", id: "res-1", expanded: true }) + expect(action).toEqual({ type: "collapse", id: "res-1", refocus: "res-1" }) + }) + + test("unrelated keys and collapsed-row escape are no-ops", () => { + expect(applyLedgerKeyDown(ev("a").event, { focusKind: "row", id: "res-1", expanded: false })).toEqual({ type: "none" }) + expect(applyLedgerKeyDown(ev("Escape").event, { focusKind: "row", id: "res-1", expanded: false })).toEqual({ type: "none" }) + }) +}) diff --git a/packages/app/src/pages/session/v2/lineage-ledger-view.ts b/packages/app/src/pages/session/v2/lineage-ledger-view.ts new file mode 100644 index 0000000000..3c73aa32fe --- /dev/null +++ b/packages/app/src/pages/session/v2/lineage-ledger-view.ts @@ -0,0 +1,444 @@ +// The unified lineage-ledger view-model (amicode#1082). +// +// This module is the render slice's data core: it turns the server's +// display-safe lineage-ledger projection (the #1078 `browser`-boundary +// projection — see packages/opencode/src/session/receipt-privacy.ts) into the +// Files Changed render model. It consumes ONLY projected facts and assessments; +// it has no path to tool-metadata or filesystem-watcher inference (amicode#1082 +// AC1), and no host-only capability, path, evidence, or redaction field is part +// of its input type (those are denied at the browser boundary by construction). + +/** Capability discovery result — the sole selector between the three views. */ +export type LedgerCapabilityMode = "full" | "partial" | "legacy" + +/** One immutable receipt fact as projected to the browser. */ +export type LedgerProjectionReceipt = { + id?: string + sequence?: number + resource?: string + operation?: string + outcome?: string + timeCreated?: number +} + +/** One append-only assessment revision as projected to the browser. */ +export type LedgerProjectionAssessment = { + receiptID?: string + confidence?: string + netState?: string + evidenceState?: string + revision?: number + expiresAt?: number + timeCreated?: number +} + +/** + * One browser-projected ledger record: operation context + an immutable receipt + * fact and/or an assessment revision + derived counts. `resourceKey` and + * `operation.session` are display-safe grouping identities the server projects + * (a stable logical resource id and a source-session label) — NOT the host-only + * canonical path or raw session id, which stay denied at this boundary. When + * `resourceKey` is absent the display path groups the resource. + */ +export type LedgerProjectionRecord = { + resourceKey?: string + operation?: { origin?: string; session?: string; state?: string } + receipt?: LedgerProjectionReceipt + assessment?: LedgerProjectionAssessment + derived?: { additions?: number; deletions?: number } +} + +export type LedgerCapabilityLabel = { + mode: LedgerCapabilityMode + label: string + description: string +} + +// Fixed explanatory text, selected only by capability discovery (amicode#1082 +// AC7). Legacy and partial never imply full historical provenance. +export const LEDGER_CAPABILITY_LABELS: Record = { + full: { + mode: "full", + label: "Full provenance", + description: "Every session-visible change is recorded in the lineage ledger.", + }, + partial: { + mode: "partial", + label: "Partial provenance", + description: "This session upgraded mid-run; changes made before the upgrade are not in the ledger.", + }, + legacy: { + mode: "legacy", + label: "Legacy view", + description: "This session predates the lineage ledger, so full historical provenance is not available.", + }, +} + +export function ledgerCapabilityLabel(mode: LedgerCapabilityMode): LedgerCapabilityLabel { + return LEDGER_CAPABILITY_LABELS[mode] +} + +// --- Status semantics (amicode#1082 AC5) -------------------------------------- + +export type LedgerStatusKind = + | "added" + | "modified" + | "deleted" + | "reverted" + | "conflicted" + | "unavailable" + | "opaque" + | "partial" + | "unknown" + +/** A semantic tone keyword (never a raw color) — color is redundant to icon + text. */ +export type LedgerStatusTone = "success" | "danger" | "warning" | "neutral" + +/** + * The status icon glyphs, as a literal subset of the shared icon-name union. + * Kept UI-package-free so the view model stays testable without the UI deps; + * every literal here is a valid `@opencode-ai/ui/icon` name, so the component + * assigns it to `Icon` with no cast. + */ +export type LedgerStatusIcon = + | "plus" + | "edit-small-2" + | "trash" + | "arrow-undo-down" + | "warning" + | "circle-ban-sign" + | "glasses" + | "dash" + | "help" + +export type LedgerStatusInput = { + operation?: string + outcome?: string + netState?: string + evidenceState?: string +} + +export type LedgerStatusDescriptor = { + kind: LedgerStatusKind + label: string + icon: LedgerStatusIcon + tone: LedgerStatusTone + accessibleName: string +} + +// Each state pairs a distinct text label with a distinct icon glyph, so the +// status is legible in both themes without depending on color. +const STATUS_PRESENTATION: Record = { + added: { label: "Added", icon: "plus", tone: "success" }, + modified: { label: "Modified", icon: "edit-small-2", tone: "neutral" }, + deleted: { label: "Deleted", icon: "trash", tone: "danger" }, + reverted: { label: "Reverted", icon: "arrow-undo-down", tone: "neutral" }, + conflicted: { label: "Conflicted", icon: "warning", tone: "danger" }, + unavailable: { label: "Unavailable", icon: "circle-ban-sign", tone: "warning" }, + opaque: { label: "Opaque", icon: "glasses", tone: "warning" }, + partial: { label: "Partial", icon: "dash", tone: "warning" }, + unknown: { label: "Unknown", icon: "help", tone: "warning" }, +} + +export function describeLedgerStatus(input: LedgerStatusInput): LedgerStatusDescriptor { + const kind = statusKind(input) + const presentation = STATUS_PRESENTATION[kind] + const accessibleName = `${presentation.label}. Execution ${input.outcome ?? "unknown"}. Assessment ${ + input.netState ?? "none" + }. Evidence ${input.evidenceState ?? "unavailable"}.` + return { kind, label: presentation.label, icon: presentation.icon, tone: presentation.tone, accessibleName } +} + +// Precedence (amicode#1082 deliberation): the immutable execution outcome is +// always reflected; the latest assessment supplies net + evidence; an +// unavailable *assessment* never hides the execution fact (only netState +// "unavailable" selects that kind — an unavailable evidence bit does not). +function statusKind(input: LedgerStatusInput): LedgerStatusKind { + if (input.outcome === "partial") return "partial" + if (input.netState === "conflicted" || input.outcome === "conflicted") return "conflicted" + if (input.netState === "unavailable") return "unavailable" + if (input.netState === "opaque" || (!input.netState && input.outcome === "opaque")) return "opaque" + if (input.netState === "added") return "added" + if (input.netState === "modified") return "modified" + if (input.netState === "deleted") return "deleted" + if (input.netState === "reverted") return "reverted" + const operation = (input.operation ?? "").toLowerCase() + if (operation.includes("delete") || operation.includes("trash") || operation.includes("remove")) return "deleted" + if (operation.includes("create") || operation.includes("add")) return "added" + if (operation.includes("revert") || operation.includes("restore")) return "reverted" + return "modified" +} + +// --- The view model ----------------------------------------------------------- + +export type LedgerAssessmentEntry = { + revision: number + confidence?: string + netState?: string + evidenceState?: string + expiresAt?: number + timeCreated?: number +} + +export type LedgerHistoryEntry = { + receiptID: string + sequence: number + operation?: string + outcome?: string + origin?: string + resource?: string + timeCreated?: number + assessments: LedgerAssessmentEntry[] +} + +export type LedgerResourceRow = { + id: string + displayPath: string + aliases: string[] + status: LedgerStatusDescriptor + origins: string[] + sources: string[] + receiptCount: number + evidenceState?: string + history: LedgerHistoryEntry[] +} + +export type LedgerUnknownItem = { + receiptID: string + sequence: number + operation?: string + outcome?: string + origin?: string + timeCreated?: number +} + +export type LedgerView = { + capability: LedgerCapabilityLabel + resources: LedgerResourceRow[] + unknown: LedgerUnknownItem[] + page: { size: number; cursor: number; nextCursor?: number; total: number } +} + +export type LedgerViewInput = { + capability: { mode: LedgerCapabilityMode } + records: readonly LedgerProjectionRecord[] + page?: { size?: number; cursor?: number } +} + +const DEFAULT_PAGE_SIZE = 50 + +export function buildLedgerView(input: LedgerViewInput): LedgerView { + const facts = collectFacts(input.records) + const rows = buildRows(facts).sort(byLatestAssessmentThenId) + const unknown = facts.unknown.slice().sort((a, b) => a.sequence - b.sequence) + + const size = input.page?.size ?? DEFAULT_PAGE_SIZE + const cursor = input.page?.cursor ?? 0 + const total = rows.length + const nextCursor = cursor + size < total ? cursor + size : undefined + + return { + capability: ledgerCapabilityLabel(input.capability.mode), + resources: rows.slice(cursor, cursor + size), + unknown, + page: { size, cursor, nextCursor, total }, + } +} + +/** Independently pages an expanded resource's receipt history (deliberation). */ +export function pageReceiptHistory(row: LedgerResourceRow, input: { cursor?: number; limit: number }) { + const cursor = input.cursor ?? 0 + const entries = row.history.slice(cursor, cursor + input.limit) + const nextCursor = cursor + input.limit < row.history.length ? cursor + input.limit : undefined + return { entries, nextCursor } +} + +type CollectedFact = { + fact: LedgerProjectionReceipt + origin?: string + session?: string + resourceKey?: string +} + +// First-seen wins for a receipt id: an immutable execution fact is never +// rewritten by a later record carrying the same id (amicode#1082 AC3). +function collectFacts(records: readonly LedgerProjectionRecord[]) { + const receipts = new Map() + const order: string[] = [] + const assessments = new Map>() + + for (const rec of records) { + const id = rec.receipt?.id + if (id && !receipts.has(id)) { + receipts.set(id, { + fact: rec.receipt!, + origin: rec.operation?.origin, + session: rec.operation?.session, + resourceKey: rec.resourceKey, + }) + order.push(id) + } + const assessment = rec.assessment + if (assessment?.receiptID) { + const revisions = assessments.get(assessment.receiptID) ?? new Map() + const revision = assessment.revision ?? 1 + if (!revisions.has(revision)) + revisions.set(revision, { + revision, + confidence: assessment.confidence, + netState: assessment.netState, + evidenceState: assessment.evidenceState, + expiresAt: assessment.expiresAt, + timeCreated: assessment.timeCreated, + }) + assessments.set(assessment.receiptID, revisions) + } + } + + const known: string[] = [] + const unknown: LedgerUnknownItem[] = [] + for (const id of order) { + const entry = receipts.get(id)! + // A receipt with no resource identity is an Unknown Mutation Receipt — an + // uncertainty item, never a fabricated resource row (amicode#1082 AC4). + if (!entry.fact.resource) { + unknown.push({ + receiptID: id, + sequence: entry.fact.sequence ?? 0, + operation: entry.fact.operation, + outcome: entry.fact.outcome, + origin: entry.origin, + timeCreated: entry.fact.timeCreated, + }) + continue + } + known.push(id) + } + + return { receipts, known, unknown, assessments } +} + +function buildRows(facts: ReturnType): LedgerResourceRow[] { + const groups = new Map() + const groupOrder: string[] = [] + for (const id of facts.known) { + const entry = facts.receipts.get(id)! + const key = entry.resourceKey ?? entry.fact.resource! + if (!groups.has(key)) { + groups.set(key, []) + groupOrder.push(key) + } + groups.get(key)!.push(id) + } + + return groupOrder.map((key) => { + const ids = groups + .get(key)! + .slice() + .sort((a, b) => sequenceOf(facts, a) - sequenceOf(facts, b)) + const history: LedgerHistoryEntry[] = ids.map((id) => { + const entry = facts.receipts.get(id)! + const revisions = [...(facts.assessments.get(id)?.values() ?? [])].sort((a, b) => a.revision - b.revision) + return { + receiptID: id, + sequence: entry.fact.sequence ?? 0, + operation: entry.fact.operation, + outcome: entry.fact.outcome, + origin: entry.origin, + resource: entry.fact.resource, + timeCreated: entry.fact.timeCreated, + assessments: revisions, + } + }) + + const latest = history[history.length - 1] + const displayPath = latest.resource ?? "" + const finalAssessment = latestAssessment(history) + return { + id: key, + displayPath, + aliases: distinct(history.map((entry) => entry.resource).filter((path): path is string => !!path && path !== displayPath)), + status: describeLedgerStatus({ + operation: latest.operation, + outcome: latest.outcome, + netState: finalAssessment?.netState, + evidenceState: finalAssessment?.evidenceState, + }), + origins: distinct(history.map((entry) => entry.origin).filter((origin): origin is string => !!origin)), + sources: distinct(ids.map((id) => facts.receipts.get(id)!.session).filter((session): session is string => !!session)), + receiptCount: ids.length, + evidenceState: finalAssessment?.evidenceState, + history, + } + }) +} + +// The final assessment is the one on the highest-sequence receipt, at its +// highest revision — the current net + evidence state for the resource. +function latestAssessment(history: LedgerHistoryEntry[]): LedgerAssessmentEntry | undefined { + let winner: LedgerAssessmentEntry | undefined + let winnerSequence = -1 + let winnerRevision = -1 + for (const entry of history) + for (const assessment of entry.assessments) + if (entry.sequence > winnerSequence || (entry.sequence === winnerSequence && assessment.revision > winnerRevision)) { + winner = assessment + winnerSequence = entry.sequence + winnerRevision = assessment.revision + } + return winner +} + +// Sort resources by latest assessment revision (most-reassessed first), then by +// stable resource id (amicode#1082 deliberation). +function byLatestAssessmentThenId(a: LedgerResourceRow, b: LedgerResourceRow): number { + const revisionDelta = maxRevision(b) - maxRevision(a) + if (revisionDelta !== 0) return revisionDelta + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 +} + +function maxRevision(row: LedgerResourceRow): number { + let max = 0 + for (const entry of row.history) for (const assessment of entry.assessments) max = Math.max(max, assessment.revision) + return max +} + +function sequenceOf(facts: ReturnType, id: string): number { + return facts.receipts.get(id)!.fact.sequence ?? 0 +} + +function distinct(values: string[]): string[] { + return [...new Set(values)] +} + +// --- Keyboard operation (amicode#1082 AC8) ------------------------------------ + +export type LedgerKeyContext = { + focusKind: "row" | "content" + id: string + expanded: boolean +} + +export type LedgerKeyAction = + | { type: "toggle"; id: string } + | { type: "collapse"; id: string; refocus: string } + | { type: "none" } + +// Enter or Space on a resource row toggles it; Escape collapses an expanded +// resource and returns focus to its row. Tab order is native DOM order (the +// component renders filters, rows, continuations, and unknown items in reading +// order), so it needs no reducer here. +export function applyLedgerKeyDown( + event: { key: string; preventDefault: () => void }, + context: LedgerKeyContext, +): LedgerKeyAction { + if (context.focusKind === "row" && (event.key === "Enter" || event.key === " ")) { + event.preventDefault() + return { type: "toggle", id: context.id } + } + if (event.key === "Escape" && context.expanded) { + event.preventDefault() + return { type: "collapse", id: context.id, refocus: context.id } + } + return { type: "none" } +} diff --git a/packages/core/schema.json b/packages/core/schema.json index 698e2f1ad0..718b65eef5 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,9 +1,9 @@ { "version": "7", "dialect": "sqlite", - "id": "68fc67ad-c3bd-4c0f-923d-db9817bf7475", + "id": "f984d12a-8384-4efa-a337-4a1518eba1f1", "prevIds": [ - "169b3aad-5dd0-4a14-a772-423b22ab2217" + "d9a74873-59fb-4487-9bf1-76477c0f831c" ], "ddl": [ { @@ -70,10 +70,30 @@ "name": "session_input", "entityType": "tables" }, + { + "name": "session_lineage_origin", + "entityType": "tables" + }, + { + "name": "session_lineage", + "entityType": "tables" + }, { "name": "session_message", "entityType": "tables" }, + { + "name": "session_receipt_assessment", + "entityType": "tables" + }, + { + "name": "session_receipt_operation", + "entityType": "tables" + }, + { + "name": "session_receipt", + "entityType": "tables" + }, { "name": "session", "entityType": "tables" @@ -1068,13 +1088,13 @@ }, { "type": "text", - "notNull": false, + "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "id", + "name": "root_id", "entityType": "columns", - "table": "session_message" + "table": "session_lineage_origin" }, { "type": "text", @@ -1084,7 +1104,7 @@ "generated": null, "name": "session_id", "entityType": "columns", - "table": "session_message" + "table": "session_lineage_origin" }, { "type": "text", @@ -1092,29 +1112,19 @@ "autoincrement": false, "default": null, "generated": null, - "name": "type", - "entityType": "columns", - "table": "session_message" - }, - { - "type": "integer", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "seq", + "name": "title", "entityType": "columns", - "table": "session_message" + "table": "session_lineage_origin" }, { - "type": "integer", + "type": "text", "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "time_created", + "name": "edge_kind", "entityType": "columns", - "table": "session_message" + "table": "session_lineage_origin" }, { "type": "integer", @@ -1122,29 +1132,29 @@ "autoincrement": false, "default": null, "generated": null, - "name": "time_updated", + "name": "deleted_at", "entityType": "columns", - "table": "session_message" + "table": "session_lineage_origin" }, { "type": "text", - "notNull": true, + "notNull": false, "autoincrement": false, "default": null, "generated": null, - "name": "data", + "name": "session_id", "entityType": "columns", - "table": "session_message" + "table": "session_lineage" }, { "type": "text", - "notNull": false, + "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "id", + "name": "root_id", "entityType": "columns", - "table": "session" + "table": "session_lineage" }, { "type": "text", @@ -1152,9 +1162,9 @@ "autoincrement": false, "default": null, "generated": null, - "name": "project_id", + "name": "mode", "entityType": "columns", - "table": "session" + "table": "session_lineage" }, { "type": "text", @@ -1162,9 +1172,9 @@ "autoincrement": false, "default": null, "generated": null, - "name": "workspace_id", + "name": "parent_id", "entityType": "columns", - "table": "session" + "table": "session_lineage" }, { "type": "text", @@ -1172,29 +1182,29 @@ "autoincrement": false, "default": null, "generated": null, - "name": "parent_id", + "name": "edge_kind", "entityType": "columns", - "table": "session" + "table": "session_lineage" }, { "type": "text", - "notNull": true, + "notNull": false, "autoincrement": false, "default": null, "generated": null, - "name": "slug", + "name": "legacy_parent_id", "entityType": "columns", - "table": "session" + "table": "session_lineage" }, { - "type": "text", - "notNull": true, + "type": "integer", + "notNull": false, "autoincrement": false, "default": null, "generated": null, - "name": "directory", + "name": "epoch_started_at", "entityType": "columns", - "table": "session" + "table": "session_lineage" }, { "type": "text", @@ -1202,19 +1212,19 @@ "autoincrement": false, "default": null, "generated": null, - "name": "directories", + "name": "id", "entityType": "columns", - "table": "session" + "table": "session_message" }, { "type": "text", - "notNull": false, + "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "path", + "name": "session_id", "entityType": "columns", - "table": "session" + "table": "session_message" }, { "type": "text", @@ -1222,139 +1232,139 @@ "autoincrement": false, "default": null, "generated": null, - "name": "title", + "name": "type", "entityType": "columns", - "table": "session" + "table": "session_message" }, { - "type": "text", + "type": "integer", "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "version", + "name": "seq", "entityType": "columns", - "table": "session" + "table": "session_message" }, { - "type": "text", - "notNull": false, + "type": "integer", + "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "share_url", + "name": "time_created", "entityType": "columns", - "table": "session" + "table": "session_message" }, { "type": "integer", - "notNull": false, + "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "summary_additions", + "name": "time_updated", "entityType": "columns", - "table": "session" + "table": "session_message" }, { - "type": "integer", - "notNull": false, + "type": "text", + "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "summary_deletions", + "name": "data", "entityType": "columns", - "table": "session" + "table": "session_message" }, { - "type": "integer", + "type": "text", "notNull": false, "autoincrement": false, "default": null, "generated": null, - "name": "summary_files", + "name": "id", "entityType": "columns", - "table": "session" + "table": "session_receipt_assessment" }, { "type": "text", - "notNull": false, + "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "summary_diffs", + "name": "receipt_id", "entityType": "columns", - "table": "session" + "table": "session_receipt_assessment" }, { "type": "text", - "notNull": false, + "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "metadata", + "name": "root_id", "entityType": "columns", - "table": "session" + "table": "session_receipt_assessment" }, { - "type": "real", + "type": "text", "notNull": true, "autoincrement": false, - "default": "0", + "default": null, "generated": null, - "name": "cost", + "name": "confidence", "entityType": "columns", - "table": "session" + "table": "session_receipt_assessment" }, { - "type": "integer", + "type": "text", "notNull": true, "autoincrement": false, - "default": "0", + "default": null, "generated": null, - "name": "tokens_input", + "name": "net_state", "entityType": "columns", - "table": "session" + "table": "session_receipt_assessment" }, { - "type": "integer", + "type": "text", "notNull": true, "autoincrement": false, - "default": "0", + "default": null, "generated": null, - "name": "tokens_output", + "name": "evidence_state", "entityType": "columns", - "table": "session" + "table": "session_receipt_assessment" }, { "type": "integer", "notNull": true, "autoincrement": false, - "default": "0", + "default": null, "generated": null, - "name": "tokens_reasoning", + "name": "revision", "entityType": "columns", - "table": "session" + "table": "session_receipt_assessment" }, { "type": "integer", - "notNull": true, + "notNull": false, "autoincrement": false, - "default": "0", + "default": null, "generated": null, - "name": "tokens_cache_read", + "name": "expires_at", "entityType": "columns", - "table": "session" + "table": "session_receipt_assessment" }, { "type": "integer", "notNull": true, "autoincrement": false, - "default": "0", + "default": null, "generated": null, - "name": "tokens_cache_write", + "name": "time_created", "entityType": "columns", - "table": "session" + "table": "session_receipt_assessment" }, { "type": "text", @@ -1362,79 +1372,79 @@ "autoincrement": false, "default": null, "generated": null, - "name": "revert", + "name": "id", "entityType": "columns", - "table": "session" + "table": "session_receipt_operation" }, { "type": "text", - "notNull": false, + "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "permission", + "name": "root_id", "entityType": "columns", - "table": "session" + "table": "session_receipt_operation" }, { "type": "text", - "notNull": false, + "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "agent", + "name": "session_id", "entityType": "columns", - "table": "session" + "table": "session_receipt_operation" }, { "type": "text", - "notNull": false, + "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "model", + "name": "origin", "entityType": "columns", - "table": "session" + "table": "session_receipt_operation" }, { "type": "integer", "notNull": true, "autoincrement": false, - "default": null, + "default": "0", "generated": null, - "name": "time_created", + "name": "reserved_receipts", "entityType": "columns", - "table": "session" + "table": "session_receipt_operation" }, { "type": "integer", "notNull": true, "autoincrement": false, - "default": null, + "default": "0", "generated": null, - "name": "time_updated", + "name": "reserved_metadata_bytes", "entityType": "columns", - "table": "session" + "table": "session_receipt_operation" }, { - "type": "integer", - "notNull": false, + "type": "text", + "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "time_compacting", + "name": "state", "entityType": "columns", - "table": "session" + "table": "session_receipt_operation" }, { - "type": "integer", + "type": "text", "notNull": false, "autoincrement": false, "default": null, "generated": null, - "name": "time_archived", + "name": "id", "entityType": "columns", - "table": "session" + "table": "session_receipt" }, { "type": "text", @@ -1442,9 +1452,9 @@ "autoincrement": false, "default": null, "generated": null, - "name": "session_id", + "name": "operation_id", "entityType": "columns", - "table": "todo" + "table": "session_receipt" }, { "type": "text", @@ -1452,19 +1462,19 @@ "autoincrement": false, "default": null, "generated": null, - "name": "content", + "name": "root_id", "entityType": "columns", - "table": "todo" + "table": "session_receipt" }, { - "type": "text", + "type": "integer", "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "status", + "name": "creation_seq", "entityType": "columns", - "table": "todo" + "table": "session_receipt" }, { "type": "text", @@ -1472,29 +1482,29 @@ "autoincrement": false, "default": null, "generated": null, - "name": "priority", + "name": "resource", "entityType": "columns", - "table": "todo" + "table": "session_receipt" }, { - "type": "integer", + "type": "text", "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "position", + "name": "operation", "entityType": "columns", - "table": "todo" + "table": "session_receipt" }, { - "type": "integer", + "type": "text", "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "time_created", + "name": "outcome", "entityType": "columns", - "table": "todo" + "table": "session_receipt" }, { "type": "integer", @@ -1502,9 +1512,9 @@ "autoincrement": false, "default": null, "generated": null, - "name": "time_updated", + "name": "time_created", "entityType": "columns", - "table": "todo" + "table": "session_receipt" }, { "type": "text", @@ -1512,9 +1522,9 @@ "autoincrement": false, "default": null, "generated": null, - "name": "session_id", + "name": "id", "entityType": "columns", - "table": "session_share" + "table": "session" }, { "type": "text", @@ -1522,65 +1532,435 @@ "autoincrement": false, "default": null, "generated": null, - "name": "id", + "name": "project_id", "entityType": "columns", - "table": "session_share" + "table": "session" }, { "type": "text", - "notNull": true, + "notNull": false, "autoincrement": false, "default": null, "generated": null, - "name": "secret", + "name": "workspace_id", "entityType": "columns", - "table": "session_share" + "table": "session" }, { "type": "text", - "notNull": true, + "notNull": false, "autoincrement": false, "default": null, "generated": null, - "name": "url", + "name": "parent_id", "entityType": "columns", - "table": "session_share" + "table": "session" }, { - "type": "integer", + "type": "text", "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "time_created", + "name": "slug", "entityType": "columns", - "table": "session_share" + "table": "session" }, { - "type": "integer", + "type": "text", "notNull": true, "autoincrement": false, "default": null, "generated": null, - "name": "time_updated", + "name": "directory", "entityType": "columns", - "table": "session_share" + "table": "session" }, { - "columns": [ - "project_id" - ], - "tableTo": "project", - "columnsTo": [ - "id" - ], - "onUpdate": "NO ACTION", - "onDelete": "CASCADE", - "nameExplicit": false, - "name": "fk_workspace_project_id_project_id_fk", - "entityType": "fks", - "table": "workspace" - }, + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directories", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "session" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_input", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_output", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_reasoning", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_read", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_write", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, { "columns": [ "active_account_id" @@ -1716,6 +2096,21 @@ "entityType": "fks", "table": "session_input" }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_lineage_session_id_session_id_fk", + "entityType": "fks", + "table": "session_lineage" + }, { "columns": [ "session_id" @@ -1731,6 +2126,81 @@ "entityType": "fks", "table": "session_message" }, + { + "columns": [ + "receipt_id" + ], + "tableTo": "session_receipt", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_receipt_assessment_receipt_id_session_receipt_id_fk", + "entityType": "fks", + "table": "session_receipt_assessment" + }, + { + "columns": [ + "root_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_receipt_assessment_root_id_session_id_fk", + "entityType": "fks", + "table": "session_receipt_assessment" + }, + { + "columns": [ + "root_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_receipt_operation_root_id_session_id_fk", + "entityType": "fks", + "table": "session_receipt_operation" + }, + { + "columns": [ + "operation_id" + ], + "tableTo": "session_receipt_operation", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_receipt_operation_id_session_receipt_operation_id_fk", + "entityType": "fks", + "table": "session_receipt" + }, + { + "columns": [ + "root_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_receipt_root_id_session_id_fk", + "entityType": "fks", + "table": "session_receipt" + }, { "columns": [ "project_id" @@ -1796,6 +2266,16 @@ "entityType": "pks", "table": "project_directory" }, + { + "columns": [ + "root_id", + "session_id" + ], + "nameExplicit": false, + "name": "session_lineage_origin_pk", + "entityType": "pks", + "table": "session_lineage_origin" + }, { "columns": [ "session_id", @@ -1932,6 +2412,15 @@ "table": "session_input", "entityType": "pks" }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_lineage_pk", + "table": "session_lineage", + "entityType": "pks" + }, { "columns": [ "id" @@ -1941,6 +2430,33 @@ "table": "session_message", "entityType": "pks" }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_receipt_assessment_pk", + "table": "session_receipt_assessment", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_receipt_operation_pk", + "table": "session_receipt_operation", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_receipt_pk", + "table": "session_receipt", + "entityType": "pks" + }, { "columns": [ "id" @@ -2163,6 +2679,48 @@ "entityType": "indexes", "table": "session_input" }, + { + "columns": [ + { + "value": "root_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_lineage_origin_root_idx", + "entityType": "indexes", + "table": "session_lineage_origin" + }, + { + "columns": [ + { + "value": "root_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_lineage_root_idx", + "entityType": "indexes", + "table": "session_lineage" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_lineage_parent_idx", + "entityType": "indexes", + "table": "session_lineage" + }, { "columns": [ { @@ -2239,6 +2797,84 @@ "entityType": "indexes", "table": "session_message" }, + { + "columns": [ + { + "value": "receipt_id", + "isExpression": false + }, + { + "value": "revision", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_receipt_assessment_receipt_revision_idx", + "entityType": "indexes", + "table": "session_receipt_assessment" + }, + { + "columns": [ + { + "value": "root_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_receipt_assessment_root_idx", + "entityType": "indexes", + "table": "session_receipt_assessment" + }, + { + "columns": [ + { + "value": "root_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_receipt_operation_root_idx", + "entityType": "indexes", + "table": "session_receipt_operation" + }, + { + "columns": [ + { + "value": "root_id", + "isExpression": false + }, + { + "value": "creation_seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_receipt_root_creation_seq_idx", + "entityType": "indexes", + "table": "session_receipt" + }, + { + "columns": [ + { + "value": "operation_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_receipt_operation_idx", + "entityType": "indexes", + "table": "session_receipt" + }, { "columns": [ { diff --git a/packages/core/script/migration.ts b/packages/core/script/migration.ts index 4f383f5f8f..cb1d2b5c92 100644 --- a/packages/core/script/migration.ts +++ b/packages/core/script/migration.ts @@ -147,12 +147,23 @@ export default { up(tx) { return Effect.gen(function* () { ${renderStatements(sql)} +${renderReceiptTriggers()} }) }, } satisfies Omit ` } +function renderReceiptTriggers() { + return [ + "CREATE TRIGGER session_receipt_immutable BEFORE UPDATE ON session_receipt BEGIN SELECT RAISE(ABORT, 'session receipt facts are immutable'); END;", + "CREATE TRIGGER session_receipt_assessment_append_only BEFORE UPDATE ON session_receipt_assessment BEGIN SELECT RAISE(ABORT, 'session receipt assessments are append-only'); END;", + "CREATE TRIGGER session_receipt_operation_state BEFORE UPDATE OF state ON session_receipt_operation WHEN NOT ((OLD.state = 'prepared' AND NEW.state = 'evidence_ready') OR (OLD.state = 'evidence_ready' AND NEW.state = 'committed')) BEGIN SELECT RAISE(ABORT, 'invalid session receipt operation state transition'); END;", + ] + .map((statement) => ` yield* tx.run(${JSON.stringify(statement)})`) + .join("\n") +} + function renderStatements(sql: string) { return sql .split("--> statement-breakpoint") diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index 62455690e6..b26af00642 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -59,6 +59,11 @@ const MERGE_TABLES = [ "event", "permission", "session", + "session_lineage", + "session_lineage_origin", + "session_receipt_operation", + "session_receipt", + "session_receipt_assessment", "session_message", "session_input", "session_context_epoch", diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index cf0271a668..19ca0c19f8 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -43,5 +43,8 @@ export const migrations = ( import("./migration/20260813162312_shocking_karnak"), import("./migration/20260820000001_add_session_directories"), import("./migration/20260828201050_normal_stryfe"), + import("./migration/20260913205004_session-lineage"), + import("./migration/20260913212936_session-receipt-storage"), + import("./migration/20260913221452_session-receipt-budget-reservation"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260913205004_session-lineage.ts b/packages/core/src/database/migration/20260913205004_session-lineage.ts new file mode 100644 index 0000000000..01550c33ff --- /dev/null +++ b/packages/core/src/database/migration/20260913205004_session-lineage.ts @@ -0,0 +1,35 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260913205004_session-lineage", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`session_lineage_origin\` ( + \`root_id\` text NOT NULL, + \`session_id\` text NOT NULL, + \`title\` text NOT NULL, + \`edge_kind\` text NOT NULL, + \`deleted_at\` integer NOT NULL, + CONSTRAINT \`session_lineage_origin_pk\` PRIMARY KEY(\`root_id\`, \`session_id\`) + ); + `) + yield* tx.run(` + CREATE TABLE \`session_lineage\` ( + \`session_id\` text PRIMARY KEY, + \`root_id\` text NOT NULL, + \`mode\` text NOT NULL, + \`parent_id\` text, + \`edge_kind\` text, + \`legacy_parent_id\` text, + \`epoch_started_at\` integer, + CONSTRAINT \`fk_session_lineage_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(`CREATE INDEX \`session_lineage_origin_root_idx\` ON \`session_lineage_origin\` (\`root_id\`);`) + yield* tx.run(`CREATE INDEX \`session_lineage_root_idx\` ON \`session_lineage\` (\`root_id\`);`) + yield* tx.run(`CREATE INDEX \`session_lineage_parent_idx\` ON \`session_lineage\` (\`parent_id\`);`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260913212936_session-receipt-storage.ts b/packages/core/src/database/migration/20260913212936_session-receipt-storage.ts new file mode 100644 index 0000000000..81024a3b52 --- /dev/null +++ b/packages/core/src/database/migration/20260913212936_session-receipt-storage.ts @@ -0,0 +1,87 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260913212936_session-receipt-storage", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`session_receipt_assessment\` ( + \`id\` text PRIMARY KEY, + \`receipt_id\` text NOT NULL, + \`root_id\` text NOT NULL, + \`confidence\` text NOT NULL, + \`net_state\` text NOT NULL, + \`evidence_state\` text NOT NULL, + \`revision\` integer NOT NULL, + \`expires_at\` integer, + \`time_created\` integer NOT NULL, + CONSTRAINT \`fk_session_receipt_assessment_receipt_id_session_receipt_id_fk\` FOREIGN KEY (\`receipt_id\`) REFERENCES \`session_receipt\`(\`id\`) ON DELETE CASCADE, + CONSTRAINT \`fk_session_receipt_assessment_root_id_session_id_fk\` FOREIGN KEY (\`root_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(` + CREATE TABLE \`session_receipt_operation\` ( + \`id\` text PRIMARY KEY, + \`root_id\` text NOT NULL, + \`session_id\` text NOT NULL, + \`origin\` text NOT NULL, + \`state\` text NOT NULL, + CONSTRAINT \`fk_session_receipt_operation_root_id_session_id_fk\` FOREIGN KEY (\`root_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(` + CREATE TABLE \`session_receipt\` ( + \`id\` text PRIMARY KEY, + \`operation_id\` text NOT NULL, + \`root_id\` text NOT NULL, + \`creation_seq\` integer NOT NULL, + \`resource\` text NOT NULL, + \`operation\` text NOT NULL, + \`outcome\` text NOT NULL, + \`time_created\` integer NOT NULL, + CONSTRAINT \`fk_session_receipt_operation_id_session_receipt_operation_id_fk\` FOREIGN KEY (\`operation_id\`) REFERENCES \`session_receipt_operation\`(\`id\`) ON DELETE CASCADE, + CONSTRAINT \`fk_session_receipt_root_id_session_id_fk\` FOREIGN KEY (\`root_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_receipt_assessment_receipt_revision_idx\` ON \`session_receipt_assessment\` (\`receipt_id\`,\`revision\`);`, + ) + yield* tx.run( + `CREATE INDEX \`session_receipt_assessment_root_idx\` ON \`session_receipt_assessment\` (\`root_id\`);`, + ) + yield* tx.run( + `CREATE INDEX \`session_receipt_operation_root_idx\` ON \`session_receipt_operation\` (\`root_id\`);`, + ) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_receipt_root_creation_seq_idx\` ON \`session_receipt\` (\`root_id\`,\`creation_seq\`);`, + ) + yield* tx.run(`CREATE INDEX \`session_receipt_operation_idx\` ON \`session_receipt\` (\`operation_id\`);`) + yield* tx.run(` + CREATE TRIGGER \`session_receipt_immutable\` + BEFORE UPDATE ON \`session_receipt\` + BEGIN + SELECT RAISE(ABORT, 'session receipt facts are immutable'); + END; + `) + yield* tx.run(` + CREATE TRIGGER \`session_receipt_assessment_append_only\` + BEFORE UPDATE ON \`session_receipt_assessment\` + BEGIN + SELECT RAISE(ABORT, 'session receipt assessments are append-only'); + END; + `) + yield* tx.run(` + CREATE TRIGGER \`session_receipt_operation_state\` + BEFORE UPDATE OF \`state\` ON \`session_receipt_operation\` + WHEN NOT ( + (OLD.\`state\` = 'prepared' AND NEW.\`state\` = 'evidence_ready') + OR (OLD.\`state\` = 'evidence_ready' AND NEW.\`state\` = 'committed') + ) + BEGIN + SELECT RAISE(ABORT, 'invalid session receipt operation state transition'); + END; + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260913221452_session-receipt-budget-reservation.ts b/packages/core/src/database/migration/20260913221452_session-receipt-budget-reservation.ts new file mode 100644 index 0000000000..00ec72c105 --- /dev/null +++ b/packages/core/src/database/migration/20260913221452_session-receipt-budget-reservation.ts @@ -0,0 +1,32 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260913221452_session-receipt-budget-reservation", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`PRAGMA foreign_keys=OFF;`) + yield* tx.run(` + CREATE TABLE \`__new_session_receipt_operation\` ( + \`id\` text PRIMARY KEY, + \`root_id\` text NOT NULL, + \`session_id\` text NOT NULL, + \`origin\` text NOT NULL, + \`reserved_receipts\` integer DEFAULT 0 NOT NULL, + \`reserved_metadata_bytes\` integer DEFAULT 0 NOT NULL, + \`state\` text NOT NULL, + CONSTRAINT \`fk_session_receipt_operation_root_id_session_id_fk\` FOREIGN KEY (\`root_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run( + `INSERT INTO \`__new_session_receipt_operation\`(\`id\`, \`root_id\`, \`session_id\`, \`origin\`, \`reserved_receipts\`, \`reserved_metadata_bytes\`, \`state\`) SELECT \`id\`, \`root_id\`, \`session_id\`, \`origin\`, \`reserved_receipts\`, \`reserved_metadata_bytes\`, \`state\` FROM \`session_receipt_operation\`;`, + ) + yield* tx.run(`DROP TABLE \`session_receipt_operation\`;`) + yield* tx.run(`ALTER TABLE \`__new_session_receipt_operation\` RENAME TO \`session_receipt_operation\`;`) + yield* tx.run(`PRAGMA foreign_keys=ON;`) + yield* tx.run( + `CREATE INDEX \`session_receipt_operation_root_idx\` ON \`session_receipt_operation\` (\`root_id\`);`, + ) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 4ff28f1da4..7ce18169e5 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -178,6 +178,28 @@ export default { CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE ); `) + yield* tx.run(` + CREATE TABLE \`session_lineage_origin\` ( + \`root_id\` text NOT NULL, + \`session_id\` text NOT NULL, + \`title\` text NOT NULL, + \`edge_kind\` text NOT NULL, + \`deleted_at\` integer NOT NULL, + CONSTRAINT \`session_lineage_origin_pk\` PRIMARY KEY(\`root_id\`, \`session_id\`) + ); + `) + yield* tx.run(` + CREATE TABLE \`session_lineage\` ( + \`session_id\` text PRIMARY KEY, + \`root_id\` text NOT NULL, + \`mode\` text NOT NULL, + \`parent_id\` text, + \`edge_kind\` text, + \`legacy_parent_id\` text, + \`epoch_started_at\` integer, + CONSTRAINT \`fk_session_lineage_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) yield* tx.run(` CREATE TABLE \`session_message\` ( \`id\` text PRIMARY KEY, @@ -190,6 +212,47 @@ export default { CONSTRAINT \`fk_session_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE ); `) + yield* tx.run(` + CREATE TABLE \`session_receipt_assessment\` ( + \`id\` text PRIMARY KEY, + \`receipt_id\` text NOT NULL, + \`root_id\` text NOT NULL, + \`confidence\` text NOT NULL, + \`net_state\` text NOT NULL, + \`evidence_state\` text NOT NULL, + \`revision\` integer NOT NULL, + \`expires_at\` integer, + \`time_created\` integer NOT NULL, + CONSTRAINT \`fk_session_receipt_assessment_receipt_id_session_receipt_id_fk\` FOREIGN KEY (\`receipt_id\`) REFERENCES \`session_receipt\`(\`id\`) ON DELETE CASCADE, + CONSTRAINT \`fk_session_receipt_assessment_root_id_session_id_fk\` FOREIGN KEY (\`root_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(` + CREATE TABLE \`session_receipt_operation\` ( + \`id\` text PRIMARY KEY, + \`root_id\` text NOT NULL, + \`session_id\` text NOT NULL, + \`origin\` text NOT NULL, + \`reserved_receipts\` integer DEFAULT 0 NOT NULL, + \`reserved_metadata_bytes\` integer DEFAULT 0 NOT NULL, + \`state\` text NOT NULL, + CONSTRAINT \`fk_session_receipt_operation_root_id_session_id_fk\` FOREIGN KEY (\`root_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(` + CREATE TABLE \`session_receipt\` ( + \`id\` text PRIMARY KEY, + \`operation_id\` text NOT NULL, + \`root_id\` text NOT NULL, + \`creation_seq\` integer NOT NULL, + \`resource\` text NOT NULL, + \`operation\` text NOT NULL, + \`outcome\` text NOT NULL, + \`time_created\` integer NOT NULL, + CONSTRAINT \`fk_session_receipt_operation_id_session_receipt_operation_id_fk\` FOREIGN KEY (\`operation_id\`) REFERENCES \`session_receipt_operation\`(\`id\`) ON DELETE CASCADE, + CONSTRAINT \`fk_session_receipt_root_id_session_id_fk\` FOREIGN KEY (\`root_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) yield* tx.run(` CREATE TABLE \`session\` ( \`id\` text PRIMARY KEY, @@ -271,6 +334,9 @@ export default { yield* tx.run( `CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`, ) + yield* tx.run(`CREATE INDEX \`session_lineage_origin_root_idx\` ON \`session_lineage_origin\` (\`root_id\`);`) + yield* tx.run(`CREATE INDEX \`session_lineage_root_idx\` ON \`session_lineage\` (\`root_id\`);`) + yield* tx.run(`CREATE INDEX \`session_lineage_parent_idx\` ON \`session_lineage\` (\`parent_id\`);`) yield* tx.run( `CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`, ) @@ -281,10 +347,32 @@ export default { `CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`, ) yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_receipt_assessment_receipt_revision_idx\` ON \`session_receipt_assessment\` (\`receipt_id\`,\`revision\`);`, + ) + yield* tx.run( + `CREATE INDEX \`session_receipt_assessment_root_idx\` ON \`session_receipt_assessment\` (\`root_id\`);`, + ) + yield* tx.run( + `CREATE INDEX \`session_receipt_operation_root_idx\` ON \`session_receipt_operation\` (\`root_id\`);`, + ) + yield* tx.run( + `CREATE UNIQUE INDEX \`session_receipt_root_creation_seq_idx\` ON \`session_receipt\` (\`root_id\`,\`creation_seq\`);`, + ) + yield* tx.run(`CREATE INDEX \`session_receipt_operation_idx\` ON \`session_receipt\` (\`operation_id\`);`) yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`) yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`) yield* tx.run(`CREATE INDEX \`session_parent_idx\` ON \`session\` (\`parent_id\`);`) yield* tx.run(`CREATE INDEX \`todo_session_idx\` ON \`todo\` (\`session_id\`);`) + yield* tx.run( + "CREATE TRIGGER session_receipt_immutable BEFORE UPDATE ON session_receipt BEGIN SELECT RAISE(ABORT, 'session receipt facts are immutable'); END;", + ) + yield* tx.run( + "CREATE TRIGGER session_receipt_assessment_append_only BEFORE UPDATE ON session_receipt_assessment BEGIN SELECT RAISE(ABORT, 'session receipt assessments are append-only'); END;", + ) + yield* tx.run( + "CREATE TRIGGER session_receipt_operation_state BEFORE UPDATE OF state ON session_receipt_operation WHEN NOT ((OLD.state = 'prepared' AND NEW.state = 'evidence_ready') OR (OLD.state = 'evidence_ready' AND NEW.state = 'committed')) BEGIN SELECT RAISE(ABORT, 'invalid session receipt operation state transition'); END;", + ) }) }, } satisfies Omit diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 1fa7ee6aaa..fb15326ef7 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -66,6 +66,116 @@ export const SessionTable = sqliteTable( ], ) +/** + * Durable Files Changed lineage. A missing row is deliberately meaningful: it + * denotes a pre-rollout (legacy) session, never an inferred relationship. + */ +export const SessionLineageTable = sqliteTable( + "session_lineage", + { + session_id: text() + .$type() + .primaryKey() + .references(() => SessionTable.id, { onDelete: "cascade" }), + root_id: text().$type().notNull(), + mode: text().$type<"legacy" | "partial" | "full">().notNull(), + parent_id: text().$type(), + edge_kind: text().$type<"task_spawn" | "session_spawn">(), + legacy_parent_id: text().$type(), + epoch_started_at: integer(), + }, + (table) => [ + index("session_lineage_root_idx").on(table.root_id), + index("session_lineage_parent_idx").on(table.parent_id), + ], +) + +/** + * The root-owned, deletion-safe minimum required to render historical Files + * Changed receipts. It intentionally excludes child context and evidence. + */ +export const SessionLineageOriginTable = sqliteTable( + "session_lineage_origin", + { + root_id: text().$type().notNull(), + session_id: text().$type().notNull(), + title: text().notNull(), + edge_kind: text().$type<"task_spawn" | "session_spawn">().notNull(), + deleted_at: integer().notNull(), + }, + (table) => [ + primaryKey({ columns: [table.root_id, table.session_id] }), + index("session_lineage_origin_root_idx").on(table.root_id), + ], +) + +/** Immutable root-owned facts for Files Changed operation publication. */ +export const SessionReceiptOperationTable = sqliteTable( + "session_receipt_operation", + { + id: text().primaryKey(), + root_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + session_id: text().$type().notNull(), + origin: text().notNull(), + reserved_receipts: integer().notNull().default(0), + reserved_metadata_bytes: integer().notNull().default(0), + state: text().$type<"prepared" | "evidence_ready" | "committed">().notNull(), + }, + (table) => [index("session_receipt_operation_root_idx").on(table.root_id)], +) + +/** Immutable resource facts. Creation sequence is scoped to the lineage root. */ +export const SessionReceiptTable = sqliteTable( + "session_receipt", + { + id: text().primaryKey(), + operation_id: text() + .notNull() + .references(() => SessionReceiptOperationTable.id, { onDelete: "cascade" }), + root_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + creation_seq: integer().notNull(), + resource: text().notNull(), + operation: text().notNull(), + outcome: text().notNull(), + time_created: integer().notNull(), + }, + (table) => [ + uniqueIndex("session_receipt_root_creation_seq_idx").on(table.root_id, table.creation_seq), + index("session_receipt_operation_idx").on(table.operation_id), + ], +) + +/** Append-only observations deliberately separate from immutable receipt facts. */ +export const SessionReceiptAssessmentTable = sqliteTable( + "session_receipt_assessment", + { + id: text().primaryKey(), + receipt_id: text() + .notNull() + .references(() => SessionReceiptTable.id, { onDelete: "cascade" }), + root_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + confidence: text().notNull(), + net_state: text().notNull(), + evidence_state: text().notNull(), + revision: integer().notNull(), + expires_at: integer(), + time_created: integer().notNull(), + }, + (table) => [ + uniqueIndex("session_receipt_assessment_receipt_revision_idx").on(table.receipt_id, table.revision), + index("session_receipt_assessment_root_idx").on(table.root_id), + ], +) + export const MessageTable = sqliteTable( "message", { diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index b381cc7418..7fbb24e213 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -35,6 +35,9 @@ const run = (effect: Effect.Effect) => effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped), ) +const runAtPath = (filename: string, effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(SqliteClient.layer({ filename, disableWAL: true })), Effect.scoped)) + const makeDb = EffectDrizzleSqlite.makeWithDefaults() describe("DatabaseMigration", () => { @@ -99,6 +102,55 @@ describe("DatabaseMigration", () => { ) }) + test("preserves committed receipt groups, immutable receipts, and assessment history across restart", async () => { + await using tmp = await tmpdir() + const filename = path.join(tmp.path, "receipts.sqlite") + await runAtPath( + filename, + Effect.gen(function* () { + const db = yield* makeDb + yield* DatabaseMigration.apply(db) + yield* db.run( + sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('project', '/project', 1, 1, '[]')`, + ) + yield* db.run( + sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('root', 'project', 'root', '/project', 'Root', 'test', 1, 1)`, + ) + yield* db.run( + sql`INSERT INTO session_receipt_operation (id, root_id, session_id, origin, reserved_receipts, reserved_metadata_bytes, state) VALUES ('operation', 'root', 'root', 'agent', 1, 64, 'committed')`, + ) + yield* db.run( + sql`INSERT INTO session_receipt (id, operation_id, root_id, creation_seq, resource, operation, outcome, time_created) VALUES ('receipt', 'operation', 'root', 1, 'file:///root', 'write', 'applied', 2)`, + ) + yield* db.run( + sql`INSERT INTO session_receipt_assessment (id, receipt_id, root_id, confidence, net_state, evidence_state, revision, expires_at, time_created) VALUES ('assessment', 'receipt', 'root', 'verified', 'changed', 'available', 1, 3, 3)`, + ) + }), + ) + await runAtPath( + filename, + Effect.gen(function* () { + const db = yield* makeDb + yield* DatabaseMigration.apply(db) + expect( + yield* db.get(sql` + SELECT operation.state, operation.reserved_receipts AS reservedReceipts, operation.reserved_metadata_bytes AS reservedMetadataBytes, receipt.creation_seq AS sequence, assessment.revision, assessment.expires_at AS expiresAt + FROM session_receipt_operation operation + JOIN session_receipt receipt ON receipt.operation_id = operation.id + JOIN session_receipt_assessment assessment ON assessment.receipt_id = receipt.id + `), + ).toEqual({ + state: "committed", + reservedReceipts: 1, + reservedMetadataBytes: 64, + sequence: 1, + revision: 1, + expiresAt: 3, + }) + }), + ) + }) + test("rejects a non-empty database without a session table", async () => { await expect( run( diff --git a/packages/opencode/src/session/business-record.ts b/packages/opencode/src/session/business-record.ts new file mode 100644 index 0000000000..1dc2724ef4 --- /dev/null +++ b/packages/opencode/src/session/business-record.ts @@ -0,0 +1,194 @@ +import { SessionEvidence } from "./evidence" +import { SessionMutation } from "./mutation" +import { SessionReceiptPrivacy } from "./receipt-privacy" + +/** + * Adopts plugin bookkeeping and runner-originated writes as system-origin + * business receipts. A business record is a durable problem, run, or artifact + * record referenced by a researcher-facing surface; credentials, caches, + * queues, updater state, telemetry, ledger storage, and retention transitions + * are operational machinery and never become receipts. + * + * The registry (#1077) is the single source of truth for route identity; the + * exposure matrix (#1078) is the single serializer for display projection. + * This layer neither adopts engine tool routes (#1079) nor edits the UI. + */ +export namespace SessionBusinessRecord { + /** Registered plugin/runner writers, each mapped to its resource classification. */ + export const Routes = { + "plugin-problem-record": "problem_record", + "runner-run-metadata": "run_metadata", + "runner-artifact": "artifact", + } as const + export type Route = keyof typeof Routes + export type Classification = (typeof Routes)[Route] + + /** Operational machinery — explicitly out of scope, cannot recurse into a receipt. */ + export const OperationalRoutes = [ + "credential-store", + "cache-store", + "queue-store", + "updater-state", + "telemetry-store", + "ledger-infrastructure", + "retention-state", + ] as const + export type OperationalRoute = (typeof OperationalRoutes)[number] + + export type ResourceInput = { + id: string + resource: string + outcome: "applied" | "failed" + /** A link to a large generated output — preferred over copying content into evidence. */ + artifact?: { ref: string } + /** Small display-safe evidence recorded inline. */ + evidence?: { content: string } + } + + export type ResourceReceipt = { + id: string + resource: string + outcome: "applied" | "failed" + evidence: "artifact_link" | "inline" | "none" + } + + export type SystemReceipt = { + origin: "system" + rootID: string + sessionID: string + routeID: Route + operationID: string + operation: string + classification: Classification + outcome: "applied" | "failed" + resources: ReadonlyArray + } + + export type RefusalReason = + | "unregistered_route" + | "operational_resource" + | "requires_opaque_receipt" + | "not_business_route" + | "lineage_mismatch" + | "no_resources" + + export type Adoption = { kind: "adopted"; receipt: SystemReceipt } | { kind: "refused"; reason: RefusalReason } + + export type UnknownReceipt = SessionMutation.UnknownReceipt + + /** + * Where a route sits relative to this adopter. `business` routes adopt here; + * `operational` are out of scope; `opaque` (shell/CLI/MCP) carry only an + * Unknown Mutation Receipt; `engine` file routes belong to #1079. + */ + export function classify(routeID: string): "business" | "operational" | "opaque" | "engine" | undefined { + if (routeID in Routes) return "business" + if ((OperationalRoutes as ReadonlyArray).includes(routeID)) return "operational" + const route = SessionMutation.Registry.require(routeID) + if (!route) return undefined + if (route.kind === "opaque") return "opaque" + return "engine" + } + + export function adopt(input: { + rootForSession: (sessionID: string) => string | undefined + routeID: string + sessionID: string + rootID: string + operation: string + operationID: string + resources: ReadonlyArray + }): Adoption { + const kind = classify(input.routeID) + if (kind === undefined) return { kind: "refused", reason: "unregistered_route" } + if (kind === "operational") return { kind: "refused", reason: "operational_resource" } + if (kind === "opaque") return { kind: "refused", reason: "requires_opaque_receipt" } + if (kind === "engine") return { kind: "refused", reason: "not_business_route" } + if (input.rootForSession(input.sessionID) !== input.rootID) return { kind: "refused", reason: "lineage_mismatch" } + if (input.resources.length === 0) return { kind: "refused", reason: "no_resources" } + + const routeID = input.routeID as Route + const resources = input.resources.map((resource) => ({ + id: resource.id, + resource: resource.resource, + outcome: resource.outcome, + evidence: resource.artifact ? ("artifact_link" as const) : resource.evidence ? ("inline" as const) : ("none" as const), + })) + return { + kind: "adopted", + receipt: { + origin: "system", + rootID: input.rootID, + sessionID: input.sessionID, + routeID, + operationID: input.operationID, + operation: input.operation, + classification: Routes[routeID], + outcome: resources.some((resource) => resource.outcome === "failed") ? "failed" : "applied", + resources, + }, + } + } + + /** A contextless CLI or shell launch adopts no filesystem effects — only an operation-level unknown receipt. */ + export function unknown(input: { operationID: string; origin: string; operation: string }): UnknownReceipt { + return { + kind: "unknown_mutation", + operationID: input.operationID, + origin: input.origin, + operation: input.operation, + } + } + + /** + * Display-safe projection through the #1078 exposure matrix. Protected internal + * values (canonical path, raw hash, baseline) are carried on the host record and + * denied by the matrix; the artifact link survives only as a display-safe + * receipt reference, reauthorized against the same root and policy when opened. + */ + export function project( + input: { + receipt: SystemReceipt + resource: ResourceReceipt + sequence: number + timeCreated: number + artifactRef?: string + internal?: { canonicalPath?: string; rawHash?: string; baseline?: string } + }, + boundary: SessionReceiptPrivacy.DisplayBoundary, + ): SessionReceiptPrivacy.Projection { + const host: SessionReceiptPrivacy.HostReceipt = { + operation: { + id: input.receipt.operationID, + rootID: input.receipt.rootID, + sessionID: input.receipt.sessionID, + origin: input.receipt.origin, + state: "committed", + }, + receipt: { + id: input.artifactRef ?? input.receipt.operationID, + sequence: input.sequence, + resource: input.resource.resource, + operation: input.receipt.operation, + outcome: input.resource.outcome, + timeCreated: input.timeCreated, + }, + ...(input.artifactRef ? { evidence: { receiptID: input.artifactRef } } : {}), + ...(input.internal + ? { + context: { + ...(input.internal.canonicalPath ? { canonicalPath: input.internal.canonicalPath } : {}), + ...(input.internal.rawHash ? { rawHash: input.internal.rawHash } : {}), + ...(input.internal.baseline ? { baseline: input.internal.baseline } : {}), + }, + } + : {}), + } + return SessionReceiptPrivacy.project(host, boundary) + } + + /** Deleting a lineage root removes its plugin and runner host-local evidence together. */ + export function removeRootEvidence(rootID: string) { + SessionEvidence.removeRoot(rootID) + } +} diff --git a/packages/opencode/src/session/evidence.ts b/packages/opencode/src/session/evidence.ts new file mode 100644 index 0000000000..8d59169827 --- /dev/null +++ b/packages/opencode/src/session/evidence.ts @@ -0,0 +1,79 @@ +import { Global } from "@opencode-ai/core/global" +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs" +import path from "node:path" + +/** Host-local receipt evidence. Contents never enter the session database. */ +export namespace SessionEvidence { + export type Entry = { receiptID: string; content: string } + + const root = () => path.join(Global.Path.data, "session-receipt-evidence") + const directory = (rootID: string) => path.join(root(), encodeURIComponent(rootID)) + const file = (rootID: string, operationID: string) => + path.join(directory(rootID), `${encodeURIComponent(operationID)}.json`) + + export function write(rootID: string, operationID: string, entries: ReadonlyArray, maxBytes: number) { + const bytes = entries.reduce((total, entry) => total + new TextEncoder().encode(entry.content).byteLength, 0) + if (bytes > maxBytes) return false + const target = file(rootID, operationID) + mkdirSync(path.dirname(target), { recursive: true }) + const temporary = `${target}.${crypto.randomUUID()}.tmp` + try { + writeFileSync(temporary, JSON.stringify({ version: 1, entries })) + renameSync(temporary, target) + return true + } catch (error) { + rmSync(temporary, { force: true }) + throw error + } + } + + export function exists(rootID: string, operationID: string) { + return existsSync(file(rootID, operationID)) + } + + export function read(rootID: string, operationID: string): Entry[] | undefined { + try { + const parsed: unknown = JSON.parse(readFileSync(file(rootID, operationID), "utf8")) + if ( + typeof parsed !== "object" || + parsed === null || + !("version" in parsed) || + parsed.version !== 1 || + !("entries" in parsed) || + !Array.isArray(parsed.entries) || + !parsed.entries.every( + (entry: unknown): entry is Entry => + typeof entry === "object" && + entry !== null && + "receiptID" in entry && + typeof entry.receiptID === "string" && + "content" in entry && + typeof entry.content === "string", + ) + ) + return + return parsed.entries + } catch { + return + } + } + + export function has(rootID: string, operationID: string, receiptID: string) { + return read(rootID, operationID)?.some((entry) => entry.receiptID === receiptID) ?? false + } + + export function removeRoot(rootID: string) { + rmSync(directory(rootID), { recursive: true, force: true }) + } + + /** Remove evidence left by interrupted publication; committed operation IDs retain their sidecars. */ + export function sweep(rootID: string, committedOperationIDs: ReadonlySet) { + const dir = directory(rootID) + if (!existsSync(dir)) return + for (const entry of readdirSync(dir)) { + const operationID = entry.endsWith(".json") ? decodeURIComponent(entry.slice(0, -".json".length)) : undefined + if (!operationID || !committedOperationIDs.has(operationID)) + rmSync(path.join(dir, entry), { recursive: true, force: true }) + } + } +} diff --git a/packages/opencode/src/session/external-diff.ts b/packages/opencode/src/session/external-diff.ts index aa3dd0c0a2..42d51d2c47 100644 --- a/packages/opencode/src/session/external-diff.ts +++ b/packages/opencode/src/session/external-diff.ts @@ -21,6 +21,8 @@ export namespace ExternalDiff { deletions: number } | { reference: string; file: string; state: "unchanged" | "unavailable" } + /** Read-only adapter for v1 external-diff persistence; it never claims ledger ownership. */ + export type CompatibilityRecord = { kind: "legacy_external"; assessment: Assessment } type Endpoint = { present: true; content: string } | { present: false } type Expected = { present: boolean; digest?: string } @@ -446,6 +448,10 @@ export namespace ExternalDiff { : [] return { version: 1, revision: revisions.get(sessionID) ?? 0, assessments } } + /** Projects persisted v1 records without attaching an operation or lineage claim. */ + export function compatibility(sessionID: string): CompatibilityRecord[] { + return assessed(sessionID).assessments.map((assessment) => ({ kind: "legacy_external", assessment })) + } /** Test-only restart seam; production restart rehydrates lazily from the manifest. */ export function resetMemoryForTest() { entries.clear() diff --git a/packages/opencode/src/session/lineage.ts b/packages/opencode/src/session/lineage.ts new file mode 100644 index 0000000000..3caaafdfba --- /dev/null +++ b/packages/opencode/src/session/lineage.ts @@ -0,0 +1,212 @@ +import { Database } from "@opencode-ai/core/database/database" +import { SessionLineageOriginTable, SessionLineageTable, SessionTable } from "@opencode-ai/core/session/sql" +import { and, eq, isNull } from "drizzle-orm" +import { Effect } from "effect" +import { SessionID } from "./schema" + +export namespace SessionLineage { + export type Mode = "legacy" | "partial" | "full" + export type EdgeKind = "task_spawn" | "session_spawn" + export type Origin = { + sessionID: SessionID + title: string + edgeKind: EdgeKind + deletedAt: number + } + export type Descendant = { + sessionID: SessionID + parentID: SessionID + title: string + edgeKind: EdgeKind + mode: Exclude + } + export type Info = + | { mode: "legacy"; rootID: undefined; root: undefined; descendants: []; retainedOrigins: Origin[] } + | { + mode: Exclude + rootID: SessionID + root: { sessionID: SessionID; title: string } + legacyParentID?: SessionID + descendants: Descendant[] + retainedOrigins: Origin[] + } + + export function register( + database: Database.Interface, + input: { sessionID: SessionID; parentID?: SessionID; edgeKind?: EdgeKind }, + ) { + return Effect.gen(function* () { + if (!input.parentID) { + yield* database.db + .insert(SessionLineageTable) + .values({ session_id: input.sessionID, root_id: input.sessionID, mode: "full" }) + .run() + .pipe(Effect.orDie) + return + } + + const parent = yield* database.db + .select() + .from(SessionLineageTable) + .where(eq(SessionLineageTable.session_id, input.parentID)) + .get() + .pipe(Effect.orDie) + if (!parent) { + yield* database.db + .insert(SessionLineageTable) + .values({ + session_id: input.sessionID, + root_id: input.sessionID, + mode: "full", + legacy_parent_id: input.parentID, + }) + .run() + .pipe(Effect.orDie) + return + } + if (parent.mode === "legacy") + return yield* Effect.die(`Invalid persisted lineage mode for ${input.parentID}`) + + yield* database.db + .insert(SessionLineageTable) + .values({ + session_id: input.sessionID, + root_id: parent.root_id, + mode: parent.mode, + parent_id: input.parentID, + edge_kind: input.edgeKind ?? "session_spawn", + }) + .run() + .pipe(Effect.orDie) + }) + } + + /** Opens the explicit partial epoch for a legacy session; it never backfills history. */ + export function beginPartial(database: Database.Interface, sessionID: SessionID, boundary = Date.now()) { + return Effect.gen(function* () { + const existing = yield* database.db + .select() + .from(SessionLineageTable) + .where(eq(SessionLineageTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) + if (existing) return + yield* database.db + .insert(SessionLineageTable) + .values({ session_id: sessionID, root_id: sessionID, mode: "partial", epoch_started_at: boundary }) + .run() + .pipe(Effect.orDie) + }) + } + + export function get( + database: Database.Interface, + sessionID: SessionID, + options?: { retainedOrigins?: boolean }, + ) { + return Effect.gen(function* () { + const lineage = yield* database.db + .select() + .from(SessionLineageTable) + .where(eq(SessionLineageTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) + if (!lineage) + return { + mode: "legacy", + rootID: undefined, + root: undefined, + descendants: [], + retainedOrigins: [], + } satisfies Info + if (lineage.mode === "legacy") return yield* Effect.die(`Invalid persisted lineage mode for ${sessionID}`) + + const root = yield* database.db + .select({ id: SessionTable.id, title: SessionTable.title }) + .from(SessionTable) + .where(eq(SessionTable.id, lineage.root_id)) + .get() + .pipe(Effect.orDie) + if (!root) return yield* Effect.die(`Missing lineage root: ${lineage.root_id}`) + + const descendants = yield* database.db + .select({ lineage: SessionLineageTable, session: SessionTable }) + .from(SessionLineageTable) + .innerJoin(SessionTable, eq(SessionTable.id, SessionLineageTable.session_id)) + .where(and(eq(SessionLineageTable.root_id, lineage.root_id), isNull(SessionTable.time_archived))) + .all() + .pipe(Effect.orDie) + const retainedOrigins = options?.retainedOrigins + ? yield* database.db + .select() + .from(SessionLineageOriginTable) + .where(eq(SessionLineageOriginTable.root_id, lineage.root_id)) + .all() + .pipe(Effect.orDie) + : [] + return { + mode: lineage.mode, + rootID: lineage.root_id, + root: { sessionID: root.id, title: root.title }, + ...(lineage.legacy_parent_id ? { legacyParentID: lineage.legacy_parent_id } : {}), + descendants: descendants.flatMap((item) => { + if ( + item.lineage.session_id === lineage.root_id || + !item.lineage.parent_id || + !item.lineage.edge_kind || + item.lineage.mode === "legacy" + ) + return [] + return [ + { + sessionID: item.lineage.session_id, + parentID: item.lineage.parent_id, + title: item.session.title, + edgeKind: item.lineage.edge_kind, + mode: item.lineage.mode, + }, + ] + }), + retainedOrigins: retainedOrigins.map((origin) => ({ + sessionID: origin.session_id, + title: origin.title, + edgeKind: origin.edge_kind, + deletedAt: origin.deleted_at, + })), + } satisfies Info + }) + } + + export function retainBeforeDelete(database: Database.Interface, input: { sessionID: SessionID; title: string }) { + return Effect.gen(function* () { + const lineage = yield* database.db + .select() + .from(SessionLineageTable) + .where(eq(SessionLineageTable.session_id, input.sessionID)) + .get() + .pipe(Effect.orDie) + if (!lineage) return + if (lineage.root_id === input.sessionID) { + yield* database.db + .delete(SessionLineageOriginTable) + .where(eq(SessionLineageOriginTable.root_id, input.sessionID)) + .run() + .pipe(Effect.orDie) + return + } + if (!lineage.edge_kind) return + yield* database.db + .insert(SessionLineageOriginTable) + .values({ + root_id: lineage.root_id, + session_id: input.sessionID, + title: input.title, + edge_kind: lineage.edge_kind, + deleted_at: Date.now(), + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + }) + } +} diff --git a/packages/opencode/src/session/mutation.ts b/packages/opencode/src/session/mutation.ts new file mode 100644 index 0000000000..e9873134d7 --- /dev/null +++ b/packages/opencode/src/session/mutation.ts @@ -0,0 +1,519 @@ +import { existsSync, statSync } from "node:fs" +import { basename, dirname, isAbsolute, join } from "node:path" + +const contextHandle: unique symbol = Symbol("session-mutation-context") + +export namespace SessionMutation { + export type Route = + | { + id: + | "local-file-write" + | "tool-write" + | "tool-edit" + | "tool-apply-patch" + | "direct-file-write" + | "plugin-problem-record" + | "runner-run-metadata" + | "runner-artifact" + | "user-sidebar-op" + | "user-preview-edit" + | "user-review-edit" + kind: "ledger" + } + | { id: "shell-action" | "mcp-action" | "custom-tool-action" | "cli-action"; kind: "opaque" } + | { + id: + | "ledger-infrastructure" + | "credential-store" + | "cache-store" + | "queue-store" + | "updater-state" + | "telemetry-store" + | "retention-state" + kind: "out_of_scope" + } + + export type LedgerRouteID = Extract["id"] + export type OpaqueRouteID = Extract["id"] + + const routes = [ + { id: "local-file-write", kind: "ledger" }, + { id: "tool-write", kind: "ledger" }, + { id: "tool-edit", kind: "ledger" }, + { id: "tool-apply-patch", kind: "ledger" }, + { id: "direct-file-write", kind: "ledger" }, + { id: "plugin-problem-record", kind: "ledger" }, + { id: "runner-run-metadata", kind: "ledger" }, + { id: "runner-artifact", kind: "ledger" }, + { id: "user-sidebar-op", kind: "ledger" }, + { id: "user-preview-edit", kind: "ledger" }, + { id: "user-review-edit", kind: "ledger" }, + { id: "shell-action", kind: "opaque" }, + { id: "mcp-action", kind: "opaque" }, + { id: "custom-tool-action", kind: "opaque" }, + { id: "cli-action", kind: "opaque" }, + { id: "ledger-infrastructure", kind: "out_of_scope" }, + { id: "credential-store", kind: "out_of_scope" }, + { id: "cache-store", kind: "out_of_scope" }, + { id: "queue-store", kind: "out_of_scope" }, + { id: "updater-state", kind: "out_of_scope" }, + { id: "telemetry-store", kind: "out_of_scope" }, + { id: "retention-state", kind: "out_of_scope" }, + ] as const satisfies ReadonlyArray + + export namespace Registry { + export const version = 4 + + export function manifest() { + return { version, routes: [...routes] } + } + + export function require(id: string) { + return routes.find((route) => route.id === id) + } + } + + export type Endpoint = { value: string; kind: "file" | "directory" } + + export type ResourceOperation = "write" | "edit" | "patch" | "move" | "delete" | "create_parent" | "format" + export type ResourceRole = "target" | "source" | "destination" | "implicit_parent" | "formatter" + export type DeclaredResource = { + id: string + endpoint: Endpoint + operation: ResourceOperation + role: ResourceRole + } + export type ResourceReceipt = DeclaredResource & { outcome: "applied" | "failed" | "not_started" } + + /** + * Physical identity only. Paths are intentionally discarded before a context + * is issued, so aliases and symlinks bind the same existing resource. + */ + export namespace ResourceIdentity { + export function resolve(input: string, kind: Endpoint["kind"]): Endpoint | undefined { + try { + const target = isAbsolute(input) ? input : join(process.cwd(), input) + if (existsSync(target)) { + const stat = statSync(target) + if ((kind === "file" && !stat.isFile()) || (kind === "directory" && !stat.isDirectory())) return + return { value: `local:existing:${stat.dev}:${stat.ino}`, kind } + } + const leaves: string[] = [] + let parent = target + while (!existsSync(parent)) { + leaves.unshift(basename(parent).normalize("NFC")) + const next = dirname(parent) + if (next === parent) return + parent = next + } + const stat = statSync(parent) + if (!stat.isDirectory() || leaves.length === 0) return + return { value: `local:missing:${stat.dev}:${stat.ino}:${leaves.join("/")}:${kind}`, kind } + } catch { + return + } + } + } + + export namespace Capability { + export function discover(input: { supported: readonly number[]; requested: number }) { + if (input.supported.includes(input.requested)) return { mode: "full" as const, version: input.requested } + return { mode: "legacy" as const } + } + } + + export type Request = { + routeID: string + panelID: string + sessionID: string + rootID: string + origin: string + operation: string + operationID: string + source?: Endpoint + destination?: Endpoint + } + + export type Result = { groupID: string; outcome: string } + export type GroupResult = Result & { + outcome: "applied" | "partial" | "failed" | "denied" + receipts: ReadonlyArray + } + export type UnknownReceipt = { + kind: "unknown_mutation" + operationID: string + origin: string + operation: string + } + export type OpaqueResult = { groupID: string; outcome: "unknown"; receipt: UnknownReceipt } + export type OperationRecord = { fingerprint: string; result: Result | OpaqueResult } + /** Root-owned #1076 operation storage; retention removes all keys for an expired root. */ + export type OperationStore = { + get: (key: string) => OperationRecord | undefined + set: (key: string, record: OperationRecord) => void + } + + export namespace OperationStore { + export function memory(): OperationStore { + return new Map() + } + } + + export type LocalProvider = { + capabilities: { safeResolve: boolean; noFollowWrite: boolean } + safeResolve: (endpoint: Endpoint) => Endpoint | undefined + noFollowWrite: (input: { source: Endpoint; destination?: Endpoint }) => Result | undefined + } + export type GroupProvider = { + capabilities: { safeResolve: boolean; noFollowWrite: boolean } + safeResolve: (endpoint: Endpoint) => Endpoint | undefined + execute: (resource: DeclaredResource) => "applied" | "failed" + } + + export type GroupRequest = { + routeID: LedgerRouteID + panelID: string + sessionID: string + rootID: string + origin: string + operation: string + operationID: string + resources: ReadonlyArray + recursive?: { maxResources: number } + } + + type StoredContext = + | { + kind: "local" + routeID: LedgerRouteID + panelID: string + sessionID: string + rootID: string + origin: string + operation: string + source: Endpoint + destination?: Endpoint + expiresAt: number + idempotency: "exact" + } + | { + kind: "group" + routeID: LedgerRouteID + panelID: string + sessionID: string + rootID: string + origin: string + operation: string + resources: ReadonlyArray + recursive?: { maxResources: number } + expiresAt: number + idempotency: "exact" + } + | { + kind: "opaque" + routeID: OpaqueRouteID + panelID: string + sessionID: string + rootID: string + origin: string + operation: string + expiresAt: number + idempotency: "exact" + } + + type Context = { readonly [contextHandle]: true } + type Issue = Omit & { kind: "local" | "opaque"; expiresAt: number } + type GroupIssue = Omit & { expiresAt: number } + type OpaqueRequest = Omit & { + routeID: OpaqueRouteID + resources?: ReadonlyArray + } + export function create(input: { + rootForSession: (sessionID: string) => string | undefined + now: () => number + operations: OperationStore + }) { + const contexts = new Map() + const operations = input.operations + + const same = (left: Endpoint | undefined, right: Endpoint | undefined) => + left?.value === right?.value && left?.kind === right?.kind + const sameResources = (left: ReadonlyArray, right: ReadonlyArray) => + left.length === right.length && + left.every( + (resource, index) => + resource.id === right[index]?.id && + resource.operation === right[index]?.operation && + resource.role === right[index]?.role && + same(resource.endpoint, right[index]?.endpoint), + ) + const validResources = (resources: ReadonlyArray) => + resources.length > 0 && + resources.every((resource) => resource.id.length > 0) && + new Set(resources.map((resource) => resource.id)).size === resources.length + const operationKey = (rootID: string, operationID: string) => JSON.stringify([rootID, operationID]) + const fingerprint = (request: Request & { resources?: ReadonlyArray }) => + JSON.stringify({ + routeID: request.routeID, + operation: request.operation, + source: request.source, + destination: request.destination, + resources: request.resources, + rootID: request.rootID, + origin: request.origin, + }) + const validateLocal = (execution: { context?: Context; request: Request }) => { + if (!execution.context) return { reason: "missing_context" as const } + const context = contexts.get(execution.context) + if (!context) return { reason: "invalid_context" as const } + if (context.expiresAt <= input.now()) return { reason: "expired_context" as const } + if (input.rootForSession(context.sessionID) !== context.rootID) return { reason: "invalid_context" as const } + if ( + context.kind !== "local" || + execution.request.routeID !== context.routeID || + execution.request.panelID !== context.panelID || + execution.request.sessionID !== context.sessionID || + execution.request.rootID !== context.rootID || + execution.request.origin !== context.origin || + execution.request.operation !== context.operation || + !same(execution.request.source, context.source) || + !same(execution.request.destination, context.destination) + ) + return { reason: "invalid_context" as const } + return { context } + } + const validateGroup = (execution: { context?: Context; request: GroupRequest }) => { + if (!execution.context) return { reason: "missing_context" as const } + const context = contexts.get(execution.context) + if (!context) return { reason: "invalid_context" as const } + if (context.expiresAt <= input.now()) return { reason: "expired_context" as const } + if (input.rootForSession(context.sessionID) !== context.rootID) return { reason: "invalid_context" as const } + if ( + context.kind !== "group" || + execution.request.routeID !== context.routeID || + execution.request.panelID !== context.panelID || + execution.request.sessionID !== context.sessionID || + execution.request.rootID !== context.rootID || + execution.request.origin !== context.origin || + execution.request.operation !== context.operation || + execution.request.recursive?.maxResources !== context.recursive?.maxResources || + !sameResources(execution.request.resources, context.resources) + ) + return { reason: "invalid_context" as const } + return { context } + } + + return { + issue(request: Issue): Context | undefined { + const route = Registry.require(request.routeID) + if (!route || input.rootForSession(request.sessionID) !== request.rootID) return + if (request.kind === "local") { + if (route.kind !== "ledger" || !request.source) return + const stored: StoredContext = { + kind: "local", + routeID: route.id, + panelID: request.panelID, + sessionID: request.sessionID, + rootID: request.rootID, + origin: request.origin, + operation: request.operation, + source: request.source, + ...(request.destination ? { destination: request.destination } : {}), + expiresAt: request.expiresAt, + idempotency: "exact", + } + const context: Context = { [contextHandle]: true } + contexts.set(context, stored) + return context + } + if (route.kind !== "opaque") return + if (request.source || request.destination) return + const stored: StoredContext = { + kind: "opaque", + routeID: route.id, + panelID: request.panelID, + sessionID: request.sessionID, + rootID: request.rootID, + origin: request.origin, + operation: request.operation, + expiresAt: request.expiresAt, + idempotency: "exact", + } + const context: Context = { [contextHandle]: true } + contexts.set(context, stored) + return context + }, + issueGroup(request: GroupIssue): Context | undefined { + const route = Registry.require(request.routeID) + if ( + !route || + route.kind !== "ledger" || + !validResources(request.resources) || + (request.recursive && + (!Number.isSafeInteger(request.recursive.maxResources) || request.recursive.maxResources < 1)) || + input.rootForSession(request.sessionID) !== request.rootID + ) + return + const context: Context = { [contextHandle]: true } + contexts.set(context, { + kind: "group", + routeID: route.id, + panelID: request.panelID, + sessionID: request.sessionID, + rootID: request.rootID, + origin: request.origin, + operation: request.operation, + resources: request.resources, + ...(request.recursive ? { recursive: request.recursive } : {}), + expiresAt: request.expiresAt, + idempotency: "exact", + }) + return context + }, + revoke(context: Context) { + return contexts.delete(context) + }, + executeOpaque(execution: { context?: Context; request: OpaqueRequest }) { + if (!execution.context) return { kind: "denied" as const, reason: "missing_context" as const } + const context = contexts.get(execution.context) + if (!context) return { kind: "denied" as const, reason: "invalid_context" as const } + if (context.expiresAt <= input.now()) return { kind: "denied" as const, reason: "expired_context" as const } + if (input.rootForSession(context.sessionID) !== context.rootID) + return { kind: "denied" as const, reason: "invalid_context" as const } + if ( + context.kind !== "opaque" || + execution.request.routeID !== context.routeID || + execution.request.panelID !== context.panelID || + execution.request.sessionID !== context.sessionID || + execution.request.rootID !== context.rootID || + execution.request.origin !== context.origin || + execution.request.operation !== context.operation + ) + return { kind: "denied" as const, reason: "invalid_context" as const } + const key = operationKey(context.rootID, execution.request.operationID) + const normalized = fingerprint(execution.request) + const previous = operations.get(key) + if (previous && previous.fingerprint !== normalized) + return { kind: "denied" as const, reason: "invalid_replay" as const } + if (previous) return { kind: "replayed" as const, result: previous.result } + if (execution.request.resources && validResources(execution.request.resources)) { + const result: GroupResult = { + groupID: execution.request.operationID, + outcome: "applied", + receipts: execution.request.resources.map((resource) => ({ ...resource, outcome: "applied" })), + } + operations.set(key, { fingerprint: normalized, result }) + return { kind: "executed" as const, result } + } + const result: OpaqueResult = { + groupID: execution.request.operationID, + outcome: "unknown", + receipt: { + kind: "unknown_mutation", + operationID: execution.request.operationID, + origin: context.origin, + operation: context.operation, + }, + } + operations.set(key, { fingerprint: normalized, result }) + return { kind: "executed" as const, result } + }, + executeLocal(execution: { context?: Context; request: Request; provider: LocalProvider }) { + const validation = validateLocal(execution) + if ("reason" in validation) return { kind: "denied" as const, reason: validation.reason } + const context = validation.context + if (!execution.provider.capabilities.safeResolve || !execution.provider.capabilities.noFollowWrite) + return { kind: "denied" as const, reason: "unsafe_provider" as const } + const key = operationKey(context.rootID, execution.request.operationID) + const normalized = fingerprint(execution.request) + const previous = operations.get(key) + if (previous && previous.fingerprint !== normalized) + return { kind: "denied" as const, reason: "invalid_replay" as const } + if (previous) return { kind: "replayed" as const, result: previous.result } + if (!execution.request.source) return { kind: "denied" as const, reason: "invalid_context" as const } + const source = execution.provider.safeResolve(execution.request.source) + const destination = + execution.request.destination && execution.provider.safeResolve(execution.request.destination) + if ( + !source || + (context.destination && !destination) || + !same(source, context.source) || + !same(destination, context.destination) + ) + return { kind: "denied" as const, reason: "identity_changed" as const } + const result = execution.provider.noFollowWrite({ + source, + ...(destination ? { destination } : {}), + }) + if (!result) return { kind: "denied" as const, reason: "identity_changed" as const } + operations.set(key, { fingerprint: normalized, result }) + return { kind: "executed" as const, result } + }, + executeGroup(execution: { context?: Context; request: GroupRequest; provider: GroupProvider }) { + const validation = validateGroup(execution) + if ("reason" in validation) return { kind: "denied" as const, reason: validation.reason } + const context = validation.context + if (!execution.provider.capabilities.safeResolve || !execution.provider.capabilities.noFollowWrite) + return { kind: "denied" as const, reason: "unsafe_provider" as const } + const key = operationKey(context.rootID, execution.request.operationID) + const normalized = JSON.stringify({ + routeID: execution.request.routeID, + operation: execution.request.operation, + resources: execution.request.resources, + recursive: execution.request.recursive, + rootID: execution.request.rootID, + origin: execution.request.origin, + }) + const previous = operations.get(key) + if (previous && previous.fingerprint !== normalized) + return { kind: "denied" as const, reason: "invalid_replay" as const } + if (previous) return { kind: "replayed" as const, result: previous.result } + if (context.recursive && context.resources.length > context.recursive.maxResources) { + const result: GroupResult = { groupID: execution.request.operationID, outcome: "denied", receipts: [] } + operations.set(key, { fingerprint: normalized, result }) + return { kind: "denied" as const, reason: "resource_budget_exceeded" as const, result } + } + if ( + context.resources.some( + (resource) => !same(execution.provider.safeResolve(resource.endpoint), resource.endpoint), + ) + ) { + const result: GroupResult = { + groupID: execution.request.operationID, + outcome: "failed", + receipts: context.resources.map((resource) => ({ ...resource, outcome: "not_started" })), + } + operations.set(key, { fingerprint: normalized, result }) + return { kind: "executed" as const, result } + } + const receipts: ResourceReceipt[] = [] + for (const resource of context.resources) { + let outcome: "applied" | "failed" + try { + outcome = execution.provider.execute(resource) + } catch { + outcome = "failed" + } + receipts.push({ ...resource, outcome }) + if (outcome === "failed") { + receipts.push( + ...context.resources.slice(receipts.length).map((next) => ({ ...next, outcome: "not_started" as const })), + ) + break + } + } + const result: GroupResult = { + groupID: execution.request.operationID, + outcome: receipts.some((receipt) => receipt.outcome === "failed") + ? receipts.some((receipt) => receipt.outcome === "applied") + ? "partial" + : "failed" + : "applied", + receipts, + } + operations.set(key, { fingerprint: normalized, result }) + return { kind: "executed" as const, result } + }, + } + } +} diff --git a/packages/opencode/src/session/receipt-privacy.ts b/packages/opencode/src/session/receipt-privacy.ts new file mode 100644 index 0000000000..dcd7e1d6de --- /dev/null +++ b/packages/opencode/src/session/receipt-privacy.ts @@ -0,0 +1,114 @@ +import { SessionReceipt as ReceiptSchema } from "@opencode-ai/schema/session-receipt" +import { SessionMutation } from "./mutation" + +export namespace SessionReceiptPrivacy { + export type HostReceipt = { + operation?: { + id?: string + rootID?: string + sessionID?: string + origin?: string + state?: string + } + receipt?: { + id?: string + sequence?: number + resource?: string + operation?: string + outcome?: string + timeCreated?: number + } + assessment?: { + id?: string + receiptID?: string + confidence?: string + netState?: string + evidenceState?: string + revision?: number + expiresAt?: number + timeCreated?: number + } + evidence?: { receiptID?: string; content?: string } + context?: { + capability?: string + canonicalPath?: string + rawHash?: string + baseline?: string + redactionDecision?: string + } + derived?: { patch?: string; additions?: number; deletions?: number } + } + + export type DisplayBoundary = Exclude + export type Projection = Partial< + Record<"operation" | "receipt" | "assessment" | "evidence" | "derived", Record> + > + export type DetailAccess = { + authenticated: boolean + receiptReferenceAuthorized: boolean + evidencePolicy: "allow" | "deny" + now: number + expiresAt?: number + redirected?: boolean + } + export type ExternalDetail = { + status: 200 | 307 | 401 | 403 | 410 + headers: { "cache-control": "no-store" } + body: Projection | { error: "unauthorized" | "forbidden" | "redirected" | "evidence_denied" | "evidence_expired" } + } + export type DisplayCapability = ReturnType + + export function marker(field: ReceiptSchema.Field) { + return `[redacted:${field}]` + } + + /** + * The only general receipt serializer. It enumerates schema fields rather + * than spreading host records, so host-only additions cannot leak by default. + */ + export function project(input: HostReceipt, boundary: DisplayBoundary): Projection { + return projectBoundary(input, boundary) + } + + /** + * Evidence bytes are available only through this authenticated detail gate; + * ordinary serializers cannot select the external-detail boundary. + */ + export function externalDetail(input: HostReceipt, access: DetailAccess): ExternalDetail { + const headers = { "cache-control": "no-store" } as const + if (!access.authenticated) return { status: 401, headers, body: { error: "unauthorized" } } + if (!access.receiptReferenceAuthorized) return { status: 403, headers, body: { error: "forbidden" } } + if (access.redirected) return { status: 307, headers, body: { error: "redirected" } } + if (access.evidencePolicy !== "allow") return { status: 403, headers, body: { error: "evidence_denied" } } + if (input.assessment?.evidenceState !== "available") + return { status: 403, headers, body: { error: "evidence_denied" } } + if (access.expiresAt !== undefined && access.expiresAt <= access.now) + return { status: 410, headers, body: { error: "evidence_expired" } } + return { status: 200, headers, body: projectBoundary(input, "external_detail") } + } + + /** The capability result from #1077 is the sole legacy/full selection point. */ + export function display( + input: { legacy: T; receipt: HostReceipt }, + capability: DisplayCapability, + ): T | Projection { + return capability.mode === "legacy" ? input.legacy : project(input.receipt, "files_changed") + } + + function projectBoundary(input: HostReceipt, boundary: ReceiptSchema.Boundary): Projection { + const output: Projection = {} + for (const field of ReceiptSchema.Fields) { + const [group, key] = field.split(".") as [keyof HostReceipt, string] + const value = input[group]?.[key as never] + if (value === undefined) continue + + const decision = ReceiptSchema.Exposure[field][boundary] + if (decision === "deny") continue + + const displayGroup = group as keyof Projection + const target = (output[displayGroup] ??= {}) + target[key] = decision === "allow" ? value : marker(field) + } + return output + } +} diff --git a/packages/opencode/src/session/receipt.ts b/packages/opencode/src/session/receipt.ts new file mode 100644 index 0000000000..24f7e38b5d --- /dev/null +++ b/packages/opencode/src/session/receipt.ts @@ -0,0 +1,465 @@ +import { Database } from "@opencode-ai/core/database/database" +import { + SessionLineageTable, + SessionReceiptAssessmentTable, + SessionReceiptOperationTable, + SessionReceiptTable, +} from "@opencode-ai/core/session/sql" +import { and, asc, desc, eq, gt, inArray } from "drizzle-orm" +import { Effect } from "effect" +import { SessionEvidence } from "./evidence" +import { SessionID } from "./schema" + +export namespace SessionReceipt { + export type Budget = { + maxReceipts: number + maxMetadataBytes: number + maxEvidenceBytes?: number + retentionMs?: number + } + + export type Evidence = SessionEvidence.Entry + + export type Fact = { + id: string + resource: string + operation: string + outcome: string + timeCreated: number + } + + export type Assessment = { + id: string + receiptID: string + confidence: string + netState: string + evidenceState: string + revision: number + expiresAt?: number + timeCreated: number + } + + type ReservationInput = { + id: string + sessionID: SessionID + origin: string + receipts: ReadonlyArray + budget: Budget + evidence?: ReadonlyArray + } + + export function reserve(database: Database.Interface, input: ReservationInput) { + return database.db.transaction( + (tx) => + Effect.gen(function* () { + const lineage = yield* tx + .select({ rootID: SessionLineageTable.root_id }) + .from(SessionLineageTable) + .where(eq(SessionLineageTable.session_id, input.sessionID)) + .get() + if (!lineage) return yield* Effect.fail(new Error(`Missing lineage root for ${input.sessionID}`)) + + const receiptCount = input.receipts.length + const metadataBytes = metadataSize(input.receipts) + if (receiptCount > input.budget.maxReceipts || metadataBytes > input.budget.maxMetadataBytes) + return yield* Effect.fail(new Error(`Receipt budget exceeded for ${lineage.rootID}`)) + + const reservations = yield* tx + .select({ + receipts: SessionReceiptOperationTable.reserved_receipts, + metadataBytes: SessionReceiptOperationTable.reserved_metadata_bytes, + }) + .from(SessionReceiptOperationTable) + .where(eq(SessionReceiptOperationTable.root_id, lineage.rootID)) + .all() + const reservedReceipts = reservations.reduce( + (total, reservation) => total + reservation.receipts, + receiptCount, + ) + const reservedMetadataBytes = reservations.reduce( + (total, reservation) => total + reservation.metadataBytes, + metadataBytes, + ) + if (reservedReceipts > input.budget.maxReceipts || reservedMetadataBytes > input.budget.maxMetadataBytes) + return yield* Effect.fail(new Error(`Receipt budget exhausted for ${lineage.rootID}`)) + + yield* tx + .insert(SessionReceiptOperationTable) + .values({ + id: input.id, + root_id: lineage.rootID, + session_id: input.sessionID, + origin: input.origin, + reserved_receipts: receiptCount, + reserved_metadata_bytes: metadataBytes, + state: "prepared", + }) + .run() + }), + { behavior: "immediate" }, + ) + } + + export function abort(database: Database.Interface, id: string) { + return database.db.transaction( + (tx) => + Effect.gen(function* () { + yield* tx + .delete(SessionReceiptOperationTable) + .where(and(eq(SessionReceiptOperationTable.id, id), eq(SessionReceiptOperationTable.state, "prepared"))) + .run() + }), + { behavior: "immediate" }, + ) + } + + export function commit( + database: Database.Interface, + input: { id: string; receipts: ReadonlyArray; evidenceReceiptIDs?: ReadonlySet }, + ) { + return database.db + .transaction( + (tx) => + Effect.gen(function* () { + const reservation = yield* tx + .select() + .from(SessionReceiptOperationTable) + .where(eq(SessionReceiptOperationTable.id, input.id)) + .get() + if (!reservation || reservation.state !== "prepared") + return yield* Effect.fail(new Error(`No prepared receipt reservation ${input.id}`)) + if ( + reservation.reserved_receipts !== input.receipts.length || + reservation.reserved_metadata_bytes !== metadataSize(input.receipts) + ) + return yield* Effect.fail(new Error(`Receipt reservation mismatch for ${input.id}`)) + if ( + input.evidenceReceiptIDs?.size && + [...input.evidenceReceiptIDs].some( + (receiptID) => !input.receipts.some((receipt) => receipt.id === receiptID), + ) + ) + return yield* Effect.fail(new Error(`Evidence receipt mismatch for ${input.id}`)) + + const latest = yield* tx + .select({ sequence: SessionReceiptTable.creation_seq }) + .from(SessionReceiptTable) + .where(eq(SessionReceiptTable.root_id, reservation.root_id)) + .orderBy(desc(SessionReceiptTable.creation_seq)) + .get() + yield* tx + .update(SessionReceiptOperationTable) + .set({ state: "evidence_ready" }) + .where(eq(SessionReceiptOperationTable.id, input.id)) + .run() + if (input.receipts.length > 0) + yield* tx + .insert(SessionReceiptTable) + .values( + input.receipts.map((receipt, index) => ({ + id: receipt.id, + operation_id: input.id, + root_id: reservation.root_id, + creation_seq: (latest?.sequence ?? 0) + index + 1, + resource: receipt.resource, + operation: receipt.operation, + outcome: receipt.outcome, + time_created: receipt.timeCreated, + })), + ) + .run() + const evidence = input.receipts.filter((receipt) => input.evidenceReceiptIDs?.has(receipt.id)) + if (evidence.length) + yield* tx + .insert(SessionReceiptAssessmentTable) + .values( + evidence.map((receipt) => ({ + id: crypto.randomUUID(), + receipt_id: receipt.id, + root_id: reservation.root_id, + confidence: "observed", + net_state: "unknown", + evidence_state: "available", + revision: 1, + time_created: receipt.timeCreated, + })), + ) + .run() + yield* tx + .update(SessionReceiptOperationTable) + .set({ state: "committed" }) + .where(eq(SessionReceiptOperationTable.id, input.id)) + .run() + }), + { behavior: "immediate" }, + ) + .pipe(Effect.tapError(() => abort(database, input.id))) + } + + export function publish(database: Database.Interface, input: ReservationInput) { + return Effect.gen(function* () { + yield* reserve(database, input) + const lineage = yield* root(database, input.sessionID) + const evidence = input.evidence?.length + ? yield* Effect.try({ + try: () => SessionEvidence.write(lineage, input.id, input.evidence!, input.budget.maxEvidenceBytes ?? 0), + catch: (cause) => new Error(`Failed to publish receipt evidence for ${input.id}`, { cause }), + }) + : false + yield* commit(database, { + ...input, + ...(evidence ? { evidenceReceiptIDs: new Set(input.evidence!.map((entry) => entry.receiptID)) } : {}), + }) + }) + } + + /** Removes evidence that cannot belong to a committed receipt operation. Safe to repeat after interruption. */ + export function cleanupEvidence(database: Database.Interface, rootID: SessionID) { + return Effect.gen(function* () { + const operations = yield* database.db + .select({ id: SessionReceiptOperationTable.id }) + .from(SessionReceiptOperationTable) + .where( + and(eq(SessionReceiptOperationTable.root_id, rootID), eq(SessionReceiptOperationTable.state, "committed")), + ) + .all() + yield* Effect.sync(() => SessionEvidence.sweep(rootID, new Set(operations.map((operation) => operation.id)))) + }) + } + + /** Expire root-owned evidence after its terminal retention window without changing immutable receipt facts. */ + export function expireEvidence( + database: Database.Interface, + input: { rootID: SessionID; now: number; retentionMs: number }, + ) { + return Effect.gen(function* () { + const latest = yield* database.db + .select({ timeCreated: SessionReceiptTable.time_created }) + .from(SessionReceiptTable) + .where(eq(SessionReceiptTable.root_id, input.rootID)) + .orderBy(desc(SessionReceiptTable.time_created)) + .get() + if (latest && latest.timeCreated + input.retentionMs <= input.now) { + const evidence = yield* database.db + .select({ receiptID: SessionReceiptAssessmentTable.receipt_id }) + .from(SessionReceiptAssessmentTable) + .where( + and( + eq(SessionReceiptAssessmentTable.root_id, input.rootID), + eq(SessionReceiptAssessmentTable.evidence_state, "available"), + ), + ) + .all() + yield* Effect.sync(() => SessionEvidence.removeRoot(input.rootID)) + yield* Effect.forEach(evidence, (entry) => + assessEvidence(database, { receiptID: entry.receiptID, timeCreated: input.now }), + ) + } + }) + } + + /** Deleting a lineage root owns deletion of all of its host-local evidence. */ + export function removeRootEvidence(database: Database.Interface, sessionID: SessionID) { + return Effect.gen(function* () { + const lineage = yield* database.db + .select({ rootID: SessionLineageTable.root_id }) + .from(SessionLineageTable) + .where(eq(SessionLineageTable.session_id, sessionID)) + .get() + if (lineage?.rootID === sessionID) yield* Effect.sync(() => SessionEvidence.removeRoot(sessionID)) + }) + } + + export function appendAssessment(database: Database.Interface, input: Assessment) { + return database.db.transaction( + (tx) => + Effect.gen(function* () { + const receipt = yield* tx + .select({ rootID: SessionReceiptTable.root_id }) + .from(SessionReceiptTable) + .where(eq(SessionReceiptTable.id, input.receiptID)) + .get() + if (!receipt) return yield* Effect.fail(new Error(`Missing receipt ${input.receiptID}`)) + yield* tx + .insert(SessionReceiptAssessmentTable) + .values({ + id: input.id, + receipt_id: input.receiptID, + root_id: receipt.rootID, + confidence: input.confidence, + net_state: input.netState, + evidence_state: input.evidenceState, + revision: input.revision, + expires_at: input.expiresAt, + time_created: input.timeCreated, + }) + .run() + }), + { behavior: "immediate" }, + ) + } + + export function assessments(database: Database.Interface, receiptID: string) { + return database.db + .select() + .from(SessionReceiptAssessmentTable) + .where(eq(SessionReceiptAssessmentTable.receipt_id, receiptID)) + .orderBy(asc(SessionReceiptAssessmentTable.revision)) + .all() + .pipe( + Effect.map((assessments) => + assessments.map((assessment) => ({ + id: assessment.id, + receiptID: assessment.receipt_id, + confidence: assessment.confidence, + netState: assessment.net_state, + evidenceState: assessment.evidence_state, + revision: assessment.revision, + ...(assessment.expires_at === null ? {} : { expiresAt: assessment.expires_at }), + timeCreated: assessment.time_created, + })), + ), + ) + } + + /** Marks an evidence-bearing receipt unavailable when its host-local sidecar is absent. */ + export function assessEvidence(database: Database.Interface, input: { receiptID: string; timeCreated: number }) { + return database.db.transaction( + (tx) => + Effect.gen(function* () { + const receipt = yield* tx + .select({ rootID: SessionReceiptTable.root_id, operationID: SessionReceiptTable.operation_id }) + .from(SessionReceiptTable) + .where(eq(SessionReceiptTable.id, input.receiptID)) + .get() + if (!receipt) return yield* Effect.fail(new Error(`Missing receipt ${input.receiptID}`)) + if (SessionEvidence.has(receipt.rootID, receipt.operationID, input.receiptID)) return + + const latest = yield* tx + .select({ + revision: SessionReceiptAssessmentTable.revision, + evidenceState: SessionReceiptAssessmentTable.evidence_state, + }) + .from(SessionReceiptAssessmentTable) + .where(eq(SessionReceiptAssessmentTable.receipt_id, input.receiptID)) + .orderBy(desc(SessionReceiptAssessmentTable.revision)) + .get() + if (latest?.evidenceState !== "available") return + yield* tx + .insert(SessionReceiptAssessmentTable) + .values({ + id: crypto.randomUUID(), + receipt_id: input.receiptID, + root_id: receipt.rootID, + confidence: "unavailable", + net_state: "unavailable", + evidence_state: "unavailable", + revision: (latest?.revision ?? 0) + 1, + time_created: input.timeCreated, + }) + .run() + }), + { behavior: "immediate" }, + ) + } + + /** Pages immutable root-owned facts by their creation sequence. */ + export function page(database: Database.Interface, sessionID: SessionID, input: { cursor?: number; limit: number }) { + return Effect.gen(function* () { + if (!Number.isSafeInteger(input.limit) || input.limit < 1) + return yield* Effect.fail(new Error(`Invalid receipt page limit ${input.limit}`)) + if (input.cursor !== undefined && (!Number.isSafeInteger(input.cursor) || input.cursor < 0)) + return yield* Effect.fail(new Error(`Invalid receipt page cursor ${input.cursor}`)) + const rootID = yield* root(database, sessionID) + const receipts = yield* database.db + .select() + .from(SessionReceiptTable) + .where(and(eq(SessionReceiptTable.root_id, rootID), gt(SessionReceiptTable.creation_seq, input.cursor ?? 0))) + .orderBy(asc(SessionReceiptTable.creation_seq)) + .limit(input.limit + 1) + .all() + const page = receipts.slice(0, input.limit) + const next = receipts.length > input.limit ? page.at(-1)?.creation_seq : undefined + return { + rootID, + receipts: page.map((receipt) => ({ + id: receipt.id, + sequence: receipt.creation_seq, + resource: receipt.resource, + operation: receipt.operation, + outcome: receipt.outcome, + timeCreated: receipt.time_created, + })), + nextCursor: next, + } + }) + } + + export function committed(database: Database.Interface, sessionID: SessionID) { + return Effect.gen(function* () { + const lineage = yield* database.db + .select({ rootID: SessionLineageTable.root_id }) + .from(SessionLineageTable) + .where(eq(SessionLineageTable.session_id, sessionID)) + .get() + if (!lineage) return [] + const operations = yield* database.db + .select() + .from(SessionReceiptOperationTable) + .where( + and( + eq(SessionReceiptOperationTable.root_id, lineage.rootID), + eq(SessionReceiptOperationTable.state, "committed"), + ), + ) + .all() + const receipts = operations.length + ? yield* database.db + .select() + .from(SessionReceiptTable) + .where( + inArray( + SessionReceiptTable.operation_id, + operations.map((operation) => operation.id), + ), + ) + .orderBy(asc(SessionReceiptTable.creation_seq)) + .all() + : [] + return operations.map((operation) => ({ + id: operation.id, + rootID: operation.root_id, + sessionID: operation.session_id, + origin: operation.origin, + state: operation.state, + receipts: receipts + .filter((receipt) => receipt.operation_id === operation.id) + .map((receipt) => ({ + id: receipt.id, + sequence: receipt.creation_seq, + resource: receipt.resource, + operation: receipt.operation, + outcome: receipt.outcome, + timeCreated: receipt.time_created, + })), + })) + }) + } +} + +function root(database: Database.Interface, sessionID: SessionID) { + return database.db + .select({ rootID: SessionLineageTable.root_id }) + .from(SessionLineageTable) + .where(eq(SessionLineageTable.session_id, sessionID)) + .get() + .pipe( + Effect.flatMap((lineage) => + lineage ? Effect.succeed(lineage.rootID) : Effect.fail(new Error(`Missing lineage root for ${sessionID}`)), + ), + ) +} + +function metadataSize(receipts: ReadonlyArray) { + return new TextEncoder().encode(JSON.stringify(receipts)).byteLength +} diff --git a/packages/opencode/src/session/rollout.ts b/packages/opencode/src/session/rollout.ts new file mode 100644 index 0000000000..15d43ef91a --- /dev/null +++ b/packages/opencode/src/session/rollout.ts @@ -0,0 +1,316 @@ +import { SessionMutation } from "./mutation" +import type { SessionLineage } from "./lineage" + +/** + * The deterministic gate-migration + coordinated-release surface (amicode#1083). + * + * Two concerns, kept separate by design (Key Decision: compatibility discovery + * is separate from mutation-context validation): + * + * 1. **Compatibility discovery** — a versioned {@link SessionRollout.Capability} + * ({@link SessionRollout.Capability.discover}) and the mixed-version + * {@link SessionRollout.Matrix} resolver. Both are pure functions: they read + * engine support, client generation, and the session root's persisted mode, + * and NEVER probe a mutation route. + * 2. **Release readiness** — {@link SessionRollout.Release} parses a release + * manifest and computes a machine-readable all-required-passed verdict. It + * only READS a manifest and reports; it performs NO release act, and default + * enablement always stays `off` (the flip is a human gate, out of this code). + * + * This module never tags a fork, pins a binary, publishes an extension, runs + * overlay sync, or flips default-enablement on — those are human-only acts. + */ +export namespace SessionRollout { + /** Reuse the #1077 mutation-registry generation as the rollout protocol version — one source of truth, no parallel constant. */ + export const PROTOCOL_VERSION = SessionMutation.Registry.version + + /** The three lineage modes, reused verbatim from the #972 lineage surface. */ + export type Mode = SessionLineage.Mode + + /** + * Where a session sits relative to the ledger migration. + * - `none` — a full-native root born after rollout; nothing to migrate. + * - `epoch` — an explicit opted-in post-upgrade epoch (partial mode). + * - `unmigrated` — a legacy session that never opened an epoch. + */ + export type MigrationBoundary = + | { kind: "none" } + | { kind: "epoch"; startedAt: number } + | { kind: "unmigrated" } + + /** The versioned discovery object. */ + export type Capability = { + protocol_version: number + mode: Mode + migration_boundary: MigrationBoundary + } + + const legacyCapability = (protocolVersion: number): Capability => ({ + protocol_version: protocolVersion, + mode: "legacy", + migration_boundary: { kind: "unmigrated" }, + }) + + /** + * Compatibility discovery for a NEW client. Deterministic and route-free: it + * decides full / partial / legacy from the engine's advertised support and the + * session root's persisted mode + epoch marker only. It never probes, writes, + * or touches the filesystem. + * + * - Unsupported (old) engine → legacy, whatever the root. + * - Supported engine + full root → full (unless `fullDiscoveryEnabled` is off). + * - Supported engine + partial root WITH an epoch marker → partial. + * - Everything else (legacy root, or a partial root with no epoch) → legacy. + * A partial mode is never inferred without an explicit epoch boundary. + * + * `fullDiscoveryEnabled` (default true) is the rollback gate: with it off, a + * full root downgrades to legacy so a partially-torn-down release can never + * authorize a full-provenance mutation. + */ + export namespace Capability { + export function discover(input: { + supported: readonly number[] + requested: number + root: { mode: Mode; epochStartedAt?: number } + fullDiscoveryEnabled?: boolean + }): Capability { + const fullEnabled = input.fullDiscoveryEnabled ?? true + if (!input.supported.includes(input.requested)) return legacyCapability(input.requested) + if (input.root.mode === "full") + return fullEnabled + ? { protocol_version: input.requested, mode: "full", migration_boundary: { kind: "none" } } + : legacyCapability(input.requested) + if (input.root.mode === "partial" && input.root.epochStartedAt !== undefined) + return { + protocol_version: input.requested, + mode: "partial", + migration_boundary: { kind: "epoch", startedAt: input.root.epochStartedAt }, + } + return legacyCapability(input.requested) + } + } + + export type Client = "new" | "old" + export type EngineSupport = { supported: readonly number[]; requested: number } + export type MatrixLabel = "full" | "partial" | "legacy" | "pre_ledger" + export type MatrixOutcome = Capability & { label: MatrixLabel } + + /** + * The supported mixed-version matrix. Every outcome is visibly labelled and + * only the true full case ever claims full provenance. + * + * - old engine + new client → legacy (label `legacy`). + * - new engine + old client → the old client keeps its pre-ledger view; mode + * is legacy (never a ledger mode) and label `pre_ledger` makes it visible. + * - new engine + new client → delegates to {@link Capability.discover}: full + * for a full root, otherwise labelled partial / legacy. + */ + export namespace Matrix { + export function resolve(input: { + engine: EngineSupport + client: Client + root: { mode: Mode; epochStartedAt?: number } + }): MatrixOutcome { + // An old client does not speak the ledger protocol: it retains its + // pre-ledger view regardless of the engine, and never claims a ledger mode. + if (input.client === "old") return { ...legacyCapability(input.engine.requested), label: "pre_ledger" } + const cap = Capability.discover({ supported: input.engine.supported, requested: input.engine.requested, root: input.root }) + const label: MatrixLabel = cap.mode === "full" ? "full" : cap.mode === "partial" ? "partial" : "legacy" + return { ...cap, label } + } + } + + /** + * Migration transitions. The ONLY path off legacy is an explicit epoch start; + * there is deliberately no infer/backfill function — a partial epoch is never + * derived by background inference. + */ + export namespace Migration { + export type StartResult = + | { kind: "started"; mode: "partial"; migration_boundary: { kind: "epoch"; startedAt: number } } + | { kind: "rejected"; reason: string } + + export function startEpoch(input: { current: Mode; boundary: number }): StartResult { + if (input.current !== "legacy") + return { kind: "rejected", reason: `only a legacy session may open a post-upgrade epoch (current: ${input.current})` } + return { kind: "started", mode: "partial", migration_boundary: { kind: "epoch", startedAt: input.boundary } } + } + } + + /** The generation at which ledger provenance begins; v1 (=1) reservations pre-date it. */ + export const LEDGER_GENERATION = 2 + + export type ReservationResult = + | { kind: "legacy"; label: "legacy" } + | { kind: "ledger"; generation: number } + + /** + * Resolve an in-flight reservation. A v1 (pre-ledger) reservation — any + * generation before {@link LEDGER_GENERATION} — resolves as a legacy result + * and is never silently adopted into a ledger operation. + */ + export function resolveReservation(input: { + reservation: { generation: number } + ledgerGeneration?: number + }): ReservationResult { + const ledgerGeneration = input.ledgerGeneration ?? LEDGER_GENERATION + if (input.reservation.generation < ledgerGeneration) return { kind: "legacy", label: "legacy" } + return { kind: "ledger", generation: input.reservation.generation } + } + + /** + * Rollback safety. The danger during a rollback is a window where full + * discovery is still live but the client mutation routes are already gone: a + * full capability would then authorize a mutation with no route to honor it — + * an uncontextualized full-provenance mutation. The correct rollback disables + * full discovery FIRST, then withdraws client routes. + */ + export namespace Rollback { + export type State = { fullDiscovery: boolean; clientRoutes: boolean } + + export function unsafe(state: State): boolean { + return state.fullDiscovery && !state.clientRoutes + } + + /** The correct teardown sequence: disable full discovery before withdrawing client routes. */ + export function plan(): State[] { + return [ + { fullDiscovery: true, clientRoutes: true }, + { fullDiscovery: false, clientRoutes: true }, + { fullDiscovery: false, clientRoutes: false }, + ] + } + } + + /** + * The release manifest schema/parsing and the machine-readable + * all-required-passed gate evaluator. Pure data/validation: it reads a + * manifest and computes readiness. It performs NO release act, and default + * enablement always stays `off`. + */ + export namespace Release { + export type Gate = { id: string; required: boolean; passed: boolean } + export type Completion = { forkAt: number; binaryPinAt: number; extensionAt: number } + export type Manifest = { + forkTag: string + binaryChecksums: Record + lockPin: string + extensionVersion: string + overlayProvenance: { verified: boolean } + rehearsalCaseIDs: readonly string[] + matrixCaseIDs: readonly string[] + completion: Completion + gates: readonly Gate[] + } + + export type ParseResult = { ok: true; manifest: Manifest } | { ok: false; errors: string[] } + + const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + + const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every((entry) => typeof entry === "string") + + export function parse(input: unknown): ParseResult { + const errors: string[] = [] + if (!isObject(input)) return { ok: false, errors: ["manifest must be an object"] } + + if (typeof input.forkTag !== "string" || input.forkTag.length === 0) errors.push("forkTag must be a non-empty string") + if ( + !isObject(input.binaryChecksums) || + !Object.values(input.binaryChecksums).every((entry) => typeof entry === "string") + ) + errors.push("binaryChecksums must be a record of platform → checksum strings") + if (typeof input.lockPin !== "string" || input.lockPin.length === 0) errors.push("lockPin must be a non-empty string") + if (typeof input.extensionVersion !== "string" || input.extensionVersion.length === 0) + errors.push("extensionVersion must be a non-empty string") + if (!isObject(input.overlayProvenance) || typeof input.overlayProvenance.verified !== "boolean") + errors.push("overlayProvenance.verified must be a boolean") + if (!isStringArray(input.rehearsalCaseIDs)) errors.push("rehearsalCaseIDs must be an array of strings") + if (!isStringArray(input.matrixCaseIDs)) errors.push("matrixCaseIDs must be an array of strings") + if ( + !isObject(input.completion) || + typeof input.completion.forkAt !== "number" || + typeof input.completion.binaryPinAt !== "number" || + typeof input.completion.extensionAt !== "number" + ) + errors.push("completion must carry numeric forkAt, binaryPinAt, and extensionAt") + if ( + !Array.isArray(input.gates) || + !input.gates.every( + (gate) => + isObject(gate) && + typeof gate.id === "string" && + typeof gate.required === "boolean" && + typeof gate.passed === "boolean", + ) + ) + errors.push("gates must be an array of {id, required, passed}") + + if (errors.length > 0) return { ok: false, errors } + + const raw = input as Record + return { + ok: true, + manifest: { + forkTag: raw.forkTag as string, + binaryChecksums: raw.binaryChecksums as Record, + lockPin: raw.lockPin as string, + extensionVersion: raw.extensionVersion as string, + overlayProvenance: { verified: (raw.overlayProvenance as { verified: boolean }).verified }, + rehearsalCaseIDs: raw.rehearsalCaseIDs as string[], + matrixCaseIDs: raw.matrixCaseIDs as string[], + completion: raw.completion as Completion, + gates: raw.gates as Gate[], + }, + } + } + + /** + * Readiness verdict. `defaultEnablement` is ALWAYS `off`: this evaluator + * reports readiness only — flipping enablement on is a human gate that lives + * outside this code (AC7). `ready` is true iff every required field, every + * mandatory rehearsal/matrix case, and every required gate is satisfied. + */ + export type Readiness = { + ready: boolean + defaultEnablement: "off" + missing: string[] + } + + export function evaluate( + manifest: Manifest, + mandatory: { rehearsalCaseIDs: readonly string[]; matrixCaseIDs: readonly string[] }, + ): Readiness { + const missing: string[] = [] + + if (manifest.forkTag.length === 0) missing.push("fork tag is empty") + if (Object.keys(manifest.binaryChecksums).length === 0) missing.push("binary checksums are empty") + else if (Object.values(manifest.binaryChecksums).some((entry) => entry.length === 0)) + missing.push("a binary checksum is empty") + if (manifest.lockPin.length === 0) missing.push("lock pin is empty") + if (manifest.extensionVersion.length === 0) missing.push("extension version is empty") + if (!manifest.overlayProvenance.verified) missing.push("overlay provenance is not verified") + + for (const id of mandatory.rehearsalCaseIDs) + if (!manifest.rehearsalCaseIDs.includes(id)) missing.push(`mandatory rehearsal case missing: ${id}`) + for (const id of mandatory.matrixCaseIDs) + if (!manifest.matrixCaseIDs.includes(id)) missing.push(`mandatory matrix case missing: ${id}`) + + for (const gate of manifest.gates) + if (gate.required && !gate.passed) missing.push(`required gate failed: ${gate.id}`) + + return { ready: missing.length === 0, defaultEnablement: "off", missing } + } + + /** + * The release-ordering evaluator: fork release completes before the verified + * binary pin, which completes before the extension release. Reads the + * manifest's declared completion order — it does NOT perform any release act. + */ + export function orderingSatisfied(manifest: Manifest): boolean { + const { forkAt, binaryPinAt, extensionAt } = manifest.completion + return forkAt < binaryPinAt && binaryPinAt < extensionAt + } + } +} diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 27b275c7fd..ec43df8958 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -33,6 +33,8 @@ import type { InstanceContext } from "../project/instance-context" import { InstanceState } from "@/effect/instance-state" import { Snapshot } from "@/snapshot" import { ExternalDiff } from "@/session/external-diff" +import { SessionLineage } from "@/session/lineage" +import { SessionReceipt } from "@/session/receipt" import { ProjectV2 } from "@opencode-ai/core/project" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { SessionID, MessageID, PartID } from "./schema" @@ -418,6 +420,7 @@ export interface Interface { readonly listGlobal: (input?: GlobalListInput) => Effect.Effect readonly create: (input?: { parentID?: SessionID + lineageEdgeKind?: SessionLineage.EdgeKind title?: string agent?: string model?: Schema.Schema.Type @@ -450,6 +453,11 @@ export interface Interface { readonly diff: (sessionID: SessionID) => Effect.Effect readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect readonly children: (parentID: SessionID) => Effect.Effect + readonly lineage: ( + sessionID: SessionID, + options?: { retainedOrigins?: boolean }, + ) => Effect.Effect + readonly beginPartialLineage: (sessionID: SessionID, boundary?: number) => Effect.Effect readonly remove: (sessionID: SessionID) => Effect.Effect readonly updateMessage: (msg: T) => Effect.Effect readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect @@ -506,6 +514,7 @@ const layer: Layer.Layer< agent?: string model?: Schema.Schema.Type parentID?: SessionID + lineageEdgeKind?: SessionLineage.EdgeKind workspaceID?: WorkspaceV2.ID directory: string path?: string @@ -537,6 +546,11 @@ const layer: Layer.Layer< yield* Effect.logInfo("created", result) yield* events.publish(SessionV1.Event.Created, { sessionID: result.id, info: result }) + yield* SessionLineage.register(database, { + sessionID: result.id, + parentID: input.parentID, + edgeKind: input.lineageEdgeKind, + }) return result }) @@ -627,6 +641,8 @@ const layer: Layer.Layer< // External baselines are host-local and never belong to a fork or a // transcript. Tombstone them before deleting session records so an // in-flight mutation cannot recreate ownership after teardown starts. + yield* SessionLineage.retainBeforeDelete(database, { sessionID, title: session.title }) + yield* SessionReceipt.removeRootEvidence(database, sessionID).pipe(Effect.orDie) ExternalDiff.remove(sessionID) yield* events.publish(SessionV1.Event.Deleted, { sessionID, info: session }) yield* events.remove(sessionID) @@ -675,6 +691,7 @@ const layer: Layer.Layer< const create = Effect.fn("Session.create")(function* (input?: { parentID?: SessionID + lineageEdgeKind?: SessionLineage.EdgeKind title?: string agent?: string model?: Schema.Schema.Type @@ -686,6 +703,7 @@ const layer: Layer.Layer< const workspace = yield* InstanceState.workspaceID return yield* createNext({ parentID: input?.parentID, + lineageEdgeKind: input?.lineageEdgeKind, directory: ctx.directory, path: sessionPath(ctx.worktree, ctx.directory), title: input?.title, @@ -697,6 +715,18 @@ const layer: Layer.Layer< }) }) + const lineage: Interface["lineage"] = Effect.fn("Session.lineage")(function* (sessionID, options) { + yield* get(sessionID) + return yield* SessionLineage.get(database, sessionID, options) + }) + + const beginPartialLineage: Interface["beginPartialLineage"] = Effect.fn("Session.beginPartialLineage")( + function* (sessionID, boundary) { + yield* get(sessionID) + yield* SessionLineage.beginPartial(database, sessionID, boundary) + }, + ) + const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { const ctx = yield* InstanceState.context const original = yield* get(input.sessionID) @@ -1112,6 +1142,8 @@ const layer: Layer.Layer< diff, messages, children, + lineage, + beginPartialLineage, remove, updateMessage, removeMessage, diff --git a/packages/opencode/src/session/user-mutation.ts b/packages/opencode/src/session/user-mutation.ts new file mode 100644 index 0000000000..b74c38b108 --- /dev/null +++ b/packages/opencode/src/session/user-mutation.ts @@ -0,0 +1,265 @@ +import { SessionMutation } from "./mutation" + +/** + * Adopts user-initiated Sidebar, Preview, and Files Changed file operations as + * host-mediated mutation intents. The browser sends a display-safe + * {@link SessionUserMutation.MutationIntent} — an operation, a user-selected + * display target, and an idempotency key. The extension host resolves resource + * identity, snapshots the initiating panel and active session BEFORE input, + * obtains a #1077 operation-group context, and returns only a display-safe + * result. Browser code never receives a mutation capability, a canonical path, + * a hash, or evidence, and never determines externality. + * + * The registry (#1077) is the single source of truth for route identity and + * every user operation consumes its operation-group contract (idempotent retry, + * preflight-no-applied, explicit execution partials). This layer neither adopts + * engine tool routes (#1079) nor renders the unified UI (#1082). + */ +export namespace SessionUserMutation { + /** The panel a user operation originates from, mapped to its registered ledger route. */ + export const Routes = { + sidebar: "user-sidebar-op", + preview: "user-preview-edit", + review: "user-review-edit", + } as const + export type Panel = keyof typeof Routes + export type RouteID = (typeof Routes)[Panel] + + /** User actions are first-class session origins, distinct from agent, child-agent, and system events. */ + export const Origins = ["user", "agent", "child_agent", "system"] as const + export type Origin = (typeof Origins)[number] + + export function distinguish(origin: string): Origin | undefined { + return (Origins as ReadonlyArray).includes(origin) ? (origin as Origin) : undefined + } + + export type Capability = + | { mode: "full"; version: number } + | { mode: "partial"; version: number; label: "post_upgrade_partial" } + | { mode: "legacy"; label: "legacy" } + | { mode: "unavailable" } + + /** + * The four-outcome discovery for user mutation surfaces. Only `legacy` selects + * existing behavior; `partial` records a labelled post-upgrade epoch; `full` + * mediates a fresh full-provenance session; `unavailable` denies rather than + * letting a user operation proceed untracked. + */ + export namespace Capability { + export function discover(input: { + supported: readonly number[] + requested: number + hostReachable: boolean + sessionEpoch: "full" | "post_upgrade" + }): Capability { + if (!input.hostReachable) return { mode: "unavailable" } + if (!input.supported.includes(input.requested)) return { mode: "legacy", label: "legacy" } + if (input.sessionEpoch === "post_upgrade") + return { mode: "partial", version: input.requested, label: "post_upgrade_partial" } + return { mode: "full", version: input.requested } + } + } + + /** A display-safe intent — the ONLY thing the browser sends. No capability, path, hash, or evidence. */ + export type MutationIntent = + | { operation: "create" | "directory" | "edit"; panel: Panel; target: { display: string }; idempotencyKey: string } + | { + operation: "move" | "trash" | "restore" + panel: Panel + target: { display: string } + destination: { display: string } + idempotencyKey: string + } + | { + operation: "recursive_delete" + panel: Panel + targets: ReadonlyArray<{ display: string }> + idempotencyKey: string + recursive: { maxResources: number } + } + + /** + * The host's authenticated view of the initiating panel and active session, + * snapshotted before the user's input or confirmation. This never crosses to + * the browser. + */ + export type PanelSnapshot = { + panelID: string + sessionID: string + rootID: string + origin: Origin | string + } + + /** + * The host-side capability: it resolves a display target to a physical + * identity and applies verified writes through a no-follow gate. It stays on + * the host — the browser never holds one. + */ + export type HostProvider = { + capabilities: { safeResolve: boolean; noFollowWrite: boolean } + resolveTarget: (display: string) => SessionMutation.Endpoint | undefined + safeResolve: (endpoint: SessionMutation.Endpoint) => SessionMutation.Endpoint | undefined + execute: (resource: SessionMutation.DeclaredResource) => "applied" | "failed" + } + + /** A display-safe receipt reference: logical facts only, never the physical endpoint identity. */ + export type DisplayResource = { + operation: SessionMutation.ResourceOperation + role: SessionMutation.ResourceRole + outcome: "applied" | "failed" | "not_started" + } + + export type DenyReason = + | "unavailable" + | "non_user_origin" + | "unresolved_target" + | "context_unavailable" + | "resource_budget_exceeded" + | "unsafe_provider" + | "invalid_context" + + export type MediationResult = + | { kind: "applied" | "partial" | "failed"; panel: Panel; resources: ReadonlyArray; epoch?: "post_upgrade_partial" } + | { kind: "legacy"; label: "legacy" } + | { kind: "denied"; reason: DenyReason } + + type Plan = { resources: SessionMutation.DeclaredResource[]; recursive?: { maxResources: number } } + + /** Resolve every logical target to a physical identity host-side and shape the declared group. */ + function planResources(intent: MutationIntent, resolveTarget: HostProvider["resolveTarget"]): Plan | undefined { + if (intent.operation === "recursive_delete") { + const resources: SessionMutation.DeclaredResource[] = [] + intent.targets.forEach((entry, index) => { + const endpoint = resolveTarget(entry.display) + if (endpoint) resources.push({ id: `target-${index}`, endpoint, operation: "delete", role: "target" }) + }) + if (resources.length !== intent.targets.length || resources.length === 0) return + return { resources, recursive: intent.recursive } + } + if (intent.operation === "move" || intent.operation === "trash" || intent.operation === "restore") { + const source = resolveTarget(intent.target.display) + const destination = resolveTarget(intent.destination.display) + if (!source || !destination) return + return { + resources: [ + { id: "source", endpoint: source, operation: "move", role: "source" }, + { id: "destination", endpoint: destination, operation: "move", role: "destination" }, + ], + } + } + const endpoint = resolveTarget(intent.target.display) + if (!endpoint) return + if (intent.operation === "directory") + return { resources: [{ id: "target", endpoint, operation: "create_parent", role: "implicit_parent" }] } + if (intent.operation === "edit") + return { resources: [{ id: "target", endpoint, operation: "edit", role: "target" }] } + return { resources: [{ id: "target", endpoint, operation: "write", role: "target" }] } + } + + const display = (resource: SessionMutation.ResourceReceipt): DisplayResource => ({ + operation: resource.operation, + role: resource.role, + outcome: resource.outcome, + }) + + /** + * Mediate one user file operation host-side. Capability gates first (legacy is + * a labelled passthrough, unavailable denies); a non-user origin is refused; a + * registered operation-group context is obtained bound to the panel/session + * snapshot, and the write runs through #1077's group contract. An invalid or + * unresolvable context denies before any filesystem access — never an + * untracked mutation — and only display-safe facts are returned. + */ + export function mediate(input: { + intent: MutationIntent + capability: Capability + snapshot: PanelSnapshot + rootForSession: (sessionID: string) => string | undefined + gate: ReturnType + provider: HostProvider + now?: () => number + ttl?: number + }): MediationResult { + const { capability } = input + if (capability.mode === "legacy") return { kind: "legacy", label: capability.label } + if (capability.mode === "unavailable") return { kind: "denied", reason: "unavailable" } + const epoch = capability.mode === "partial" ? capability.label : undefined + + // The snapshot is captured before input; a non-user origin never adopts a user route. + if (input.snapshot.origin !== "user") return { kind: "denied", reason: "non_user_origin" } + + const routeID = Routes[input.intent.panel] + const plan = planResources(input.intent, input.provider.resolveTarget) + if (!plan) return { kind: "denied", reason: "unresolved_target" } + + const now = input.now ?? (() => Date.now()) + const expiresAt = now() + (input.ttl ?? 30_000) + const context = input.gate.issueGroup({ + routeID, + panelID: input.snapshot.panelID, + sessionID: input.snapshot.sessionID, + rootID: input.snapshot.rootID, + origin: input.snapshot.origin, + operation: input.intent.operation, + resources: plan.resources, + ...(plan.recursive ? { recursive: plan.recursive } : {}), + expiresAt, + }) + if (!context) return { kind: "denied", reason: "context_unavailable" } + + const request: SessionMutation.GroupRequest = { + routeID, + panelID: input.snapshot.panelID, + sessionID: input.snapshot.sessionID, + rootID: input.snapshot.rootID, + origin: input.snapshot.origin, + operation: input.intent.operation, + operationID: input.intent.idempotencyKey, + resources: plan.resources, + ...(plan.recursive ? { recursive: plan.recursive } : {}), + } + const execution = input.gate.executeGroup({ + context, + request, + provider: { + capabilities: input.provider.capabilities, + safeResolve: input.provider.safeResolve, + execute: input.provider.execute, + }, + }) + + if (execution.kind === "denied") { + if (execution.reason === "resource_budget_exceeded") + return { kind: "denied", reason: "resource_budget_exceeded" } + if (execution.reason === "unsafe_provider") return { kind: "denied", reason: "unsafe_provider" } + return { kind: "denied", reason: "invalid_context" } + } + + const result = execution.result as SessionMutation.GroupResult + const kind = result.outcome === "applied" ? "applied" : result.outcome === "partial" ? "partial" : "failed" + return { + kind, + panel: input.intent.panel, + resources: result.receipts.map(display), + ...(epoch ? { epoch } : {}), + } + } + + /** + * Watchers only revalidate server-owned receipts. They receive receipt + * references and assessment revisions, emit invalidation, and can never derive + * ownership, lifecycle, canonical paths, hashes, or evidence locally. An + * unknown reference is ignored — never adopted or attributed. + */ + export namespace Watcher { + export type Signal = { receiptRef: string; assessmentRevision: number } + export type Outcome = { kind: "invalidate"; receiptRef: string } | { kind: "ignore" } + + export function revalidate(known: ReadonlyMap, signal: Signal): Outcome { + const current = known.get(signal.receiptRef) + if (current === undefined) return { kind: "ignore" } + if (signal.assessmentRevision > current) return { kind: "invalidate", receiptRef: signal.receiptRef } + return { kind: "ignore" } + } + } +} diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 1384e5d197..f6dbf29ab5 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -157,6 +157,7 @@ export const TaskTool = Tool.define( session ?? (yield* sessions.create({ parentID: ctx.sessionID, + lineageEdgeKind: "task_spawn", title: params.description + ` (@${next.name} subagent)`, agent: next.name, permission: [ diff --git a/packages/opencode/test/server/session-diff-scoped.test.ts b/packages/opencode/test/server/session-diff-scoped.test.ts index a50bedd267..684f85d959 100644 --- a/packages/opencode/test/server/session-diff-scoped.test.ts +++ b/packages/opencode/test/server/session-diff-scoped.test.ts @@ -325,6 +325,45 @@ describe("Session.diff — session-scoped agent diffs (#174)", () => { { git: true, config: { formatter: false, lsp: false } }, ) + it.instance( + "projects persisted v1 external diffs as legacy_external without ownership", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const fs = yield* FSUtil.Service + const session = yield* withSession({ title: "external-legacy-compatibility" }) + const sibling = path.join(path.dirname(test.directory), `external-legacy-${session.id}.txt`) + yield* fs.writeWithDirs(sibling, "before\n") + const reservation = ExternalDiff.prepare({ sessionID: session.id, files: [sibling] })! + yield* fs.writeWithDirs(sibling, "after\n") + expect(ExternalDiff.commit({ sessionID: session.id, reservation })).toBe(true) + const legacyWire = ExternalDiff.assessed(session.id) + + ExternalDiff.resetMemoryForTest() + + const compatibility = ExternalDiff.compatibility(session.id) + expect(compatibility).toEqual([ + { + kind: "legacy_external", + assessment: expect.objectContaining({ + reference: reservation.endpoints[0].reference, + file: sibling, + state: "changed", + status: "modified", + }), + }, + ]) + expect(compatibility[0]).not.toHaveProperty("operationID") + expect(compatibility[0]).not.toHaveProperty("rootID") + expect(compatibility[0]).not.toHaveProperty("lineage") + expect(compatibility[0]).not.toHaveProperty("origin") + const stillLegacy = ExternalDiff.assessed(session.id) + expect(Object.keys(stillLegacy).sort()).toEqual(["assessments", "revision", "version"]) + expect(stillLegacy).toMatchObject({ version: legacyWire.version, assessments: legacyWire.assessments }) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + it.instance( "keeps generated external patches out of unrequested detail and legacy diff responses", () => diff --git a/packages/opencode/test/session/adversarial-e2e.test.ts b/packages/opencode/test/session/adversarial-e2e.test.ts new file mode 100644 index 0000000000..67be3e6493 --- /dev/null +++ b/packages/opencode/test/session/adversarial-e2e.test.ts @@ -0,0 +1,902 @@ +import { describe, expect, test } from "bun:test" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { Effect, Layer } from "effect" +import { eq } from "drizzle-orm" +import { Database } from "@opencode-ai/core/database/database" +import { SessionLineageTable } from "@opencode-ai/core/session/sql" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { ProvenanceMatrix } from "@opencode-ai/schema/provenance-matrix" +import { SessionMutation } from "@/session/mutation" +import { SessionReceipt } from "@/session/receipt" +import { SessionEvidence } from "@/session/evidence" +import { SessionReceiptPrivacy } from "@/session/receipt-privacy" +import { SessionRollout } from "@/session/rollout" +import { ExternalDiff } from "@/session/external-diff" +import { Session as SessionNs } from "@/session/session" +import { EventV2Bridge } from "@/event-v2-bridge" +import { InstanceStore } from "@/project/instance-store" +import { InstanceBootstrap } from "@/project/bootstrap" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { testEffect } from "../lib/effect" + +/** + * The engine harness of the adversarial full-provenance matrix (amicode#1084). + * + * This cross-harness E2E fixture RUNS every engine-owned matrix row against the + * REAL merged contracts — the mutation gate + identity (#1077), lineage (#1075), + * receipt/evidence/budget storage (#1088/#1090/#1103/#1104), the privacy + * serializers (#1078), the external-diff boundary, and the rollout resolver + * (#1083) — and records each row that produced its declared observable result as + * green. The final gate assertion feeds the green set to `ProvenanceMatrix.gate` + * and proves `all_required_passed` is true only when every required engine case is + * green (product-denied outcomes are asserted expected results, not failures), and + * composes with the #1083 `SessionRollout.Release` readiness evaluator. + * + * Isolation: every mutated file lives in an isolated `mkdtemp` root that is removed + * with a containment assertion; receipt/evidence/external-diff storage and the + * database are sandboxed by test/preload.ts (temp XDG_DATA_HOME + in-memory SQLite). + * No case touches a real user directory. + */ + +// Case IDs that ran green, collected across every per-row test below. The final +// gate test consumes this set — a row whose assertions throw never lands here. +const green = new Set() + +const engine = ProvenanceMatrix.subset("engine") +const isDBRow = (row: ProvenanceMatrix.Row) => + (row.dimension === "outcome" && (row.expectedOutcome === "aborted" || row.expectedOutcome === "unavailable")) || + (row.dimension === "version" && row.concern !== "mixed_binaries" && row.concern !== "rollback") || + (row.dimension === "concurrency" && row.id === "concurrency:non-attribution") + +// ── In-memory mutation-gate helpers (pure boundaries) ──────────────────────── + +const SESSION = "ses_1084_root" +const ROOT = "ses_1084_root" +const makeGate = (now = 1_000) => + SessionMutation.create({ + rootForSession: (s) => (s === SESSION ? ROOT : undefined), + now: () => now, + operations: SessionMutation.OperationStore.memory(), + }) + +type Endpoint = { value: string; kind: "file" | "directory" } +const fileEndpoint = (value: string): Endpoint => ({ value, kind: "file" }) + +const declaredResource = (id: string, value: string): SessionMutation.DeclaredResource => ({ + id, + endpoint: fileEndpoint(value), + operation: "write", + role: "target", +}) + +// ── Origin dimension (AC1): agent/child-agent/user/system/opaque × modes ───── + +const hostReceiptWithOrigin = (origin: string): SessionReceiptPrivacy.HostReceipt => ({ + operation: { id: "op-priv", rootID: "root-priv", sessionID: "ses-priv", origin, state: "committed" }, + receipt: { id: "r1", sequence: 1, resource: "src/a.ts", operation: "write", outcome: "applied", timeCreated: 1 }, + assessment: { + id: "assess-priv", + receiptID: "r1", + confidence: "observed", + netState: "modified", + evidenceState: "available", + revision: 1, + timeCreated: 2, + }, +}) + +function runOrigin(row: ProvenanceMatrix.Row): void { + const origin = row.origin! + if (origin === "opaque") { + const gate = makeGate() + const ctx = gate.issue({ + routeID: "shell-action", + kind: "opaque", + panelID: "p", + sessionID: SESSION, + rootID: ROOT, + origin: "opaque", + operation: "shell", + expiresAt: 5_000, + })! + expect(ctx).toBeDefined() + const result = gate.executeOpaque({ + context: ctx, + request: { + routeID: "shell-action", + panelID: "p", + sessionID: SESSION, + rootID: ROOT, + origin: "opaque", + operation: "shell", + operationID: `op-opaque-${row.requiredMode}`, + }, + }) + expect(result.kind).toBe("executed") + if (result.kind !== "executed") return + expect(result.result.outcome).toBe("unknown") + expect((result.result as SessionMutation.OpaqueResult).receipt.origin).toBe("opaque") + return + } + + if (row.requiredMode === "legacy") { + const legacy = { version: 1, note: "pre-ledger" } + const out = SessionReceiptPrivacy.display({ legacy, receipt: hostReceiptWithOrigin(origin) }, { mode: "legacy" }) + // Legacy mode retains the pre-ledger payload — never a full-provenance claim. + expect(out).toBe(legacy) + return + } + + // full / partial: the capability-selected projection carries the origin, attributed to the active root. + const projection = SessionReceiptPrivacy.display( + { legacy: { version: 1 }, receipt: hostReceiptWithOrigin(origin) }, + { mode: "full", version: 1 }, + ) + expect(projection).toEqual(SessionReceiptPrivacy.project(hostReceiptWithOrigin(origin), "files_changed")) + expect((projection as SessionReceiptPrivacy.Projection).operation?.origin).toBe(origin) +} + +// ── Resource dimension (AC2): every filesystem resource class ──────────────── + +function withTmp(fn: (dir: string) => void): void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "amc1084-res-")) + try { + fn(dir) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + // Containment: the isolated fixture is gone; nothing escaped it. + expect(fs.existsSync(dir)).toBe(false) + } +} + +const identity = (target: string, kind: "file" | "directory") => SessionMutation.ResourceIdentity.resolve(target, kind) + +function runExternalDiff(sessionID: string, git: boolean): void { + withTmp((dir) => { + if (git) Bun.spawnSync(["git", "init", "-q", dir]) + const file = path.join(dir, "external.txt") + fs.writeFileSync(file, "baseline\n") + const reservation = ExternalDiff.prepare({ sessionID, files: [file] }) + expect(reservation).toBeDefined() + fs.writeFileSync(file, "changed\n") + expect(ExternalDiff.commit({ sessionID, reservation: reservation! })).toBe(true) + + const canonical = path.resolve(file) + const assessment = ExternalDiff.assessed(sessionID).assessments.find((a) => a.file === canonical) + expect(assessment?.state).toBe("changed") + // The generated patch never crosses this boundary by default — host-local only. + expect(assessment).not.toHaveProperty("patch") + const detail = ExternalDiff.assessed(sessionID, { patch: true }).assessments.find((a) => a.file === canonical) + expect(typeof (detail as { patch?: string }).patch).toBe("string") + ExternalDiff.remove(sessionID) + }) +} + +function runResource(row: ProvenanceMatrix.Row): void { + switch (row.resourceClass) { + case "workspace": + return withTmp((dir) => { + const file = path.join(dir, "w.ts") + fs.writeFileSync(file, "x") + const id = identity(file, "file") + expect(id?.value.startsWith("local:existing:")).toBe(true) + expect(identity(file, "file")!.value).toBe(id!.value) + }) + case "symlink": + return withTmp((dir) => { + const target = path.join(dir, "t.ts") + fs.writeFileSync(target, "x") + const link = path.join(dir, "l.ts") + fs.symlinkSync(target, link) + // A symlink binds the SAME physical identity as its target — the path is discarded. + expect(identity(link, "file")!.value).toBe(identity(target, "file")!.value) + }) + case "alias": + return withTmp((dir) => { + const target = path.join(dir, "t.ts") + fs.writeFileSync(target, "x") + const hard = path.join(dir, "h.ts") + fs.linkSync(target, hard) + expect(identity(hard, "file")!.value).toBe(identity(target, "file")!.value) + }) + case "directory": + return withTmp((dir) => { + const sub = path.join(dir, "sub") + fs.mkdirSync(sub) + expect(identity(sub, "directory")?.kind).toBe("directory") + expect(identity(sub, "file")).toBeUndefined() + }) + case "binary": + return withTmp((dir) => { + const file = path.join(dir, "b.bin") + fs.writeFileSync(file, Buffer.from([0, 1, 2, 255, 254, 0, 3])) + // Identity is by physical inode, content-agnostic — the bytes never enter it. + expect(identity(file, "file")?.value.startsWith("local:existing:")).toBe(true) + }) + case "trash": + return withTmp((dir) => { + const file = path.join(dir, "f.ts") + fs.writeFileSync(file, "x") + expect(identity(file, "file")!.value.startsWith("local:existing:")).toBe(true) + const trash = path.join(dir, "Trash") + fs.mkdirSync(trash) + fs.renameSync(file, path.join(trash, "f.ts")) + // The original path is now missing — the delete is captured as an identity change. + expect(identity(file, "file")!.value.startsWith("local:missing:")).toBe(true) + }) + case "restore": + return withTmp((dir) => { + const file = path.join(dir, "f.ts") + expect(identity(file, "file")!.value.startsWith("local:missing:")).toBe(true) + fs.writeFileSync(file, "x") + expect(identity(file, "file")!.value.startsWith("local:existing:")).toBe(true) + }) + case "internal": { + // An internal (out-of-scope) store is never a ledger route → no context is issued. + expect(SessionMutation.Registry.require("credential-store")?.kind).toBe("out_of_scope") + const gate = makeGate() + const ctx = gate.issue({ + routeID: "credential-store", + kind: "local", + panelID: "p", + sessionID: SESSION, + rootID: ROOT, + origin: "system", + operation: "write", + source: fileEndpoint("local:existing:1:1"), + expiresAt: 5_000, + }) + expect(ctx).toBeUndefined() + return + } + case "unsupported_provider": { + const gate = makeGate() + const source = fileEndpoint("local:existing:1:1") + const ctx = gate.issue({ + routeID: "local-file-write", + kind: "local", + panelID: "p", + sessionID: SESSION, + rootID: ROOT, + origin: "agent", + operation: "write", + source, + expiresAt: 5_000, + })! + const result = gate.executeLocal({ + context: ctx, + request: { + routeID: "local-file-write", + panelID: "p", + sessionID: SESSION, + rootID: ROOT, + origin: "agent", + operation: "write", + operationID: "op-unsafe", + source, + }, + provider: { + capabilities: { safeResolve: false, noFollowWrite: false }, + safeResolve: (e) => e, + noFollowWrite: () => ({ groupID: "g", outcome: "applied" }), + }, + }) + // A provider lacking the safe-resolve / no-follow-write capability is DENIED (expected product-denial). + expect(result.kind).toBe("denied") + if (result.kind === "denied") expect(result.reason).toBe("unsafe_provider") + return + } + case "non_git_external": + return runExternalDiff("ext_non_git_1084", false) + case "external_repo": + return runExternalDiff("ext_repo_1084", true) + case "artifact": + return runExternalDiff("ext_artifact_1084", false) + default: + throw new Error(`unhandled resource class: ${row.resourceClass}`) + } +} + +// ── Outcome dimension (AC3): pure gate outcomes ────────────────────────────── + +function runGateOutcome(outcome: ProvenanceMatrix.Outcome): void { + const source = fileEndpoint("local:existing:9:900") + const localRequest = { + routeID: "local-file-write" as const, + panelID: "p", + sessionID: SESSION, + rootID: ROOT, + origin: "agent", + operation: "write", + } + switch (outcome) { + case "success": { + const gate = makeGate() + const ctx = gate.issue({ ...localRequest, kind: "local", source, expiresAt: 5_000 })! + const result = gate.executeLocal({ + context: ctx, + request: { ...localRequest, operationID: "op-ok", source }, + provider: { + capabilities: { safeResolve: true, noFollowWrite: true }, + safeResolve: (e) => e, + noFollowWrite: () => ({ groupID: "op-ok", outcome: "applied" }), + }, + }) + expect(result.kind).toBe("executed") + if (result.kind === "executed") expect(result.result.outcome).toBe("applied") + return + } + case "denied": { + const gate = makeGate() + const result = gate.executeLocal({ + request: { ...localRequest, operationID: "op-denied", source }, + provider: { + capabilities: { safeResolve: true, noFollowWrite: true }, + safeResolve: (e) => e, + noFollowWrite: () => ({ groupID: "op-denied", outcome: "applied" }), + }, + }) + expect(result.kind).toBe("denied") + if (result.kind === "denied") expect(result.reason).toBe("missing_context") + return + } + case "expiry": { + const gate = makeGate(1_000) + const ctx = gate.issue({ ...localRequest, kind: "local", source, expiresAt: 1_000 })! + const result = gate.executeLocal({ + context: ctx, + request: { ...localRequest, operationID: "op-exp", source }, + provider: { + capabilities: { safeResolve: true, noFollowWrite: true }, + safeResolve: (e) => e, + noFollowWrite: () => ({ groupID: "op-exp", outcome: "applied" }), + }, + }) + expect(result.kind).toBe("denied") + if (result.kind === "denied") expect(result.reason).toBe("expired_context") + return + } + case "conflict": { + const gate = makeGate() + const ctx = gate.issue({ ...localRequest, kind: "local", source, expiresAt: 5_000 })! + const result = gate.executeLocal({ + context: ctx, + request: { ...localRequest, operationID: "op-conflict", source }, + provider: { + capabilities: { safeResolve: true, noFollowWrite: true }, + // The source resolved to a DIFFERENT physical identity between issue and execute — a conflict. + safeResolve: () => fileEndpoint("local:existing:9:999"), + noFollowWrite: () => ({ groupID: "op-conflict", outcome: "applied" }), + }, + }) + expect(result.kind).toBe("denied") + if (result.kind === "denied") expect(result.reason).toBe("identity_changed") + return + } + case "opaque": { + const gate = makeGate() + const ctx = gate.issue({ + routeID: "shell-action", + kind: "opaque", + panelID: "p", + sessionID: SESSION, + rootID: ROOT, + origin: "agent", + operation: "shell", + expiresAt: 5_000, + })! + const result = gate.executeOpaque({ + context: ctx, + request: { + routeID: "shell-action", + panelID: "p", + sessionID: SESSION, + rootID: ROOT, + origin: "agent", + operation: "shell", + operationID: "op-opaque-outcome", + }, + }) + expect(result.kind).toBe("executed") + if (result.kind === "executed") expect(result.result.outcome).toBe("unknown") + return + } + case "failed": { + const gate = makeGate() + const resources = [declaredResource("r1", "local:existing:1:1")] + const groupRequest = { + routeID: "tool-write" as const, + panelID: "p", + sessionID: SESSION, + rootID: ROOT, + origin: "agent", + operation: "write", + resources, + } + const ctx = gate.issueGroup({ ...groupRequest, expiresAt: 5_000 })! + const result = gate.executeGroup({ + context: ctx, + request: { ...groupRequest, operationID: "op-failed" }, + provider: { + capabilities: { safeResolve: true, noFollowWrite: true }, + // A resource's identity no longer resolves to what was reserved → the group fails, nothing started. + safeResolve: (e) => ({ value: `${e.value}X`, kind: e.kind }), + execute: () => "applied", + }, + }) + expect(result.kind).toBe("executed") + if (result.kind === "executed") { + expect(result.result.outcome).toBe("failed") + expect(result.result.receipts.every((r) => r.outcome === "not_started")).toBe(true) + } + return + } + case "partial": { + const gate = makeGate() + const resources = [ + declaredResource("r1", "local:existing:1:1"), + declaredResource("r2", "local:existing:1:2"), + ] + const groupRequest = { + routeID: "tool-write" as const, + panelID: "p", + sessionID: SESSION, + rootID: ROOT, + origin: "agent", + operation: "write", + resources, + } + const ctx = gate.issueGroup({ ...groupRequest, expiresAt: 5_000 })! + const result = gate.executeGroup({ + context: ctx, + request: { ...groupRequest, operationID: "op-partial" }, + provider: { + capabilities: { safeResolve: true, noFollowWrite: true }, + safeResolve: (e) => e, + execute: (resource) => (resource.id === "r1" ? "applied" : "failed"), + }, + }) + expect(result.kind).toBe("executed") + if (result.kind === "executed") expect(result.result.outcome).toBe("partial") + return + } + case "quota": { + const gate = makeGate() + const resources = [ + declaredResource("r1", "local:existing:1:1"), + declaredResource("r2", "local:existing:1:2"), + ] + const groupRequest = { + routeID: "tool-write" as const, + panelID: "p", + sessionID: SESSION, + rootID: ROOT, + origin: "agent", + operation: "write", + resources, + recursive: { maxResources: 1 }, + } + const ctx = gate.issueGroup({ ...groupRequest, expiresAt: 5_000 })! + const result = gate.executeGroup({ + context: ctx, + request: { ...groupRequest, operationID: "op-quota" }, + provider: { + capabilities: { safeResolve: true, noFollowWrite: true }, + safeResolve: (e) => e, + execute: () => "applied", + }, + }) + // The recursive group exceeded its resource budget → DENIED (quota). + expect(result.kind).toBe("denied") + if (result.kind === "denied") expect(result.reason).toBe("resource_budget_exceeded") + return + } + default: + throw new Error(`unhandled pure outcome: ${outcome}`) + } +} + +// ── Privacy dimension (AC5): capability/evidence/protected metadata never egress + +const FIELD_SENTINEL: Partial> = { + "context.capability": "__CAPABILITY_1084__", + "evidence.content": "__EVIDENCE_BYTES_1084__", + "operation.id": "__OPERATION_ID_1084__", + "operation.rootID": "__ROOT_ID_1084__", + "operation.sessionID": "__SESSION_ID_1084__", + "assessment.id": "__ASSESSMENT_ID_1084__", + "context.canonicalPath": "__CANONICAL_PATH_1084__", + "context.rawHash": "__RAW_HASH_1084__", + "context.baseline": "__BASELINE_1084__", + "context.redactionDecision": "__REDACTION_1084__", +} + +const sentinelHostReceipt = (): SessionReceiptPrivacy.HostReceipt => ({ + operation: { + id: FIELD_SENTINEL["operation.id"], + rootID: FIELD_SENTINEL["operation.rootID"], + sessionID: FIELD_SENTINEL["operation.sessionID"], + origin: "agent", + state: "committed", + }, + receipt: { id: "r1", sequence: 1, resource: "src/a.ts", operation: "write", outcome: "applied", timeCreated: 1 }, + assessment: { + id: FIELD_SENTINEL["assessment.id"], + receiptID: "r1", + confidence: "observed", + netState: "modified", + evidenceState: "available", + revision: 1, + timeCreated: 2, + }, + evidence: { receiptID: "r1", content: FIELD_SENTINEL["evidence.content"] }, + context: { + capability: FIELD_SENTINEL["context.capability"], + canonicalPath: FIELD_SENTINEL["context.canonicalPath"], + rawHash: FIELD_SENTINEL["context.rawHash"], + baseline: FIELD_SENTINEL["context.baseline"], + redactionDecision: FIELD_SENTINEL["context.redactionDecision"], + }, + derived: { patch: FIELD_SENTINEL["evidence.content"], additions: 1, deletions: 1 }, +}) + +function runPrivacy(row: ProvenanceMatrix.Row): void { + const boundary = row.prohibitedEgress[0]!.boundary + const projection = SessionReceiptPrivacy.project(sentinelHostReceipt(), boundary) + const serialized = JSON.stringify(projection) + // Every field the row declares prohibited at this boundary is absent from the projection. + for (const prohibited of row.prohibitedEgress) { + const sentinel = FIELD_SENTINEL[prohibited.field] + if (sentinel) expect(serialized).not.toContain(sentinel) + } + // The browser boundary still surfaces the display-safe fields; other egress carries only redacted markers. + if (boundary === "browser") { + expect((projection as SessionReceiptPrivacy.Projection).receipt?.resource).toBe("src/a.ts") + expect((projection as SessionReceiptPrivacy.Projection).operation?.origin).toBe("agent") + } +} + +// ── Concurrency dimension (AC4): cross-root context is denied (pure) ────────── + +function runCrossRootDenied(): void { + const store = SessionMutation.OperationStore.memory() + // The session maps to root A at issue time, but resolves to root B at execute time. + let mapped = "root-A" + const gate = SessionMutation.create({ + rootForSession: () => mapped, + now: () => 1_000, + operations: store, + }) + const source = fileEndpoint("local:existing:2:2") + const base = { routeID: "local-file-write" as const, panelID: "p", sessionID: "ses-x", origin: "agent", operation: "write" } + const ctx = gate.issue({ ...base, rootID: "root-A", kind: "local", source, expiresAt: 5_000 })! + expect(ctx).toBeDefined() + mapped = "root-B" + const result = gate.executeLocal({ + context: ctx, + request: { ...base, rootID: "root-A", operationID: "op-x", source }, + provider: { + capabilities: { safeResolve: true, noFollowWrite: true }, + safeResolve: (e) => e, + noFollowWrite: () => ({ groupID: "op-x", outcome: "applied" }), + }, + }) + // A context is bound to one root; a session that now resolves elsewhere can never use it. + expect(result.kind).toBe("denied") + if (result.kind === "denied") expect(result.reason).toBe("invalid_context") +} + +// ── Version dimension (AC6): pure rollout concerns ─────────────────────────── + +function runRolloutConcern(concern: ProvenanceMatrix.VersionConcern): void { + const v = SessionRollout.PROTOCOL_VERSION + if (concern === "mixed_binaries") { + const oldEngine = SessionRollout.Matrix.resolve({ + engine: { supported: [v], requested: v + 1 }, + client: "new", + root: { mode: "full" }, + }) + expect(oldEngine.mode).toBe("legacy") + const oldClient = SessionRollout.Matrix.resolve({ + engine: { supported: [v], requested: v }, + client: "old", + root: { mode: "full" }, + }) + // A new engine + old client keeps its pre-ledger view — a visibly-labelled non-full claim. + expect(oldClient.mode).toBe("legacy") + expect(oldClient.label).toBe("pre_ledger") + return + } + // rollback: full discovery is disabled BEFORE client routes are withdrawn — no uncontextualized window. + expect( + SessionRollout.Capability.discover({ supported: [v], requested: v, root: { mode: "full" }, fullDiscoveryEnabled: false }).mode, + ).toBe("legacy") + expect(SessionRollout.Rollback.unsafe({ fullDiscovery: true, clientRoutes: false })).toBe(true) + for (const step of SessionRollout.Rollback.plan()) expect(SessionRollout.Rollback.unsafe(step)).toBe(false) +} + +// ── Register the pure per-row tests, grouped by dimension ──────────────────── + +const enginePure = engine.cases.filter((row) => !isDBRow(row)) + +describe("adversarial matrix — engine harness, pure boundaries", () => { + for (const row of enginePure) { + test(row.id, () => { + switch (row.dimension) { + case "origin": + runOrigin(row) + break + case "resource": + runResource(row) + break + case "outcome": + runGateOutcome(row.expectedOutcome!) + break + case "privacy": + runPrivacy(row) + break + case "concurrency": + runCrossRootDenied() + break + case "version": + runRolloutConcern(row.concern!) + break + default: + throw new Error(`no pure executor for dimension ${row.dimension}`) + } + green.add(row.id) + }) + } +}) + +// ── Register the database-backed rows via the shared session harness ───────── + +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([ + SessionNs.node, + Database.node, + EventV2Bridge.node, + SessionProjector.node, + CrossSpawnSpawner.node, + InstanceStore.node, + ]), + [ + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces: false })], + [InstanceBootstrap.node, Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))], + ], + ), +) + +const budget = { maxReceipts: 4, maxMetadataBytes: 100_000 } + +describe("adversarial matrix — engine harness, database-backed boundaries", () => { + it.instance("outcome:aborted", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const database = yield* Database.Service + const root = yield* session.create({ title: "aborted" }) + yield* SessionReceipt.reserve(database, { + id: "op-abort", + sessionID: root.id, + origin: "agent", + receipts: [{ id: "r-abort", resource: "file:///a", operation: "write", outcome: "applied", timeCreated: 1 }], + budget: { maxReceipts: 1, maxMetadataBytes: 1_000 }, + }) + yield* SessionReceipt.abort(database, "op-abort") + expect(yield* SessionReceipt.committed(database, root.id)).toEqual([]) + yield* session.remove(root.id) + green.add("outcome:aborted") + }), + ) + + it.instance("outcome:unavailable", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const database = yield* Database.Service + const root = yield* session.create({ title: "unavailable" }) + yield* SessionReceipt.publish(database, { + id: "op-unavail", + sessionID: root.id, + origin: "agent", + budget: { maxReceipts: 1, maxMetadataBytes: 1_000, maxEvidenceBytes: 1_000 }, + receipts: [{ id: "r-unavail", resource: "file:///u", operation: "write", outcome: "applied", timeCreated: 1 }], + evidence: [{ receiptID: "r-unavail", content: "baseline" }], + }) + SessionEvidence.removeRoot(root.id) + yield* SessionReceipt.assessEvidence(database, { receiptID: "r-unavail", timeCreated: 2 }) + const assessments = yield* SessionReceipt.assessments(database, "r-unavail") + const latest = assessments.at(-1)! + expect(latest.evidenceState).toBe("unavailable") + expect(latest).not.toHaveProperty("patch") + yield* session.remove(root.id) + green.add("outcome:unavailable") + }), + ) + + it.instance("concurrency:non-attribution", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const database = yield* Database.Service + const [a, b] = yield* Effect.all([session.create({ title: "root-a" }), session.create({ title: "root-b" })], { + concurrency: "unbounded", + }) + yield* Effect.all( + [ + SessionReceipt.publish(database, { + id: "op-a", + sessionID: a.id, + origin: "agent", + budget, + receipts: [{ id: "r-a", resource: "file:///a", operation: "write", outcome: "applied", timeCreated: 1 }], + }), + SessionReceipt.publish(database, { + id: "op-b", + sessionID: b.id, + origin: "agent", + budget, + receipts: [{ id: "r-b", resource: "file:///b", operation: "write", outcome: "applied", timeCreated: 1 }], + }), + ], + { concurrency: "unbounded" }, + ) + const aCommitted = yield* SessionReceipt.committed(database, a.id) + const bCommitted = yield* SessionReceipt.committed(database, b.id) + // The active root sees ONLY its own receipts — the unrelated concurrent writer is never attributed to it. + expect(aCommitted.flatMap((o) => o.receipts.map((r) => r.id))).toEqual(["r-a"]) + expect(bCommitted.flatMap((o) => o.receipts.map((r) => r.id))).toEqual(["r-b"]) + expect(aCommitted.some((o) => o.rootID === b.id)).toBe(false) + yield* session.remove(a.id) + yield* session.remove(b.id) + green.add("concurrency:non-attribution") + }), + ) + + it.instance("version:task_aggregation", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const parent = yield* session.create({ title: "parent" }) + const task = yield* session.create({ parentID: parent.id, lineageEdgeKind: "task_spawn", title: "task" }) + const lineage = yield* session.lineage(task.id) + expect(lineage.rootID).toBe(parent.id) + expect(lineage.descendants.some((d) => d.sessionID === task.id && d.edgeKind === "task_spawn")).toBe(true) + green.add("version:task_aggregation") + }), + ) + + it.instance("version:spawn_aggregation", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const parent = yield* session.create({ title: "parent" }) + const spawn = yield* session.create({ parentID: parent.id, title: "spawn" }) + const lineage = yield* session.lineage(spawn.id) + expect(lineage.rootID).toBe(parent.id) + expect(lineage.descendants.some((d) => d.sessionID === spawn.id && d.edgeKind === "session_spawn")).toBe(true) + green.add("version:spawn_aggregation") + }), + ) + + it.instance("version:fork_separation", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const parent = yield* session.create({ title: "source" }) + yield* session.create({ parentID: parent.id, title: "source child" }) + const fork = yield* session.fork({ sessionID: parent.id }) + const lineage = yield* session.lineage(fork.id) + // A fork is its own root and inherits no descendants. + expect(lineage.rootID).toBe(fork.id) + expect(lineage.descendants).toEqual([]) + green.add("version:fork_separation") + }), + ) + + it.instance("version:child_archival", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const root = yield* session.create({ title: "root" }) + const child = yield* session.create({ parentID: root.id, title: "child" }) + yield* session.setArchived({ sessionID: child.id, time: 1 }) + expect((yield* session.lineage(root.id)).descendants).toEqual([]) + green.add("version:child_archival") + }), + ) + + it.instance("version:child_deletion", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const root = yield* session.create({ title: "root" }) + const child = yield* session.create({ + parentID: root.id, + title: "child", + metadata: { private: "do not retain" }, + }) + yield* session.setArchived({ sessionID: child.id, time: 1 }) + yield* session.remove(child.id) + const lineage = yield* session.lineage(root.id, { retainedOrigins: true }) + const retained = lineage.retainedOrigins.find((o) => o.sessionID === child.id) + // Only the root-owned origin projection is retained — no private metadata survives. + expect(retained?.title).toBe("child") + expect(retained).not.toHaveProperty("metadata") + green.add("version:child_deletion") + }), + ) + + it.instance("version:legacy_session", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const database = yield* Database.Service + const legacy = yield* session.create({ title: "before rollout" }) + yield* database.db.delete(SessionLineageTable).where(eq(SessionLineageTable.session_id, legacy.id)).run() + const lineage = yield* session.lineage(legacy.id) + expect(lineage.mode).toBe("legacy") + expect(lineage.rootID).toBeUndefined() + green.add("version:legacy_session") + }), + ) + + it.instance("version:upgrade_epoch", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const database = yield* Database.Service + const legacy = yield* session.create({ title: "before" }) + yield* database.db.delete(SessionLineageTable).where(eq(SessionLineageTable.session_id, legacy.id)).run() + yield* session.beginPartialLineage(legacy.id, 42) + const after = yield* session.create({ parentID: legacy.id, title: "tracked" }) + const lineage = yield* session.lineage(after.id) + // The epoch tracks spawns after the boundary as partial; pre-epoch history is never backfilled. + expect(lineage.mode).toBe("partial") + expect(lineage.rootID).toBe(legacy.id) + expect(lineage.descendants.some((d) => d.sessionID === after.id && d.mode === "partial")).toBe(true) + green.add("version:upgrade_epoch") + }), + ) +}) + +// ── The engine-subset gate (AC8) + composition with the #1083 release evaluator + +describe("adversarial matrix — the release gate (AC8)", () => { + test("every required engine case ran green — all_required_passed, enablement STILL off", () => { + const result = ProvenanceMatrix.gate(engine, green) + expect(result.missingRequired).toEqual([]) + expect(result.all_required_passed).toBe(true) + // AC8: the matrix reports readiness only — it NEVER flips default enablement (human-only). + expect(result.defaultEnablement).toBe("off") + }) + + test("dropping any one required engine case blocks the gate", () => { + const dropped = ProvenanceMatrix.requiredCaseIDs(engine)[0] + const minusOne = new Set(green) + minusOne.delete(dropped) + const result = ProvenanceMatrix.gate(engine, minusOne) + expect(result.all_required_passed).toBe(false) + expect(result.missingRequired).toContain(dropped) + }) + + test("the green engine matrix IDs satisfy the #1083 SessionRollout.Release evaluator (enablement off)", () => { + const mandatory = { rehearsalCaseIDs: ["upgrade"], matrixCaseIDs: ProvenanceMatrix.requiredCaseIDs(engine) } + const parsed = SessionRollout.Release.parse({ + forkTag: "opencode-v1.18.12-amicode.3", + binaryChecksums: { "darwin-arm64": "sha256:aaa" }, + lockPin: "opencode.lock:deadbeef", + extensionVersion: "0.0.3", + overlayProvenance: { verified: true }, + rehearsalCaseIDs: ["upgrade"], + matrixCaseIDs: [...green], + completion: { forkAt: 100, binaryPinAt: 200, extensionAt: 300 }, + gates: [{ id: "matrix", required: true, passed: true }], + }) + expect(parsed.ok).toBe(true) + if (!parsed.ok) return + const readiness = SessionRollout.Release.evaluate(parsed.manifest, mandatory) + expect(readiness.ready).toBe(true) + expect(readiness.defaultEnablement).toBe("off") + // A missing matrix case blocks the release evaluator too. + const short = SessionRollout.Release.parse({ ...parsed.manifest, matrixCaseIDs: [...green].slice(1) }) + if (short.ok) expect(SessionRollout.Release.evaluate(short.manifest, mandatory).ready).toBe(false) + }) +}) diff --git a/packages/opencode/test/session/business-record.test.ts b/packages/opencode/test/session/business-record.test.ts new file mode 100644 index 0000000000..b45dbe60ce --- /dev/null +++ b/packages/opencode/test/session/business-record.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, test } from "bun:test" +import { SessionBusinessRecord } from "@/session/business-record" +import { SessionEvidence } from "@/session/evidence" +import { SessionMutation } from "@/session/mutation" + +const lineage = (rootID: string, sessionID: string) => (candidate: string) => + candidate === sessionID ? rootID : undefined + +describe("session business-record classification", () => { + test("classifies every plugin, runner, and operational route the registry ships", () => { + // Business-visible plugin/runner writes. + for (const routeID of ["plugin-problem-record", "runner-run-metadata", "runner-artifact"] as const) { + expect(SessionBusinessRecord.classify(routeID)).toBe("business") + expect(SessionMutation.Registry.require(routeID)).toEqual({ id: routeID, kind: "ledger" }) + } + // Every business route names a resource classification. + expect(SessionBusinessRecord.Routes).toEqual({ + "plugin-problem-record": "problem_record", + "runner-run-metadata": "run_metadata", + "runner-artifact": "artifact", + }) + // Operational machinery — explicitly out of scope. + for (const routeID of SessionBusinessRecord.OperationalRoutes) { + expect(SessionBusinessRecord.classify(routeID)).toBe("operational") + expect(SessionMutation.Registry.require(routeID)).toEqual({ id: routeID, kind: "out_of_scope" }) + } + // Engine tool routes belong to a different adopter; opaque routes carry no adopted resources. + for (const routeID of ["local-file-write", "tool-write", "direct-file-write"] as const) + expect(SessionBusinessRecord.classify(routeID)).toBe("engine") + for (const routeID of ["shell-action", "cli-action", "mcp-action"] as const) + expect(SessionBusinessRecord.classify(routeID)).toBe("opaque") + expect(SessionBusinessRecord.classify("unregistered-writer")).toBeUndefined() + }) +}) + +describe("session business-record adoption", () => { + test("registered plugin writes produce a system-origin receipt tied to the initiating session lineage", () => { + const adoption = SessionBusinessRecord.adopt({ + rootForSession: lineage("root-1", "session-1"), + routeID: "plugin-problem-record", + sessionID: "session-1", + rootID: "root-1", + operation: "record-problem", + operationID: "op-problem-1", + resources: [{ id: "problem", resource: "problem:cz-transmon", outcome: "applied" }], + }) + + expect(adoption).toEqual({ + kind: "adopted", + receipt: { + origin: "system", + rootID: "root-1", + sessionID: "session-1", + routeID: "plugin-problem-record", + operationID: "op-problem-1", + operation: "record-problem", + classification: "problem_record", + outcome: "applied", + resources: [{ id: "problem", resource: "problem:cz-transmon", outcome: "applied", evidence: "none" }], + }, + }) + + // A write whose declared session is not the lineage's initiating session is refused, not mis-attributed. + expect( + SessionBusinessRecord.adopt({ + rootForSession: lineage("root-1", "session-1"), + routeID: "plugin-problem-record", + sessionID: "session-1", + rootID: "root-other", + operation: "record-problem", + operationID: "op-problem-2", + resources: [{ id: "problem", resource: "problem:cz-transmon", outcome: "applied" }], + }), + ).toEqual({ kind: "refused", reason: "lineage_mismatch" }) + }) + + test("runner metadata and declared artifacts carry safe evidence or artifact references", () => { + const metadata = SessionBusinessRecord.adopt({ + rootForSession: lineage("root-2", "session-2"), + routeID: "runner-run-metadata", + sessionID: "session-2", + rootID: "root-2", + operation: "record-run", + operationID: "op-run-1", + resources: [{ id: "run", resource: "run:r20260912", outcome: "applied", evidence: { content: "F=0.9982" } }], + }) + expect(metadata).toMatchObject({ + kind: "adopted", + receipt: { + classification: "run_metadata", + resources: [{ id: "run", resource: "run:r20260912", outcome: "applied", evidence: "inline" }], + }, + }) + + const artifact = SessionBusinessRecord.adopt({ + rootForSession: lineage("root-2", "session-2"), + routeID: "runner-artifact", + sessionID: "session-2", + rootID: "root-2", + operation: "record-artifact", + operationID: "op-artifact-1", + resources: [{ id: "pulse", resource: "artifact:pulse.jld2", outcome: "applied", artifact: { ref: "artifact-ref-1" } }], + }) + expect(artifact).toMatchObject({ + kind: "adopted", + receipt: { + classification: "artifact", + resources: [{ id: "pulse", resource: "artifact:pulse.jld2", outcome: "applied", evidence: "artifact_link" }], + }, + }) + // The large generated output is linked, never copied into the receipt. + expect(JSON.stringify(artifact)).not.toContain("artifact-ref-1") + }) + + test("operational state is out of scope and cannot recurse into a business receipt", () => { + for (const routeID of SessionBusinessRecord.OperationalRoutes) + expect( + SessionBusinessRecord.adopt({ + rootForSession: lineage("root-3", "session-3"), + routeID, + sessionID: "session-3", + rootID: "root-3", + operation: "operational", + operationID: `op-${routeID}`, + resources: [{ id: "state", resource: "state:internal", outcome: "applied" }], + }), + ).toEqual({ kind: "refused", reason: "operational_resource" }) + + // An unregistered writer never adopts; an engine/opaque route routes elsewhere. + expect( + SessionBusinessRecord.adopt({ + rootForSession: lineage("root-3", "session-3"), + routeID: "unregistered-writer", + sessionID: "session-3", + rootID: "root-3", + operation: "write", + operationID: "op-unknown", + resources: [{ id: "x", resource: "x", outcome: "applied" }], + }), + ).toEqual({ kind: "refused", reason: "unregistered_route" }) + expect( + SessionBusinessRecord.adopt({ + rootForSession: lineage("root-3", "session-3"), + routeID: "tool-write", + sessionID: "session-3", + rootID: "root-3", + operation: "write", + operationID: "op-engine", + resources: [{ id: "x", resource: "x", outcome: "applied" }], + }), + ).toEqual({ kind: "refused", reason: "not_business_route" }) + }) + + test("contextless CLI and shell launches emit an unknown-operation receipt without adopting effects", () => { + const receipt = SessionBusinessRecord.unknown({ + operationID: "op-cli-1", + origin: "system", + operation: "cli-launch", + }) + expect(receipt).toEqual({ + kind: "unknown_mutation", + operationID: "op-cli-1", + origin: "system", + operation: "cli-launch", + }) + expect(receipt).not.toHaveProperty("resource") + expect(receipt).not.toHaveProperty("resources") + + // The opaque CLI/shell routes never adopt filesystem effects through the business path. + for (const routeID of ["cli-action", "shell-action"] as const) + expect( + SessionBusinessRecord.adopt({ + rootForSession: lineage("root-4", "session-4"), + routeID, + sessionID: "session-4", + rootID: "root-4", + operation: "cli", + operationID: `op-${routeID}`, + resources: [{ id: "x", resource: "x", outcome: "applied" }], + }), + ).toEqual({ kind: "refused", reason: "requires_opaque_receipt" }) + }) + + test("protected internal values are redacted by the exposure matrix before any display projection", () => { + const projection = SessionBusinessRecord.project( + { + receipt: { + origin: "system", + rootID: "root-5", + sessionID: "session-5", + routeID: "runner-artifact", + operationID: "op-5", + operation: "record-artifact", + classification: "artifact", + outcome: "applied", + resources: [{ id: "pulse", resource: "artifact:pulse.jld2", outcome: "applied", evidence: "artifact_link" }], + }, + resource: { id: "pulse", resource: "artifact:pulse.jld2", outcome: "applied", evidence: "artifact_link" }, + sequence: 1, + timeCreated: 1, + artifactRef: "receipt-5", + internal: { canonicalPath: "/private/runs/pulse.jld2", rawHash: "raw-hash-private", baseline: "baseline-private" }, + }, + "files_changed", + ) + + // Display-safe facts survive; the artifact link survives as a display-safe reference. + expect(projection).toMatchObject({ + operation: { origin: "system" }, + receipt: { resource: "artifact:pulse.jld2", operation: "record-artifact", outcome: "applied" }, + evidence: { receiptID: "receipt-5" }, + }) + // Protected internal values never reach the projection. + const serialized = JSON.stringify(projection) + expect(serialized).not.toContain("/private/runs/pulse.jld2") + expect(serialized).not.toContain("raw-hash-private") + expect(serialized).not.toContain("baseline-private") + expect(projection).not.toHaveProperty("context") + + // The small egress summary is redacted, not exposed. + expect( + SessionBusinessRecord.project( + { + receipt: { + origin: "system", + rootID: "root-5", + sessionID: "session-5", + routeID: "runner-artifact", + operationID: "op-5", + operation: "record-artifact", + classification: "artifact", + outcome: "applied", + resources: [ + { id: "pulse", resource: "artifact:pulse.jld2", outcome: "applied", evidence: "artifact_link" }, + ], + }, + resource: { id: "pulse", resource: "artifact:pulse.jld2", outcome: "applied", evidence: "artifact_link" }, + sequence: 1, + timeCreated: 1, + artifactRef: "receipt-5", + internal: { canonicalPath: "/private/runs/pulse.jld2" }, + }, + "share", + ), + ).toEqual({ + operation: { origin: "[redacted:operation.origin]" }, + receipt: { resource: "[redacted:receipt.resource]" }, + }) + }) + + test("deleting a root removes plugin and runner evidence together and leaves other roots intact", () => { + const rootID = `bizrec-test-${crypto.randomUUID()}` + const otherRoot = `bizrec-test-${crypto.randomUUID()}` + try { + SessionEvidence.write(rootID, "op-plugin", [{ receiptID: "plugin-receipt", content: "problem-card" }], 1024) + SessionEvidence.write(rootID, "op-runner", [{ receiptID: "runner-receipt", content: "run.toml" }], 1024) + SessionEvidence.write(otherRoot, "op-other", [{ receiptID: "other-receipt", content: "keep" }], 1024) + expect(SessionEvidence.has(rootID, "op-plugin", "plugin-receipt")).toBe(true) + expect(SessionEvidence.has(rootID, "op-runner", "runner-receipt")).toBe(true) + + SessionBusinessRecord.removeRootEvidence(rootID) + + expect(SessionEvidence.exists(rootID, "op-plugin")).toBe(false) + expect(SessionEvidence.exists(rootID, "op-runner")).toBe(false) + expect(SessionEvidence.has(otherRoot, "op-other", "other-receipt")).toBe(true) + } finally { + SessionEvidence.removeRoot(rootID) + SessionEvidence.removeRoot(otherRoot) + } + }) +}) diff --git a/packages/opencode/test/session/mutation.test.ts b/packages/opencode/test/session/mutation.test.ts new file mode 100644 index 0000000000..db5fe01110 --- /dev/null +++ b/packages/opencode/test/session/mutation.test.ts @@ -0,0 +1,921 @@ +import { describe, expect, test } from "bun:test" +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs" +import os from "node:os" +import path from "node:path" +import { SessionMutation } from "@/session/mutation" + +const localProvider = (run: () => SessionMutation.Result): SessionMutation.LocalProvider => ({ + capabilities: { safeResolve: true, noFollowWrite: true }, + safeResolve: (endpoint) => endpoint, + noFollowWrite: () => run(), +}) + +const mutationGate = (input: { + rootForSession: (sessionID: string) => string | undefined + now: () => number + operations?: SessionMutation.OperationStore +}) => + SessionMutation.create({ + ...input, + operations: input.operations ?? SessionMutation.OperationStore.memory(), + }) + +describe("session mutation registry", () => { + test("classifies every registered route before storage is available", () => { + expect(SessionMutation.Registry.manifest()).toEqual({ + version: 4, + routes: [ + { id: "local-file-write", kind: "ledger" }, + { id: "tool-write", kind: "ledger" }, + { id: "tool-edit", kind: "ledger" }, + { id: "tool-apply-patch", kind: "ledger" }, + { id: "direct-file-write", kind: "ledger" }, + { id: "plugin-problem-record", kind: "ledger" }, + { id: "runner-run-metadata", kind: "ledger" }, + { id: "runner-artifact", kind: "ledger" }, + { id: "user-sidebar-op", kind: "ledger" }, + { id: "user-preview-edit", kind: "ledger" }, + { id: "user-review-edit", kind: "ledger" }, + { id: "shell-action", kind: "opaque" }, + { id: "mcp-action", kind: "opaque" }, + { id: "custom-tool-action", kind: "opaque" }, + { id: "cli-action", kind: "opaque" }, + { id: "ledger-infrastructure", kind: "out_of_scope" }, + { id: "credential-store", kind: "out_of_scope" }, + { id: "cache-store", kind: "out_of_scope" }, + { id: "queue-store", kind: "out_of_scope" }, + { id: "updater-state", kind: "out_of_scope" }, + { id: "telemetry-store", kind: "out_of_scope" }, + { id: "retention-state", kind: "out_of_scope" }, + ], + }) + expect(SessionMutation.Registry.require("unregistered-storage-route")).toBeUndefined() + const gate = mutationGate({ rootForSession: () => "root", now: () => 10 }) + expect( + gate.issue({ + kind: "local", + routeID: "unregistered-storage-route", + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "write", + source: { value: "provider:1", kind: "file" }, + expiresAt: 20, + }), + ).toBeUndefined() + }) + + test("denies an uncontextualized protected write before invoking storage", () => { + const gate = mutationGate({ rootForSession: () => "root", now: () => 10 }) + let writes = 0 + + expect( + gate.executeLocal({ + request: { + routeID: "local-file-write", + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "write", + operationID: "operation", + source: { value: "provider:1", kind: "file" }, + }, + provider: localProvider(() => { + writes++ + return { groupID: "operation", outcome: "applied" } + }), + }), + ).toEqual({ kind: "denied", reason: "missing_context" }) + expect(writes).toBe(0) + }) + + test("binds a local context to one authenticated panel, session root, operation, and endpoint", () => { + const gate = mutationGate({ + rootForSession: (sessionID) => (sessionID === "session" ? "root" : undefined), + now: () => 10, + }) + const source = { value: "provider:1", kind: "file" } as const + const destination = { value: "provider:2", kind: "file" } as const + const context = gate.issue({ + kind: "local", + routeID: "local-file-write", + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "write", + source, + destination, + expiresAt: 20, + }) + + expect(context).toBeDefined() + expect(JSON.stringify(context)).toBe("{}") + expect( + gate.executeLocal({ + context, + request: { + routeID: "local-file-write", + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "write", + operationID: "operation", + source, + destination, + }, + provider: localProvider(() => ({ groupID: "operation", outcome: "applied" })), + }), + ).toEqual({ kind: "executed", result: { groupID: "operation", outcome: "applied" } }) + }) + + test("commits one declared patch group for move, delete, implicit parents, and formatter effects", () => { + const gate = mutationGate({ rootForSession: () => "root", now: () => 10 }) + const resources = [ + { + id: "move-source", + endpoint: { value: "provider:source", kind: "file" }, + operation: "move", + role: "source", + }, + { + id: "move-destination", + endpoint: { value: "provider:destination", kind: "file" }, + operation: "move", + role: "destination", + }, + { + id: "delete", + endpoint: { value: "provider:delete", kind: "file" }, + operation: "delete", + role: "target", + }, + { + id: "implicit-parent", + endpoint: { value: "provider:parent", kind: "directory" }, + operation: "create_parent", + role: "implicit_parent", + }, + { + id: "format", + endpoint: { value: "provider:formatted", kind: "file" }, + operation: "format", + role: "formatter", + }, + ] as const + const request = { + routeID: "tool-apply-patch" as const, + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "patch", + operationID: "patch-operation", + resources, + } + const context = gate.issueGroup({ ...request, expiresAt: 20 }) + const events: string[] = [] + + expect( + gate.executeGroup({ + context, + request, + provider: { + capabilities: { safeResolve: true, noFollowWrite: true }, + safeResolve: (endpoint) => { + events.push(`resolve:${endpoint.value}`) + return endpoint + }, + execute: (resource) => { + events.push(`write:${resource.id}`) + return "applied" + }, + }, + }), + ).toEqual({ + kind: "executed", + result: { + groupID: "patch-operation", + outcome: "applied", + receipts: resources.map((resource) => ({ ...resource, outcome: "applied" })), + }, + }) + expect(events).toEqual([ + "resolve:provider:source", + "resolve:provider:destination", + "resolve:provider:delete", + "resolve:provider:parent", + "resolve:provider:formatted", + "write:move-source", + "write:move-destination", + "write:delete", + "write:implicit-parent", + "write:format", + ]) + }) + + test("requires a registered group context before every engine write route can invoke storage", () => { + const gate = mutationGate({ rootForSession: () => "root", now: () => 10 }) + for (const routeID of ["tool-write", "tool-edit", "tool-apply-patch", "direct-file-write"] as const) { + const request = { + routeID, + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "write", + operationID: `${routeID}-operation`, + resources: [ + { + id: "target", + endpoint: { value: `${routeID}:target`, kind: "file" }, + operation: "write", + role: "target", + }, + ], + } as const + let writes = 0 + const provider = { + capabilities: { safeResolve: true, noFollowWrite: true }, + safeResolve: (endpoint: SessionMutation.Endpoint) => endpoint, + execute: () => { + writes++ + return "applied" as const + }, + } + + expect(gate.executeGroup({ request, provider })).toEqual({ kind: "denied", reason: "missing_context" }) + expect(writes).toBe(0) + + const context = gate.issueGroup({ ...request, expiresAt: 20 }) + expect(gate.executeGroup({ context, request, provider })).toMatchObject({ kind: "executed" }) + expect(writes).toBe(1) + expect(gate.executeGroup({ context, request, provider })).toMatchObject({ kind: "replayed" }) + expect(writes).toBe(1) + } + }) + + test("denies a recursive group whose preflight snapshot exceeds its resource budget before mutation", () => { + const gate = mutationGate({ rootForSession: () => "root", now: () => 10 }) + const resources = [ + { + id: "first", + endpoint: { value: "provider:first", kind: "file" }, + operation: "delete", + role: "target", + }, + { + id: "second", + endpoint: { value: "provider:second", kind: "file" }, + operation: "delete", + role: "target", + }, + ] as const + const request = { + routeID: "tool-apply-patch" as const, + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "delete-recursive", + operationID: "recursive-operation", + resources, + recursive: { maxResources: 1 }, + } + const context = gate.issueGroup({ ...request, expiresAt: 20 }) + let accesses = 0 + + expect( + gate.executeGroup({ + context, + request, + provider: { + capabilities: { safeResolve: true, noFollowWrite: true }, + safeResolve: (endpoint) => { + accesses++ + return endpoint + }, + execute: () => { + accesses++ + return "applied" + }, + }, + }), + ).toEqual({ + kind: "denied", + reason: "resource_budget_exceeded", + result: { groupID: "recursive-operation", outcome: "denied", receipts: [] }, + }) + expect(accesses).toBe(0) + }) + + test("rejects a recursive execution whose enumerated resources differ from its preflight snapshot", () => { + const gate = mutationGate({ rootForSession: () => "root", now: () => 10 }) + const planned = [ + { + id: "first", + endpoint: { value: "provider:first", kind: "file" }, + operation: "delete", + role: "target", + }, + ] as const + const request = { + routeID: "tool-apply-patch" as const, + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "delete-recursive", + operationID: "changed-enumeration", + resources: planned, + recursive: { maxResources: 2 }, + } + const context = gate.issueGroup({ ...request, expiresAt: 20 }) + let writes = 0 + + expect( + gate.executeGroup({ + context, + request: { + ...request, + resources: [ + ...planned, + { + id: "second", + endpoint: { value: "provider:second", kind: "file" }, + operation: "delete", + role: "target", + }, + ], + }, + provider: { + capabilities: { safeResolve: true, noFollowWrite: true }, + safeResolve: (endpoint) => endpoint, + execute: () => { + writes++ + return "applied" + }, + }, + }), + ).toEqual({ kind: "denied", reason: "invalid_context" }) + expect(writes).toBe(0) + }) + + test("retains declared resource outcomes when non-atomic execution fails", () => { + const gate = mutationGate({ rootForSession: () => "root", now: () => 10 }) + const resources = [ + { + id: "written", + endpoint: { value: "provider:written", kind: "file" }, + operation: "patch", + role: "target", + }, + { + id: "failed", + endpoint: { value: "provider:failed", kind: "file" }, + operation: "patch", + role: "target", + }, + { + id: "not-started", + endpoint: { value: "provider:later", kind: "file" }, + operation: "patch", + role: "target", + }, + ] as const + const request = { + routeID: "tool-apply-patch" as const, + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "patch", + operationID: "partial-operation", + resources, + } + const context = gate.issueGroup({ ...request, expiresAt: 20 }) + + expect( + gate.executeGroup({ + context, + request, + provider: { + capabilities: { safeResolve: true, noFollowWrite: true }, + safeResolve: (endpoint) => endpoint, + execute: (resource) => (resource.id === "failed" ? "failed" : "applied"), + }, + }), + ).toEqual({ + kind: "executed", + result: { + groupID: "partial-operation", + outcome: "partial", + receipts: [ + { ...resources[0], outcome: "applied" }, + { ...resources[1], outcome: "failed" }, + { ...resources[2], outcome: "not_started" }, + ], + }, + }) + }) + + test("records only not-started declared resources when pre-write validation fails", () => { + const gate = mutationGate({ rootForSession: () => "root", now: () => 10 }) + const resources = [ + { + id: "first", + endpoint: { value: "provider:first", kind: "file" }, + operation: "patch", + role: "target", + }, + { + id: "second", + endpoint: { value: "provider:second", kind: "file" }, + operation: "patch", + role: "target", + }, + ] as const + const request = { + routeID: "tool-apply-patch" as const, + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "patch", + operationID: "pre-write-failure", + resources, + } + const context = gate.issueGroup({ ...request, expiresAt: 20 }) + let writes = 0 + + expect( + gate.executeGroup({ + context, + request, + provider: { + capabilities: { safeResolve: true, noFollowWrite: true }, + safeResolve: (endpoint) => (endpoint.value === "provider:second" ? undefined : endpoint), + execute: () => { + writes++ + return "applied" + }, + }, + }), + ).toEqual({ + kind: "executed", + result: { + groupID: "pre-write-failure", + outcome: "failed", + receipts: resources.map((resource) => ({ ...resource, outcome: "not_started" })), + }, + }) + expect(writes).toBe(0) + }) + + test("denies expired, revoked, wrong-owner, and wrong-endpoint contexts before storage", () => { + let now = 10 + const source = { value: "provider:1", kind: "file" } as const + const request = { + routeID: "local-file-write" as const, + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "write", + operationID: "operation", + source, + } + const setup = () => { + const gate = mutationGate({ rootForSession: () => "root", now: () => now }) + const context = gate.issue({ kind: "local", ...request, expiresAt: 20 })! + return { gate, context } + } + const cases = [ + { + name: "expired", + run: () => { + const { gate, context } = setup() + now = 20 + return gate.executeLocal({ + context, + request, + provider: localProvider(() => ({ groupID: "operation", outcome: "applied" })), + }) + }, + }, + { + name: "revoked", + run: () => { + now = 10 + const { gate, context } = setup() + gate.revoke(context) + return gate.executeLocal({ + context, + request, + provider: localProvider(() => ({ groupID: "operation", outcome: "applied" })), + }) + }, + }, + { + name: "wrong panel", + run: () => { + now = 10 + const { gate, context } = setup() + return gate.executeLocal({ + context, + request: { ...request, panelID: "other-panel" }, + provider: localProvider(() => ({ groupID: "operation", outcome: "applied" })), + }) + }, + }, + { + name: "wrong session", + run: () => { + now = 10 + const { gate, context } = setup() + return gate.executeLocal({ + context, + request: { ...request, sessionID: "other-session" }, + provider: localProvider(() => ({ groupID: "operation", outcome: "applied" })), + }) + }, + }, + { + name: "wrong endpoint", + run: () => { + now = 10 + const { gate, context } = setup() + return gate.executeLocal({ + context, + request: { ...request, source: { value: "provider:2", kind: "file" } }, + provider: localProvider(() => ({ groupID: "operation", outcome: "applied" })), + }) + }, + }, + ] + + for (const item of cases) + expect(item.run()).toEqual({ + kind: "denied", + reason: item.name === "expired" ? "expired_context" : "invalid_context", + }) + }) + + test("replays an exact root-bound operation result without a second mutation and rejects changed replays", () => { + const gate = mutationGate({ rootForSession: () => "root", now: () => 10 }) + const source = { value: "provider:1", kind: "file" } as const + const request = { + routeID: "local-file-write" as const, + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "write", + operationID: "operation", + source, + } + const context = gate.issue({ kind: "local", ...request, expiresAt: 20 })! + let writes = 0 + + expect( + gate.executeLocal({ + context, + request, + provider: localProvider(() => ({ groupID: "operation", outcome: `${++writes}` })), + }), + ).toEqual({ kind: "executed", result: { groupID: "operation", outcome: "1" } }) + expect( + gate.executeLocal({ + context, + request, + provider: localProvider(() => ({ groupID: "operation", outcome: `${++writes}` })), + }), + ).toEqual({ kind: "replayed", result: { groupID: "operation", outcome: "1" } }) + expect(writes).toBe(1) + + const changedContext = gate.issue({ kind: "local", ...request, operation: "delete", expiresAt: 20 })! + expect( + gate.executeLocal({ + context: changedContext, + request: { ...request, operation: "delete" }, + provider: localProvider(() => ({ groupID: "operation", outcome: `${++writes}` })), + }), + ).toEqual({ kind: "denied", reason: "invalid_replay" }) + expect(writes).toBe(1) + }) + + test("uses root-owned operation storage so retries survive a gate restart", () => { + const operations = SessionMutation.OperationStore.memory() + const source = { value: "provider:1", kind: "file" } as const + const request = { + routeID: "local-file-write" as const, + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "write", + operationID: "operation", + source, + } + const first = mutationGate({ rootForSession: () => "root", now: () => 10, operations }) + const firstContext = first.issue({ kind: "local", ...request, expiresAt: 20 })! + let writes = 0 + expect( + first.executeLocal({ + context: firstContext, + request, + provider: localProvider(() => ({ groupID: "operation", outcome: `${++writes}` })), + }), + ).toEqual({ kind: "executed", result: { groupID: "operation", outcome: "1" } }) + + const restarted = mutationGate({ rootForSession: () => "root", now: () => 10, operations }) + const restartedContext = restarted.issue({ kind: "local", ...request, expiresAt: 20 })! + expect( + restarted.executeLocal({ + context: restartedContext, + request, + provider: localProvider(() => ({ groupID: "operation", outcome: `${++writes}` })), + }), + ).toEqual({ kind: "replayed", result: { groupID: "operation", outcome: "1" } }) + expect(writes).toBe(1) + }) + + test("confines opaque contexts to an operation-level unknown receipt", () => { + const gate = mutationGate({ rootForSession: () => "root", now: () => 10 }) + expect( + gate.issue({ + kind: "opaque", + routeID: "shell-action", + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "shell", + source: { value: "provider:1", kind: "file" }, + expiresAt: 20, + }), + ).toBeUndefined() + const opaque = gate.issue({ + kind: "opaque", + routeID: "shell-action", + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "shell", + expiresAt: 20, + })! + let writes = 0 + + expect( + gate.executeLocal({ + context: opaque, + request: { + routeID: "local-file-write", + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "write", + operationID: "local-operation", + source: { value: "provider:1", kind: "file" }, + }, + provider: localProvider(() => { + writes++ + return { groupID: "local-operation", outcome: "applied" } + }), + }), + ).toEqual({ kind: "denied", reason: "invalid_context" }) + expect(writes).toBe(0) + + const result = gate.executeOpaque({ + context: opaque, + request: { + routeID: "shell-action", + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "shell", + operationID: "opaque-operation", + }, + }) + expect(result).toEqual({ + kind: "executed", + result: { + groupID: "opaque-operation", + outcome: "unknown", + receipt: { + kind: "unknown_mutation", + operationID: "opaque-operation", + origin: "agent", + operation: "shell", + }, + }, + }) + expect(result.kind === "executed" && "receipt" in result.result && result.result.receipt).not.toHaveProperty( + "resource", + ) + expect(result.kind === "executed" && "receipt" in result.result && result.result.receipt).not.toHaveProperty( + "patch", + ) + }) + + test("uses ordinary receipts only for complete declared opaque resources", () => { + const gate = mutationGate({ rootForSession: () => "root", now: () => 10 }) + const opaque = gate.issue({ + kind: "opaque", + routeID: "mcp-action", + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "mcp", + expiresAt: 20, + })! + const resources = [ + { + id: "declared", + endpoint: { value: "remote:declared", kind: "file" }, + operation: "write", + role: "target", + }, + ] as const + const request = { + routeID: "mcp-action" as const, + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "mcp", + operationID: "declared-opaque", + resources, + } + + expect(gate.executeOpaque({ context: opaque, request })).toEqual({ + kind: "executed", + result: { + groupID: "declared-opaque", + outcome: "applied", + receipts: resources.map((resource) => ({ ...resource, outcome: "applied" })), + }, + }) + expect( + gate.executeOpaque({ + context: opaque, + request: { ...request, operationID: "unknown-opaque", resources: [] }, + }), + ).toEqual({ + kind: "executed", + result: { + groupID: "unknown-opaque", + outcome: "unknown", + receipt: { + kind: "unknown_mutation", + operationID: "unknown-opaque", + origin: "agent", + operation: "mcp", + }, + }, + }) + }) + + test("denies known local mutation when a provider cannot guarantee safe resolution and no-follow writes", () => { + const gate = mutationGate({ rootForSession: () => "root", now: () => 10 }) + const source = { value: "provider:1", kind: "file" } as const + const request = { + routeID: "local-file-write" as const, + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "write", + operationID: "operation", + source, + } + const context = gate.issue({ kind: "local", ...request, expiresAt: 20 })! + let accessed = false + + expect( + gate.executeLocal({ + context, + request, + provider: { + capabilities: { safeResolve: false, noFollowWrite: false }, + safeResolve: () => { + accessed = true + return source + }, + noFollowWrite: () => { + accessed = true + return { groupID: "operation", outcome: "applied" } + }, + }, + }), + ).toEqual({ kind: "denied", reason: "unsafe_provider" }) + expect(accessed).toBe(false) + }) + + test("re-resolves bound local identities and delegates the write through the provider's no-follow gate", () => { + const gate = mutationGate({ rootForSession: () => "root", now: () => 10 }) + const source = { value: "provider:1", kind: "file" } as const + const request = { + routeID: "local-file-write" as const, + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "write", + operationID: "operation", + source, + } + const context = gate.issue({ kind: "local", ...request, expiresAt: 20 })! + let resolves = 0 + let writes = 0 + let verified: { source: SessionMutation.Endpoint; destination?: SessionMutation.Endpoint } | undefined + + expect( + gate.executeLocal({ + context, + request, + provider: { + capabilities: { safeResolve: true, noFollowWrite: true }, + safeResolve: (endpoint) => { + resolves++ + return endpoint + }, + noFollowWrite: (input) => { + verified = input + return { groupID: "operation", outcome: `${++writes}` } + }, + }, + }), + ).toEqual({ kind: "executed", result: { groupID: "operation", outcome: "1" } }) + expect(resolves).toBe(1) + expect(writes).toBe(1) + expect(verified).toEqual({ source }) + }) + + test("fails closed when a symlink-race re-resolution changes the physical identity", () => { + const gate = mutationGate({ rootForSession: () => "root", now: () => 10 }) + const source = { value: "local:existing:1:1", kind: "file" } as const + const request = { + routeID: "local-file-write" as const, + panelID: "panel", + sessionID: "session", + rootID: "root", + origin: "agent", + operation: "write", + operationID: "operation", + source, + } + const context = gate.issue({ kind: "local", ...request, expiresAt: 20 })! + let writes = 0 + + expect( + gate.executeLocal({ + context, + request, + provider: { + capabilities: { safeResolve: true, noFollowWrite: true }, + safeResolve: () => ({ value: "local:existing:1:2", kind: "file" }), + noFollowWrite: () => { + writes++ + return { groupID: "operation", outcome: "applied" } + }, + }, + }), + ).toEqual({ kind: "denied", reason: "identity_changed" }) + expect(writes).toBe(0) + }) + + test("canonicalizes existing aliases and nonexistent leaves without retaining path strings", () => { + const directory = mkdtempSync(path.join(os.tmpdir(), "opencode-mutation-identity-")) + try { + const target = path.join(directory, "target.txt") + const alias = path.join(directory, "alias.txt") + writeFileSync(target, "target") + symlinkSync(target, alias) + + const existing = SessionMutation.ResourceIdentity.resolve(target, "file") + const linked = SessionMutation.ResourceIdentity.resolve(alias, "file") + const missing = SessionMutation.ResourceIdentity.resolve(path.join(directory, "new.txt"), "file") + + expect(linked).toEqual(existing) + expect(missing).toEqual(expect.objectContaining({ kind: "file" })) + expect(missing?.value).not.toContain(directory) + expect(missing?.value).toContain("missing") + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) + + test("capability discovery selects legacy display only for unsupported protocol versions", () => { + expect(SessionMutation.Capability.discover({ supported: [1], requested: 1 })).toEqual({ mode: "full", version: 1 }) + expect(SessionMutation.Capability.discover({ supported: [1], requested: 2 })).toEqual({ mode: "legacy" }) + expect(SessionMutation.Capability.discover({ supported: [], requested: 1 })).toEqual({ mode: "legacy" }) + }) +}) diff --git a/packages/opencode/test/session/receipt-privacy.test.ts b/packages/opencode/test/session/receipt-privacy.test.ts new file mode 100644 index 0000000000..55916f43aa --- /dev/null +++ b/packages/opencode/test/session/receipt-privacy.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test } from "bun:test" +import { SessionReceiptPrivacy } from "@/session/receipt-privacy" + +const artifactSentinel = "GENERATED_ARTIFACT_SENTINEL_1078" + +function hostReceipt() { + return { + operation: { + id: "operation-private", + rootID: "root-private", + sessionID: "session-private", + origin: "agent", + state: "committed", + }, + receipt: { + id: "receipt-1", + sequence: 1, + resource: "logical-target.ts", + operation: "write", + outcome: "applied", + timeCreated: 1, + }, + assessment: { + id: "assessment-private", + receiptID: "receipt-1", + confidence: "verified", + netState: "changed", + evidenceState: "available", + revision: 2, + expiresAt: 10, + timeCreated: 2, + }, + evidence: { receiptID: "receipt-1", content: artifactSentinel }, + context: { + capability: "capability-private", + canonicalPath: "/private/canonical/path", + rawHash: "raw-hash-private", + baseline: artifactSentinel, + redactionDecision: "internal", + }, + derived: { patch: artifactSentinel, additions: 1, deletions: 1 }, + } +} + +describe("SessionReceiptPrivacy display-safe projection", () => { + test("emits only Files Changed fields and never host-only evidence", () => { + const projected = SessionReceiptPrivacy.project(hostReceipt(), "files_changed") + + expect(projected).toEqual({ + operation: { origin: "agent", state: "committed" }, + receipt: { + id: "receipt-1", + sequence: 1, + resource: "logical-target.ts", + operation: "write", + outcome: "applied", + timeCreated: 1, + }, + assessment: { + receiptID: "receipt-1", + confidence: "verified", + netState: "changed", + evidenceState: "available", + revision: 2, + expiresAt: 10, + timeCreated: 2, + }, + evidence: { receiptID: "receipt-1" }, + derived: { additions: 1, deletions: 1 }, + }) + expect(JSON.stringify(projected)).not.toContain(artifactSentinel) + expect(JSON.stringify(projected)).not.toContain("canonicalPath") + expect(SessionReceiptPrivacy.project(hostReceipt(), "browser")).toEqual(projected) + }) + + test("redacts the small egress summary and omits receipts from transcripts and ordinary metadata", () => { + const receipt = hostReceipt() + + for (const boundary of ["share", "export", "telemetry", "log", "error"] as const) { + const projected = SessionReceiptPrivacy.project(receipt, boundary) + expect(projected).toEqual({ + operation: { origin: "[redacted:operation.origin]" }, + receipt: { resource: "[redacted:receipt.resource]" }, + }) + expect(JSON.stringify(projected)).not.toContain(artifactSentinel) + } + + expect(SessionReceiptPrivacy.project(receipt, "transcript")).toEqual({}) + expect(SessionReceiptPrivacy.project(receipt, "session_metadata")).toEqual({}) + }) + + test("gates external evidence detail behind authentication, authorization, current policy, and no-store", () => { + const receipt = hostReceipt() + const allowed = SessionReceiptPrivacy.externalDetail(receipt, { + authenticated: true, + receiptReferenceAuthorized: true, + evidencePolicy: "allow", + now: 1, + expiresAt: 10, + }) + + expect(allowed.status).toBe(200) + expect(allowed.headers).toEqual({ "cache-control": "no-store" }) + expect(allowed.body).toMatchObject({ + evidence: { receiptID: "receipt-1", content: artifactSentinel }, + derived: { patch: artifactSentinel, additions: 1, deletions: 1 }, + }) + expect(JSON.stringify(allowed.body)).not.toContain("capability-private") + + for (const access of [ + { + authenticated: false, + receiptReferenceAuthorized: true, + evidencePolicy: "allow" as const, + now: 1, + expiresAt: 10, + }, + { + authenticated: true, + receiptReferenceAuthorized: false, + evidencePolicy: "allow" as const, + now: 1, + expiresAt: 10, + }, + { + authenticated: true, + receiptReferenceAuthorized: true, + evidencePolicy: "deny" as const, + now: 1, + expiresAt: 10, + }, + { + authenticated: true, + receiptReferenceAuthorized: true, + evidencePolicy: "allow" as const, + now: 10, + expiresAt: 10, + }, + ]) { + const denied = SessionReceiptPrivacy.externalDetail(receipt, access) + expect(denied.headers).toEqual({ "cache-control": "no-store" }) + expect(denied.status).not.toBe(200) + expect(JSON.stringify(denied.body)).not.toContain(artifactSentinel) + expect(JSON.stringify(denied.body)).not.toContain("canonicalPath") + } + + const redirected = SessionReceiptPrivacy.externalDetail(receipt, { + authenticated: true, + receiptReferenceAuthorized: true, + evidencePolicy: "allow", + now: 1, + redirected: true, + }) + expect(redirected).toMatchObject({ status: 307, headers: { "cache-control": "no-store" } }) + expect(JSON.stringify(redirected.body)).not.toContain(artifactSentinel) + expect(JSON.stringify(redirected.body)).not.toContain("canonicalPath") + + const unavailable = hostReceipt() + unavailable.assessment!.evidenceState = "unavailable" + const unavailableDetail = SessionReceiptPrivacy.externalDetail(unavailable, { + authenticated: true, + receiptReferenceAuthorized: true, + evidencePolicy: "allow", + now: 1, + expiresAt: 10, + }) + expect(unavailableDetail).toMatchObject({ status: 403, headers: { "cache-control": "no-store" } }) + expect(JSON.stringify(unavailableDetail.body)).not.toContain(artifactSentinel) + }) + + test("retains the exact legacy payload until capability discovery selects the full projection", () => { + const legacy = { + version: 1, + revision: 3, + assessments: [{ reference: "external_legacy", file: "/legacy/path", patch: artifactSentinel }], + } + + expect(SessionReceiptPrivacy.display({ legacy, receipt: hostReceipt() }, { mode: "legacy" })).toBe(legacy) + expect(SessionReceiptPrivacy.display({ legacy, receipt: hostReceipt() }, { mode: "full", version: 1 })).toEqual( + SessionReceiptPrivacy.project(hostReceipt(), "files_changed"), + ) + }) +}) diff --git a/packages/opencode/test/session/rollout.test.ts b/packages/opencode/test/session/rollout.test.ts new file mode 100644 index 0000000000..4ad7ad4fc1 --- /dev/null +++ b/packages/opencode/test/session/rollout.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, test } from "bun:test" +import { SessionMutation } from "@/session/mutation" +import { SessionRollout } from "@/session/rollout" + +/** + * Contract tests for the gate-migration + coordinated-release deterministic + * surface (amicode#1083). Every test here is pure data/logic — no filesystem, + * no probing of a mutation route, and NOTHING that performs a release act. + */ + +const fullRoot = { mode: "full" as const } +const partialRoot = { mode: "partial" as const, epochStartedAt: 1_700_000_000 } +const legacyRoot = { mode: "legacy" as const } + +describe("SessionRollout.Capability.discover — versioned, deterministic (AC1)", () => { + test("carries protocol_version, mode, and migration_boundary", () => { + const cap = SessionRollout.Capability.discover({ supported: [SessionRollout.PROTOCOL_VERSION], requested: SessionRollout.PROTOCOL_VERSION, root: fullRoot }) + expect(cap.protocol_version).toBe(SessionRollout.PROTOCOL_VERSION) + expect(cap.mode).toBe("full") + expect(cap.migration_boundary).toEqual({ kind: "none" }) + }) + + test("protocol_version tracks the #1077 mutation registry generation (reuse, not a parallel constant)", () => { + expect(SessionRollout.PROTOCOL_VERSION).toBe(SessionMutation.Registry.version) + }) + + test("a full-native root on a supported engine selects full with no migration boundary", () => { + const cap = SessionRollout.Capability.discover({ supported: [4], requested: 4, root: fullRoot }) + expect(cap).toEqual({ protocol_version: 4, mode: "full", migration_boundary: { kind: "none" } }) + }) + + test("an opted-in post-upgrade epoch selects partial with an epoch boundary", () => { + const cap = SessionRollout.Capability.discover({ supported: [4], requested: 4, root: partialRoot }) + expect(cap).toEqual({ protocol_version: 4, mode: "partial", migration_boundary: { kind: "epoch", startedAt: 1_700_000_000 } }) + }) + + test("a pre-existing root without an epoch selects legacy (never an inferred partial)", () => { + const cap = SessionRollout.Capability.discover({ supported: [4], requested: 4, root: legacyRoot }) + expect(cap).toEqual({ protocol_version: 4, mode: "legacy", migration_boundary: { kind: "unmigrated" } }) + }) + + test("a partial root that carries NO epoch marker collapses to legacy — no background inference", () => { + const cap = SessionRollout.Capability.discover({ supported: [4], requested: 4, root: { mode: "partial" } }) + expect(cap.mode).toBe("legacy") + expect(cap.migration_boundary).toEqual({ kind: "unmigrated" }) + }) + + test("an unsupported (old) engine forces legacy regardless of root mode", () => { + for (const root of [fullRoot, partialRoot, legacyRoot]) { + const cap = SessionRollout.Capability.discover({ supported: [4], requested: 5, root }) + expect(cap.mode).toBe("legacy") + expect(cap.migration_boundary).toEqual({ kind: "unmigrated" }) + expect(cap.protocol_version).toBe(5) + } + }) + + test("is a pure function — identical inputs yield identical outputs (deterministic, no probing)", () => { + const input = { supported: [4], requested: 4, root: partialRoot } + expect(SessionRollout.Capability.discover(input)).toEqual(SessionRollout.Capability.discover(input)) + }) +}) + +describe("SessionRollout.Matrix.resolve — mixed-version matrix, visibly labelled (AC2)", () => { + const engineNew = { supported: [4], requested: 4 } + const engineOld = { supported: [4], requested: 5 } + + test("old engine + new client → legacy, labelled", () => { + const out = SessionRollout.Matrix.resolve({ engine: engineOld, client: "new", root: fullRoot }) + expect(out.mode).toBe("legacy") + expect(out.label).toBe("legacy") + }) + + test("new engine + old client → pre-ledger view, never a ledger mode, labelled", () => { + const out = SessionRollout.Matrix.resolve({ engine: engineNew, client: "old", root: fullRoot }) + expect(out.mode).toBe("legacy") + expect(out.label).toBe("pre_ledger") + expect(out.migration_boundary).toEqual({ kind: "unmigrated" }) + }) + + test("new engine + new client on a full root → full", () => { + const out = SessionRollout.Matrix.resolve({ engine: engineNew, client: "new", root: fullRoot }) + expect(out.mode).toBe("full") + expect(out.label).toBe("full") + }) + + test("new engine + new client on a partial root → labelled partial", () => { + const out = SessionRollout.Matrix.resolve({ engine: engineNew, client: "new", root: partialRoot }) + expect(out.mode).toBe("partial") + expect(out.label).toBe("partial") + }) + + test("new engine + new client on a legacy root → labelled legacy", () => { + const out = SessionRollout.Matrix.resolve({ engine: engineNew, client: "new", root: legacyRoot }) + expect(out.mode).toBe("legacy") + expect(out.label).toBe("legacy") + }) + + test("no matrix outcome ever claims full provenance except the true full case (invariant)", () => { + const cases = [ + { engine: engineOld, client: "new" as const, root: fullRoot }, + { engine: engineNew, client: "old" as const, root: fullRoot }, + { engine: engineNew, client: "new" as const, root: partialRoot }, + { engine: engineNew, client: "new" as const, root: legacyRoot }, + ] + for (const c of cases) expect(SessionRollout.Matrix.resolve(c).mode).not.toBe("full") + }) +}) + +describe("SessionRollout.Migration — transitions are explicit only (AC3)", () => { + test("legacy → partial only through an explicit epoch start", () => { + const out = SessionRollout.Migration.startEpoch({ current: "legacy", boundary: 42 }) + expect(out).toEqual({ kind: "started", mode: "partial", migration_boundary: { kind: "epoch", startedAt: 42 } }) + }) + + test("a full session cannot 'start an epoch' — rejected, never re-labelled", () => { + expect(SessionRollout.Migration.startEpoch({ current: "full", boundary: 42 }).kind).toBe("rejected") + }) + + test("a session already in a partial epoch cannot restart it", () => { + expect(SessionRollout.Migration.startEpoch({ current: "partial", boundary: 42 }).kind).toBe("rejected") + }) + + test("pre-existing sessions stay legacy until an explicit epoch is opened", () => { + // Discovery of an untouched legacy root never yields partial… + expect(SessionRollout.Capability.discover({ supported: [4], requested: 4, root: legacyRoot }).mode).toBe("legacy") + // …and the ONLY path to partial is the explicit start above (there is no infer/backfill fn). + expect((SessionRollout.Migration as Record)["inferEpoch"]).toBeUndefined() + }) +}) + +describe("SessionRollout.resolveReservation — in-flight v1 reservations (AC4)", () => { + test("a v1 (pre-ledger) reservation resolves as legacy, never adopted into a ledger op", () => { + const out = SessionRollout.resolveReservation({ reservation: { generation: 1 }, ledgerGeneration: SessionRollout.LEDGER_GENERATION }) + expect(out).toEqual({ kind: "legacy", label: "legacy" }) + }) + + test("a reservation at or after the ledger generation resolves as a ledger reservation", () => { + const out = SessionRollout.resolveReservation({ reservation: { generation: SessionRollout.LEDGER_GENERATION }, ledgerGeneration: SessionRollout.LEDGER_GENERATION }) + expect(out).toEqual({ kind: "ledger", generation: SessionRollout.LEDGER_GENERATION }) + }) + + test("the ledger generation is strictly after v1", () => { + expect(SessionRollout.LEDGER_GENERATION).toBeGreaterThan(1) + }) +}) + +describe("SessionRollout.Rollback — cannot authorize an uncontextualized full mutation (AC5)", () => { + test("with full discovery disabled, a full root downgrades to legacy — never authorizes full provenance", () => { + const cap = SessionRollout.Capability.discover({ supported: [4], requested: 4, root: fullRoot, fullDiscoveryEnabled: false }) + expect(cap.mode).toBe("legacy") + expect(cap.migration_boundary).toEqual({ kind: "unmigrated" }) + }) + + test("full discovery defaults enabled (steady state)", () => { + expect(SessionRollout.Capability.discover({ supported: [4], requested: 4, root: fullRoot }).mode).toBe("full") + }) + + test("the danger window is full-discovery-live while client routes are gone", () => { + expect(SessionRollout.Rollback.unsafe({ fullDiscovery: true, clientRoutes: false })).toBe(true) + expect(SessionRollout.Rollback.unsafe({ fullDiscovery: false, clientRoutes: false })).toBe(false) + expect(SessionRollout.Rollback.unsafe({ fullDiscovery: true, clientRoutes: true })).toBe(false) + }) + + test("the correct rollback plan disables full discovery BEFORE withdrawing client routes — no unsafe step", () => { + const plan = SessionRollout.Rollback.plan() + expect(plan[0]).toEqual({ fullDiscovery: true, clientRoutes: true }) + expect(plan.at(-1)).toEqual({ fullDiscovery: false, clientRoutes: false }) + for (const step of plan) expect(SessionRollout.Rollback.unsafe(step)).toBe(false) + }) + + test("withdrawing client routes first (wrong order) produces an unsafe intermediate state", () => { + const wrongOrder = { fullDiscovery: true, clientRoutes: false } + expect(SessionRollout.Rollback.unsafe(wrongOrder)).toBe(true) + }) +}) + +// ── Release manifest schema/parsing + all-required-passed gate (AC6 evaluator, AC7) ── + +const validManifestObject = () => ({ + forkTag: "opencode-v1.18.12-amicode.3", + binaryChecksums: { "darwin-arm64": "sha256:aaa", "linux-x64": "sha256:bbb" }, + lockPin: "opencode.lock:deadbeef", + extensionVersion: "0.0.3", + overlayProvenance: { verified: true }, + rehearsalCaseIDs: ["upgrade", "rollback", "active-session", "in-flight-reservation"], + matrixCaseIDs: ["old-engine-new-client", "new-engine-old-client", "new-new-full", "new-new-partial", "new-new-legacy"], + completion: { forkAt: 100, binaryPinAt: 200, extensionAt: 300 }, + gates: [ + { id: "rehearsal:upgrade", required: true, passed: true }, + { id: "matrix:old-engine-new-client", required: true, passed: true }, + ], +}) + +describe("SessionRollout.Release.parse — manifest schema/parsing", () => { + test("parses a well-formed manifest object", () => { + const parsed = SessionRollout.Release.parse(validManifestObject()) + expect(parsed.ok).toBe(true) + if (parsed.ok) { + expect(parsed.manifest.forkTag).toBe("opencode-v1.18.12-amicode.3") + expect(parsed.manifest.rehearsalCaseIDs).toContain("rollback") + expect(parsed.manifest.matrixCaseIDs.length).toBe(5) + } + }) + + test("rejects a non-object", () => { + expect(SessionRollout.Release.parse(null).ok).toBe(false) + expect(SessionRollout.Release.parse("nope").ok).toBe(false) + }) + + test("reports each missing required field by name", () => { + const { forkTag, ...missingForkTag } = validManifestObject() + void forkTag + const parsed = SessionRollout.Release.parse(missingForkTag) + expect(parsed.ok).toBe(false) + if (!parsed.ok) expect(parsed.errors.join(" ")).toContain("forkTag") + }) + + test("rejects an unverifiable overlay-provenance shape", () => { + const bad = { ...validManifestObject(), overlayProvenance: { verified: "yes" } } + expect(SessionRollout.Release.parse(bad).ok).toBe(false) + }) +}) + +describe("SessionRollout.Release.evaluate — all-required-passed gate, enablement stays off (AC6, AC7)", () => { + const mandatory = { + rehearsalCaseIDs: ["upgrade", "rollback", "active-session", "in-flight-reservation"], + matrixCaseIDs: ["old-engine-new-client", "new-engine-old-client", "new-new-full", "new-new-partial", "new-new-legacy"], + } + + test("a fully-passing manifest reports ready, and default enablement is STILL off (only reports readiness)", () => { + const parsed = SessionRollout.Release.parse(validManifestObject()) + expect(parsed.ok).toBe(true) + if (!parsed.ok) return + const readiness = SessionRollout.Release.evaluate(parsed.manifest, mandatory) + expect(readiness.ready).toBe(true) + expect(readiness.missing).toEqual([]) + // AC7: the evaluator NEVER enables — it reports. Default enablement is off, always. + expect(readiness.defaultEnablement).toBe("off") + }) + + test("a missing mandatory rehearsal case blocks readiness and names it", () => { + const parsed = SessionRollout.Release.parse({ ...validManifestObject(), rehearsalCaseIDs: ["upgrade"] }) + if (!parsed.ok) throw new Error("fixture should parse") + const readiness = SessionRollout.Release.evaluate(parsed.manifest, mandatory) + expect(readiness.ready).toBe(false) + expect(readiness.missing.join(" ")).toContain("rollback") + expect(readiness.defaultEnablement).toBe("off") + }) + + test("a missing mandatory matrix case blocks readiness", () => { + const parsed = SessionRollout.Release.parse({ ...validManifestObject(), matrixCaseIDs: ["new-new-full"] }) + if (!parsed.ok) throw new Error("fixture should parse") + const readiness = SessionRollout.Release.evaluate(parsed.manifest, mandatory) + expect(readiness.ready).toBe(false) + }) + + test("a failed required gate blocks readiness", () => { + const parsed = SessionRollout.Release.parse({ + ...validManifestObject(), + gates: [{ id: "rehearsal:upgrade", required: true, passed: false }], + }) + if (!parsed.ok) throw new Error("fixture should parse") + const readiness = SessionRollout.Release.evaluate(parsed.manifest, mandatory) + expect(readiness.ready).toBe(false) + expect(readiness.missing.join(" ")).toContain("rehearsal:upgrade") + }) + + test("an unverified overlay-provenance blocks readiness", () => { + const parsed = SessionRollout.Release.parse({ ...validManifestObject(), overlayProvenance: { verified: false } }) + if (!parsed.ok) throw new Error("fixture should parse") + const readiness = SessionRollout.Release.evaluate(parsed.manifest, mandatory) + expect(readiness.ready).toBe(false) + expect(readiness.missing.join(" ")).toContain("overlay") + }) + + test("empty binary checksums block readiness", () => { + const parsed = SessionRollout.Release.parse({ ...validManifestObject(), binaryChecksums: {} }) + if (!parsed.ok) throw new Error("fixture should parse") + expect(SessionRollout.Release.evaluate(parsed.manifest, mandatory).ready).toBe(false) + }) +}) + +describe("SessionRollout.Release.orderingSatisfied — fork → binary pin → extension (AC6 evaluator)", () => { + const mandatory = { + rehearsalCaseIDs: ["upgrade", "rollback", "active-session", "in-flight-reservation"], + matrixCaseIDs: ["old-engine-new-client", "new-engine-old-client", "new-new-full", "new-new-partial", "new-new-legacy"], + } + + test("fork completes before the binary pin, which completes before extension release", () => { + const parsed = SessionRollout.Release.parse(validManifestObject()) + if (!parsed.ok) throw new Error("fixture should parse") + expect(SessionRollout.Release.orderingSatisfied(parsed.manifest)).toBe(true) + }) + + test("a binary pin BEFORE the fork release violates the ordering", () => { + const parsed = SessionRollout.Release.parse({ + ...validManifestObject(), + completion: { forkAt: 300, binaryPinAt: 200, extensionAt: 400 }, + }) + if (!parsed.ok) throw new Error("fixture should parse") + expect(SessionRollout.Release.orderingSatisfied(parsed.manifest)).toBe(false) + }) + + test("an extension release BEFORE the binary pin violates the ordering", () => { + const parsed = SessionRollout.Release.parse({ + ...validManifestObject(), + completion: { forkAt: 100, binaryPinAt: 300, extensionAt: 200 }, + }) + if (!parsed.ok) throw new Error("fixture should parse") + expect(SessionRollout.Release.orderingSatisfied(parsed.manifest)).toBe(false) + }) + + test("ordering evaluation only READS the manifest — it never performs a release act", () => { + const parsed = SessionRollout.Release.parse(validManifestObject()) + if (!parsed.ok) throw new Error("fixture should parse") + const before = JSON.stringify(parsed.manifest) + SessionRollout.Release.orderingSatisfied(parsed.manifest) + SessionRollout.Release.evaluate(parsed.manifest, mandatory) + expect(JSON.stringify(parsed.manifest)).toBe(before) + }) +}) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 86086eb586..9bcea16624 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -1,5 +1,7 @@ import { describe, expect } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" +import { Database } from "@opencode-ai/core/database/database" +import { SessionLineageTable, SessionReceiptAssessmentTable, SessionReceiptTable } from "@opencode-ai/core/session/sql" import { EventV2 } from "@opencode-ai/core/event" import { SessionProjector } from "@opencode-ai/core/session/projector" import { Deferred, Effect, Exit, Layer } from "effect" @@ -17,12 +19,16 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" import { ExternalDiff } from "@/session/external-diff" +import { SessionReceipt } from "@/session/receipt" +import { SessionEvidence } from "@/session/evidence" import path from "path" +import { eq } from "drizzle-orm" const it = testEffect( AppNodeBuilder.build( LayerNode.group([ SessionNs.node, + Database.node, EventV2Bridge.node, SessionProjector.node, CrossSpawnSpawner.node, @@ -208,6 +214,658 @@ describe("step-finish token propagation via event", () => { }) describe("Session", () => { + it.instance("keeps receipts metadata-only when evidence exceeds its sidecar budget", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const database = yield* Database.Service + const root = yield* session.create({ title: "evidence overflow root" }) + const budget = { maxReceipts: 1, maxMetadataBytes: 1_000, maxEvidenceBytes: 3 } + + yield* SessionReceipt.publish(database, { + id: "evidence_overflow_operation", + sessionID: root.id, + origin: "agent", + budget, + receipts: [ + { + id: "evidence_overflow_receipt", + resource: "file:///overflow", + operation: "write", + outcome: "applied", + timeCreated: 1, + }, + ], + evidence: [{ receiptID: "evidence_overflow_receipt", content: "too large" }], + }) + + expect(yield* SessionReceipt.committed(database, root.id)).toHaveLength(1) + expect(SessionEvidence.exists(root.id, "evidence_overflow_operation")).toBe(false) + yield* SessionReceipt.assessEvidence(database, { receiptID: "evidence_overflow_receipt", timeCreated: 2 }) + expect(yield* SessionReceipt.assessments(database, "evidence_overflow_receipt")).toEqual([]) + }), + ) + + it.instance("removes interrupted evidence sidecars without committing an invalid reference", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const database = yield* Database.Service + const root = yield* session.create({ title: "interrupted evidence root" }) + const budget = { maxReceipts: 1, maxMetadataBytes: 1_000, maxEvidenceBytes: 1_000 } + const input = { + id: "interrupted_evidence_operation", + sessionID: root.id, + origin: "agent", + budget, + receipts: [ + { + id: "interrupted_evidence_receipt", + resource: "file:///interrupted", + operation: "write", + outcome: "applied", + timeCreated: 1, + }, + ], + } + + yield* SessionReceipt.reserve(database, input) + SessionEvidence.write( + root.id, + input.id, + [{ receiptID: "interrupted_evidence_receipt", content: "baseline" }], + 1_000, + ) + expect(SessionEvidence.exists(root.id, input.id)).toBe(true) + expect(yield* SessionReceipt.committed(database, root.id)).toEqual([]) + + yield* SessionReceipt.cleanupEvidence(database, root.id) + yield* SessionReceipt.cleanupEvidence(database, root.id) + expect(SessionEvidence.exists(root.id, input.id)).toBe(false) + expect(yield* SessionReceipt.committed(database, root.id)).toEqual([]) + }), + ) + + it.instance("expires and deletes root-owned evidence sidecars idempotently", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const database = yield* Database.Service + const root = yield* session.create({ title: "retained evidence root" }) + const budget = { maxReceipts: 1, maxMetadataBytes: 1_000, maxEvidenceBytes: 1_000, retentionMs: 10 } + + yield* SessionReceipt.publish(database, { + id: "retained_evidence_operation", + sessionID: root.id, + origin: "agent", + budget, + receipts: [ + { + id: "retained_evidence_receipt", + resource: "file:///retained", + operation: "write", + outcome: "applied", + timeCreated: 10, + }, + ], + evidence: [{ receiptID: "retained_evidence_receipt", content: "baseline" }], + }) + expect(SessionEvidence.exists(root.id, "retained_evidence_operation")).toBe(true) + + yield* SessionReceipt.expireEvidence(database, { rootID: root.id, now: 20, retentionMs: 10 }) + yield* SessionReceipt.expireEvidence(database, { rootID: root.id, now: 20, retentionMs: 10 }) + expect(SessionEvidence.exists(root.id, "retained_evidence_operation")).toBe(false) + const expiredAssessment = yield* SessionReceipt.assessments(database, "retained_evidence_receipt") + expect(expiredAssessment).toMatchObject([ + { + receiptID: "retained_evidence_receipt", + confidence: "observed", + netState: "unknown", + evidenceState: "available", + revision: 1, + timeCreated: 10, + }, + { + receiptID: "retained_evidence_receipt", + confidence: "unavailable", + netState: "unavailable", + evidenceState: "unavailable", + revision: 2, + timeCreated: 20, + }, + ]) + expect(expiredAssessment[0]).not.toHaveProperty("patch") + + SessionEvidence.write( + root.id, + "deleted_evidence_operation", + [{ receiptID: "retained_evidence_receipt", content: "baseline" }], + 1_000, + ) + yield* session.remove(root.id) + yield* SessionReceipt.removeRootEvidence(database, root.id) + expect(SessionEvidence.exists(root.id, "deleted_evidence_operation")).toBe(false) + }), + ) + + it.instance("assesses missing receipt evidence as unavailable without a patch payload", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const database = yield* Database.Service + const root = yield* session.create({ title: "missing evidence root" }) + yield* SessionReceipt.publish(database, { + id: "missing_evidence_operation", + sessionID: root.id, + origin: "agent", + budget: { maxReceipts: 2, maxMetadataBytes: 1_000, maxEvidenceBytes: 1_000 }, + receipts: [ + { + id: "missing_evidence_receipt", + resource: "file:///missing", + operation: "write", + outcome: "applied", + timeCreated: 1, + }, + { + id: "metadata_only_receipt", + resource: "file:///metadata-only", + operation: "write", + outcome: "applied", + timeCreated: 1, + }, + ], + evidence: [{ receiptID: "missing_evidence_receipt", content: "baseline" }], + }) + SessionEvidence.removeRoot(root.id) + + yield* SessionReceipt.assessEvidence(database, { receiptID: "missing_evidence_receipt", timeCreated: 2 }) + yield* SessionReceipt.assessEvidence(database, { receiptID: "missing_evidence_receipt", timeCreated: 3 }) + yield* SessionReceipt.assessEvidence(database, { receiptID: "metadata_only_receipt", timeCreated: 2 }) + + const assessment = yield* SessionReceipt.assessments(database, "missing_evidence_receipt") + expect(assessment).toMatchObject([ + { + receiptID: "missing_evidence_receipt", + confidence: "observed", + netState: "unknown", + evidenceState: "available", + revision: 1, + timeCreated: 1, + }, + { + receiptID: "missing_evidence_receipt", + confidence: "unavailable", + netState: "unavailable", + evidenceState: "unavailable", + revision: 2, + timeCreated: 2, + }, + ]) + expect(assessment[0]).not.toHaveProperty("patch") + expect(yield* SessionReceipt.assessments(database, "metadata_only_receipt")).toEqual([]) + }), + ) + + it.instance("reserves root-wide receipt and metadata capacity before publishing", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const database = yield* Database.Service + const root = yield* session.create({ title: "budget root" }) + const budget = { maxReceipts: 2, maxMetadataBytes: 1_000 } + const first = [ + { id: "budget_first", resource: "file:///first", operation: "write", outcome: "applied", timeCreated: 1 }, + ] + const second = [ + { id: "budget_second", resource: "file:///second", operation: "write", outcome: "applied", timeCreated: 2 }, + ] + + yield* SessionReceipt.publish(database, { + id: "budget_op_first", + sessionID: root.id, + origin: "agent", + receipts: first, + budget, + }) + yield* SessionReceipt.publish(database, { + id: "budget_op_second", + sessionID: root.id, + origin: "agent", + receipts: second, + budget, + }) + + const receiptOverflow = yield* Effect.exit( + SessionReceipt.publish(database, { + id: "budget_op_receipt_overflow", + sessionID: root.id, + origin: "agent", + receipts: [ + { id: "budget_third", resource: "file:///third", operation: "write", outcome: "applied", timeCreated: 3 }, + ], + budget, + }), + ) + expect(receiptOverflow._tag).toBe("Failure") + expect(yield* SessionReceipt.committed(database, root.id)).toHaveLength(2) + + const metadataOverflow = yield* Effect.exit( + SessionReceipt.reserve(database, { + id: "budget_op_metadata_overflow", + sessionID: root.id, + origin: "agent", + receipts: [ + { + id: "budget_metadata", + resource: "file:///metadata", + operation: "write", + outcome: "applied", + timeCreated: 3, + }, + ], + budget: { maxReceipts: 3, maxMetadataBytes: 1 }, + }), + ) + expect(metadataOverflow._tag).toBe("Failure") + }), + ) + + it.instance("keeps concurrent reservations within a root budget", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const database = yield* Database.Service + const root = yield* session.create({ title: "reservation root" }) + const budget = { maxReceipts: 1, maxMetadataBytes: 1_000 } + const reserve = (id: string) => + SessionReceipt.reserve(database, { + id, + sessionID: root.id, + origin: "agent", + receipts: [ + { id: `${id}_receipt`, resource: `file:///${id}`, operation: "write", outcome: "applied", timeCreated: 1 }, + ], + budget, + }) + + const reservations = yield* Effect.all( + [Effect.exit(reserve("reservation_one")), Effect.exit(reserve("reservation_two"))], + { + concurrency: "unbounded", + }, + ) + expect(reservations.filter((result) => result._tag === "Success")).toHaveLength(1) + }), + ) + + it.instance("releases aborted reservations without consuming root capacity", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const database = yield* Database.Service + const root = yield* session.create({ title: "aborted reservation root" }) + const budget = { maxReceipts: 1, maxMetadataBytes: 1_000 } + const receipt = (id: string) => [ + { id, resource: `file:///${id}`, operation: "write", outcome: "applied", timeCreated: 1 }, + ] + + yield* SessionReceipt.reserve(database, { + id: "reservation_aborted", + sessionID: root.id, + origin: "agent", + receipts: receipt("reservation_aborted_receipt"), + budget, + }) + yield* SessionReceipt.abort(database, "reservation_aborted") + yield* SessionReceipt.reserve(database, { + id: "reservation_after_abort", + sessionID: root.id, + origin: "agent", + receipts: receipt("reservation_after_abort_receipt"), + budget, + }) + expect(yield* SessionReceipt.committed(database, root.id)).toEqual([]) + }), + ) + + it.instance("atomically publishes one committed receipt group for a lineage root", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const database = yield* Database.Service + const root = yield* session.create({ title: "receipt root" }) + const child = yield* session.create({ parentID: root.id, title: "receipt child" }) + const budget = { maxReceipts: 4, maxMetadataBytes: 100_000 } + + yield* SessionReceipt.publish(database, { + id: "op_first", + sessionID: root.id, + origin: "agent", + budget, + receipts: [ + { id: "receipt_first", resource: "file:///first", operation: "write", outcome: "applied", timeCreated: 1 }, + { id: "receipt_second", resource: "file:///second", operation: "write", outcome: "applied", timeCreated: 2 }, + ], + }) + + expect(yield* SessionReceipt.committed(database, root.id)).toEqual([ + { + id: "op_first", + rootID: root.id, + sessionID: root.id, + origin: "agent", + state: "committed", + receipts: [ + { + id: "receipt_first", + sequence: 1, + resource: "file:///first", + operation: "write", + outcome: "applied", + timeCreated: 1, + }, + { + id: "receipt_second", + sequence: 2, + resource: "file:///second", + operation: "write", + outcome: "applied", + timeCreated: 2, + }, + ], + }, + ]) + + yield* SessionReceipt.publish(database, { + id: "op_followup", + sessionID: child.id, + origin: "agent", + budget, + receipts: [ + { id: "receipt_third", resource: "file:///third", operation: "delete", outcome: "applied", timeCreated: 3 }, + ], + }) + expect((yield* SessionReceipt.committed(database, root.id))[1]?.receipts).toEqual([ + { + id: "receipt_third", + sequence: 3, + resource: "file:///third", + operation: "delete", + outcome: "applied", + timeCreated: 3, + }, + ]) + + const duplicate = yield* Effect.exit( + SessionReceipt.publish(database, { + id: "op_rolled_back", + sessionID: root.id, + origin: "agent", + budget, + receipts: [ + { id: "receipt_first", resource: "file:///third", operation: "write", outcome: "applied", timeCreated: 3 }, + ], + }), + ) + expect(duplicate._tag).toBe("Failure") + expect(yield* SessionReceipt.committed(database, root.id)).toHaveLength(2) + + yield* SessionReceipt.publish(database, { + id: "op_after_failure", + sessionID: root.id, + origin: "agent", + budget, + receipts: [ + { + id: "receipt_after_failure", + resource: "file:///fourth", + operation: "write", + outcome: "applied", + timeCreated: 4, + }, + ], + }) + expect(yield* SessionReceipt.committed(database, root.id)).toHaveLength(3) + + const firstPage = yield* SessionReceipt.page(database, child.id, { limit: 2 }) + expect(firstPage).toMatchObject({ + rootID: root.id, + receipts: [ + { id: "receipt_first", sequence: 1 }, + { id: "receipt_second", sequence: 2 }, + ], + nextCursor: 2, + }) + const secondPage = yield* SessionReceipt.page(database, root.id, { cursor: firstPage.nextCursor, limit: 1 }) + expect(secondPage).toMatchObject({ + rootID: root.id, + receipts: [{ id: "receipt_third", sequence: 3 }], + nextCursor: 3, + }) + const thirdPage = yield* SessionReceipt.page(database, root.id, { cursor: secondPage.nextCursor, limit: 2 }) + expect(thirdPage).toMatchObject({ + rootID: root.id, + receipts: [{ id: "receipt_after_failure", sequence: 4 }], + nextCursor: undefined, + }) + expect( + [...firstPage.receipts, ...secondPage.receipts, ...thirdPage.receipts].map((receipt) => receipt.sequence), + ).toEqual([1, 2, 3, 4]) + expect((yield* Effect.exit(SessionReceipt.page(database, child.id, { limit: 0 })))._tag).toBe("Failure") + + const rewrite = yield* Effect.exit( + database.db + .update(SessionReceiptTable) + .set({ outcome: "rewritten" }) + .where(eq(SessionReceiptTable.id, "receipt_first")) + .run(), + ) + expect(rewrite._tag).toBe("Failure") + expect((yield* SessionReceipt.committed(database, root.id))[0]?.receipts[0]?.outcome).toBe("applied") + }), + ) + + it.instance("appends receipt assessments without rewriting immutable facts", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const database = yield* Database.Service + const root = yield* session.create({ title: "assessment root" }) + const budget = { maxReceipts: 100, maxMetadataBytes: 100_000 } + yield* SessionReceipt.publish(database, { + id: "op_assessed", + sessionID: root.id, + origin: "agent", + budget, + receipts: [ + { + id: "receipt_assessed", + resource: "file:///assessed", + operation: "write", + outcome: "applied", + timeCreated: 1, + }, + ], + }) + + yield* SessionReceipt.appendAssessment(database, { + id: "assessment_first", + receiptID: "receipt_assessed", + confidence: "observed", + netState: "changed", + evidenceState: "available", + revision: 1, + expiresAt: 10, + timeCreated: 2, + }) + yield* SessionReceipt.appendAssessment(database, { + id: "assessment_second", + receiptID: "receipt_assessed", + confidence: "verified", + netState: "restored", + evidenceState: "unavailable", + revision: 2, + timeCreated: 3, + }) + + expect(yield* SessionReceipt.assessments(database, "receipt_assessed")).toEqual([ + { + id: "assessment_first", + receiptID: "receipt_assessed", + confidence: "observed", + netState: "changed", + evidenceState: "available", + revision: 1, + expiresAt: 10, + timeCreated: 2, + }, + { + id: "assessment_second", + receiptID: "receipt_assessed", + confidence: "verified", + netState: "restored", + evidenceState: "unavailable", + revision: 2, + timeCreated: 3, + }, + ]) + const rewrite = yield* Effect.exit( + database.db + .update(SessionReceiptAssessmentTable) + .set({ net_state: "rewritten" }) + .where(eq(SessionReceiptAssessmentTable.id, "assessment_first")) + .run(), + ) + expect(rewrite._tag).toBe("Failure") + expect((yield* SessionReceipt.committed(database, root.id))[0]?.receipts[0]?.outcome).toBe("applied") + }), + ) + + it.instance("creates one full lineage root for each new session", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const created = yield* session.create({ title: "lineage root" }) + + expect(yield* session.lineage(created.id)).toMatchObject({ + mode: "full", + rootID: created.id, + root: { sessionID: created.id, title: "lineage root" }, + descendants: [], + }) + }), + ) + + it.instance("keeps registered task and session spawns under the parent root with typed edges", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const parent = yield* session.create({ title: "parent" }) + const task = yield* session.create({ + parentID: parent.id, + lineageEdgeKind: "task_spawn", + title: "task child", + }) + const spawned = yield* session.create({ parentID: parent.id, title: "spawn child" }) + + expect(yield* session.lineage(task.id)).toMatchObject({ + mode: "full", + rootID: parent.id, + root: { sessionID: parent.id }, + descendants: [ + { sessionID: task.id, parentID: parent.id, edgeKind: "task_spawn", mode: "full" }, + { sessionID: spawned.id, parentID: parent.id, edgeKind: "session_spawn", mode: "full" }, + ], + }) + }), + ) + + it.instance("keeps legacy parents outside aggregation until an explicit partial epoch opens", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const database = yield* Database.Service + const legacy = yield* session.create({ title: "before rollout" }) + yield* database.db.delete(SessionLineageTable).where(eq(SessionLineageTable.session_id, legacy.id)).run() + + expect(yield* session.lineage(legacy.id)).toMatchObject({ + mode: "legacy", + rootID: undefined, + root: undefined, + descendants: [], + }) + + const beforeEpoch = yield* session.create({ parentID: legacy.id, title: "untracked parent spawn" }) + expect(yield* session.lineage(beforeEpoch.id)).toMatchObject({ + mode: "full", + rootID: beforeEpoch.id, + legacyParentID: legacy.id, + descendants: [], + }) + + yield* session.beginPartialLineage(legacy.id, 42) + const afterEpoch = yield* session.create({ parentID: legacy.id, title: "tracked parent spawn" }) + expect(yield* session.lineage(afterEpoch.id)).toMatchObject({ + mode: "partial", + rootID: legacy.id, + descendants: [{ sessionID: afterEpoch.id, edgeKind: "session_spawn", mode: "partial" }], + }) + }), + ) + + it.instance("gives forks independent roots without inherited lineage descendants", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const parent = yield* session.create({ title: "source" }) + const child = yield* session.create({ parentID: parent.id, title: "source child" }) + const fork = yield* session.fork({ sessionID: parent.id }) + + expect(yield* session.lineage(fork.id)).toMatchObject({ + mode: "full", + rootID: fork.id, + root: { sessionID: fork.id }, + descendants: [], + }) + expect(yield* session.lineage(parent.id)).toMatchObject({ + rootID: parent.id, + descendants: [{ sessionID: child.id }], + }) + }), + ) + + it.instance("hides archived children and retains only their root-owned origin projection after deletion", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const root = yield* session.create({ title: "root" }) + const child = yield* session.create({ parentID: root.id, title: "child", metadata: { private: "do not retain" } }) + + yield* session.setArchived({ sessionID: child.id, time: 1 }) + expect((yield* session.lineage(root.id)).descendants).toEqual([]) + + yield* session.remove(child.id) + const lineage = yield* session.lineage(root.id, { retainedOrigins: true }) + expect(lineage).toMatchObject({ + rootID: root.id, + descendants: [], + retainedOrigins: [{ sessionID: child.id, title: "child", edgeKind: "session_spawn" }], + }) + expect(lineage.retainedOrigins[0]).not.toHaveProperty("metadata") + }), + ) + + it.instance("queries only registered descendants of the requested concurrent root", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const [first, second] = yield* Effect.all( + [session.create({ title: "first root" }), session.create({ title: "second root" })], + { concurrency: "unbounded" }, + ) + const [firstChild, secondChild] = yield* Effect.all( + [ + session.create({ parentID: first.id, title: "first child" }), + session.create({ parentID: second.id, title: "second child" }), + ], + { concurrency: "unbounded" }, + ) + + expect((yield* session.lineage(first.id)).descendants).toEqual([ + expect.objectContaining({ sessionID: firstChild.id, parentID: first.id }), + ]) + expect((yield* session.lineage(second.id)).descendants).toEqual([ + expect.objectContaining({ sessionID: secondChild.id, parentID: second.id }), + ]) + }), + ) + it.live("remove works without an instance", () => Effect.gen(function* () { const session = yield* SessionNs.Service diff --git a/packages/opencode/test/session/user-mutation.test.ts b/packages/opencode/test/session/user-mutation.test.ts new file mode 100644 index 0000000000..1ea6b5567f --- /dev/null +++ b/packages/opencode/test/session/user-mutation.test.ts @@ -0,0 +1,500 @@ +import { describe, expect, test } from "bun:test" +import { SessionMutation } from "@/session/mutation" +import { SessionUserMutation } from "@/session/user-mutation" + +const lineage = (rootID: string, sessionID: string) => (candidate: string) => + candidate === sessionID ? rootID : undefined + +const gateFor = (input: { + rootForSession: (sessionID: string) => string | undefined + now?: () => number + operations?: SessionMutation.OperationStore +}) => + SessionMutation.create({ + rootForSession: input.rootForSession, + now: input.now ?? (() => 10), + operations: input.operations ?? SessionMutation.OperationStore.memory(), + }) + +/** A host provider that resolves display targets to stable physical identities and applies every write. */ +const hostProvider = (record?: (id: string) => void): SessionUserMutation.HostProvider => ({ + capabilities: { safeResolve: true, noFollowWrite: true }, + resolveTarget: (display) => ({ value: `local:existing:1:${display}`, kind: display.endsWith("/") ? "directory" : "file" }), + safeResolve: (endpoint) => endpoint, + execute: (resource) => { + record?.(resource.id) + return "applied" + }, +}) + +const snapshot = (over?: Partial): SessionUserMutation.PanelSnapshot => ({ + panelID: "panel-sidebar", + sessionID: "session-1", + rootID: "root-1", + origin: "user", + ...over, +}) + +const full: SessionUserMutation.Capability = { mode: "full", version: 1 } + +describe("session user-mutation registry", () => { + test("registers user sidebar, preview, and review routes as ledger writers and bumps the manifest version", () => { + for (const routeID of ["user-sidebar-op", "user-preview-edit", "user-review-edit"] as const) + expect(SessionMutation.Registry.require(routeID)).toEqual({ id: routeID, kind: "ledger" }) + expect(SessionUserMutation.Routes).toEqual({ + sidebar: "user-sidebar-op", + preview: "user-preview-edit", + review: "user-review-edit", + }) + // The registry version advances so mixed clients negotiate the same generation. + expect(SessionMutation.Registry.manifest().version).toBeGreaterThanOrEqual(4) + }) +}) + +describe("session user-mutation capability discovery", () => { + test("resolves full, partial, legacy, and unavailable outcomes deterministically", () => { + expect(SessionUserMutation.Capability.discover({ supported: [1], requested: 1, hostReachable: true, sessionEpoch: "full" })).toEqual({ + mode: "full", + version: 1, + }) + expect( + SessionUserMutation.Capability.discover({ supported: [1], requested: 1, hostReachable: true, sessionEpoch: "post_upgrade" }), + ).toEqual({ mode: "partial", version: 1, label: "post_upgrade_partial" }) + // Server does not advertise a supported generation -> legacy display with a visible label. + expect( + SessionUserMutation.Capability.discover({ supported: [1], requested: 2, hostReachable: true, sessionEpoch: "full" }), + ).toEqual({ mode: "legacy", label: "legacy" }) + // No host mediation reachable at all -> unavailable, never a silent untracked mutation. + expect( + SessionUserMutation.Capability.discover({ supported: [1], requested: 1, hostReachable: false, sessionEpoch: "full" }), + ).toEqual({ mode: "unavailable" }) + }) +}) + +describe("session user-mutation origin", () => { + test("keeps user receipts distinguishable from agent, child-agent, and system", () => { + expect(SessionUserMutation.distinguish("user")).toBe("user") + expect(SessionUserMutation.distinguish("agent")).toBe("agent") + expect(SessionUserMutation.distinguish("child_agent")).toBe("child_agent") + expect(SessionUserMutation.distinguish("system")).toBe("system") + expect(SessionUserMutation.distinguish("mystery")).toBeUndefined() + // A user route mediated with a non-user origin snapshot is refused before any storage work. + const gate = gateFor({ rootForSession: lineage("root-1", "session-1") }) + let writes = 0 + const result = SessionUserMutation.mediate({ + intent: { operation: "create", panel: "sidebar", target: { display: "notes.md" }, idempotencyKey: "op-origin" }, + capability: full, + snapshot: snapshot({ origin: "agent" }), + rootForSession: lineage("root-1", "session-1"), + gate, + provider: hostProvider(() => writes++), + }) + expect(result).toEqual({ kind: "denied", reason: "non_user_origin" }) + expect(writes).toBe(0) + }) +}) + +describe("session user-mutation intent transport", () => { + test("the browser intent and the display-safe result never carry host-only paths, hashes, evidence, or capabilities", () => { + const gate = gateFor({ rootForSession: lineage("root-1", "session-1") }) + const intent: SessionUserMutation.MutationIntent = { + operation: "create", + panel: "sidebar", + target: { display: "notes.md" }, + idempotencyKey: "op-transport", + } + // The intent shape carries only display-safe user selections. + expect(Object.keys(intent).sort()).toEqual(["idempotencyKey", "operation", "panel", "target"]) + expect(JSON.stringify(intent)).not.toContain("local:existing") + + const result = SessionUserMutation.mediate({ + intent, + capability: full, + snapshot: snapshot(), + rootForSession: lineage("root-1", "session-1"), + gate, + provider: hostProvider(), + }) + // Only display-safe operation facts survive; the physical endpoint identity never leaves the host. + const serialized = JSON.stringify(result) + expect(serialized).not.toContain("local:existing") + expect(serialized).not.toContain("panel-sidebar") + expect(serialized).not.toContain("root-1") + expect(result).toMatchObject({ kind: "applied" }) + }) +}) + +describe("session user-mutation panel and session binding", () => { + test("binds the initiating panel and active session snapshot before any storage work begins", () => { + const gate = gateFor({ rootForSession: lineage("root-1", "session-1") }) + const order: string[] = [] + const result = SessionUserMutation.mediate({ + intent: { operation: "create", panel: "sidebar", target: { display: "notes.md" }, idempotencyKey: "op-bind" }, + capability: full, + snapshot: snapshot(), + rootForSession: lineage("root-1", "session-1"), + gate, + provider: { + capabilities: { safeResolve: true, noFollowWrite: true }, + resolveTarget: (display) => { + order.push(`resolve:${display}`) + return { value: `local:existing:1:${display}`, kind: "file" } + }, + safeResolve: (endpoint) => endpoint, + execute: (resource) => { + order.push(`write:${resource.id}`) + return "applied" + }, + }, + }) + expect(result).toMatchObject({ kind: "applied", panel: "sidebar" }) + // Identity is resolved (bound) before the write is executed. + expect(order).toEqual(["resolve:notes.md", "write:target"]) + + // A snapshot whose session is not the active lineage root is refused, never mis-attributed. + expect( + SessionUserMutation.mediate({ + intent: { operation: "create", panel: "sidebar", target: { display: "notes.md" }, idempotencyKey: "op-bind-2" }, + capability: full, + snapshot: snapshot({ rootID: "root-mismatch" }), + rootForSession: lineage("root-1", "session-1"), + gate, + provider: hostProvider(), + }), + ).toEqual({ kind: "denied", reason: "context_unavailable" }) + }) +}) + +describe("session user-mutation sidebar operations", () => { + test("create, move, trash, restore, directory, and recursive operations run through a registered group context", () => { + const gate = gateFor({ rootForSession: lineage("root-1", "session-1") }) + const provider = hostProvider() + + const create = SessionUserMutation.mediate({ + intent: { operation: "create", panel: "sidebar", target: { display: "notes.md" }, idempotencyKey: "op-create" }, + capability: full, + snapshot: snapshot(), + rootForSession: lineage("root-1", "session-1"), + gate, + provider, + }) + expect(create).toMatchObject({ kind: "applied", resources: [{ operation: "write", role: "target", outcome: "applied" }] }) + + const move = SessionUserMutation.mediate({ + intent: { + operation: "move", + panel: "sidebar", + target: { display: "notes.md" }, + destination: { display: "archive/notes.md" }, + idempotencyKey: "op-move", + }, + capability: full, + snapshot: snapshot(), + rootForSession: lineage("root-1", "session-1"), + gate, + provider, + }) + expect(move).toMatchObject({ + kind: "applied", + resources: [ + { operation: "move", role: "source", outcome: "applied" }, + { operation: "move", role: "destination", outcome: "applied" }, + ], + }) + + for (const operation of ["trash", "restore"] as const) { + const result = SessionUserMutation.mediate({ + intent: { + operation, + panel: "sidebar", + target: { display: "notes.md" }, + destination: { display: `${operation}/notes.md` }, + idempotencyKey: `op-${operation}`, + }, + capability: full, + snapshot: snapshot(), + rootForSession: lineage("root-1", "session-1"), + gate, + provider, + }) + expect(result).toMatchObject({ kind: "applied" }) + } + + const directory = SessionUserMutation.mediate({ + intent: { operation: "directory", panel: "sidebar", target: { display: "new-folder/" }, idempotencyKey: "op-dir" }, + capability: full, + snapshot: snapshot(), + rootForSession: lineage("root-1", "session-1"), + gate, + provider, + }) + expect(directory).toMatchObject({ + kind: "applied", + resources: [{ operation: "create_parent", role: "implicit_parent", outcome: "applied" }], + }) + + const recursive = SessionUserMutation.mediate({ + intent: { + operation: "recursive_delete", + panel: "sidebar", + targets: [{ display: "dir/a.md" }, { display: "dir/b.md" }], + idempotencyKey: "op-recursive", + recursive: { maxResources: 8 }, + }, + capability: full, + snapshot: snapshot(), + rootForSession: lineage("root-1", "session-1"), + gate, + provider, + }) + expect(recursive).toMatchObject({ + kind: "applied", + resources: [ + { operation: "delete", role: "target", outcome: "applied" }, + { operation: "delete", role: "target", outcome: "applied" }, + ], + }) + }) + + test("a recursive operation over its resource budget fails closed before mutation instead of silently omitting descendants", () => { + const gate = gateFor({ rootForSession: lineage("root-1", "session-1") }) + let writes = 0 + const result = SessionUserMutation.mediate({ + intent: { + operation: "recursive_delete", + panel: "sidebar", + targets: [{ display: "dir/a.md" }, { display: "dir/b.md" }], + idempotencyKey: "op-over-budget", + recursive: { maxResources: 1 }, + }, + capability: full, + snapshot: snapshot(), + rootForSession: lineage("root-1", "session-1"), + gate, + provider: hostProvider(() => writes++), + }) + expect(result).toEqual({ kind: "denied", reason: "resource_budget_exceeded" }) + expect(writes).toBe(0) + }) +}) + +describe("session user-mutation preview and review editing", () => { + test("preview and files-changed editing cannot bypass host-mediated context validation", () => { + const gate = gateFor({ rootForSession: lineage("root-1", "session-1") }) + for (const panel of ["preview", "review"] as const) { + const provider = hostProvider() + const applied = SessionUserMutation.mediate({ + intent: { operation: "edit", panel, target: { display: "src/app.ts" }, idempotencyKey: `op-edit-${panel}` }, + capability: full, + snapshot: snapshot({ panelID: `panel-${panel}` }), + rootForSession: lineage("root-1", "session-1"), + gate, + provider, + }) + expect(applied).toMatchObject({ kind: "applied", panel, resources: [{ operation: "edit", role: "target" }] }) + + // An editing route whose target cannot be resolved to a physical identity denies before mutation. + let writes = 0 + const denied = SessionUserMutation.mediate({ + intent: { operation: "edit", panel, target: { display: "src/ghost.ts" }, idempotencyKey: `op-edit-ghost-${panel}` }, + capability: full, + snapshot: snapshot({ panelID: `panel-${panel}` }), + rootForSession: lineage("root-1", "session-1"), + gate, + provider: { + capabilities: { safeResolve: true, noFollowWrite: true }, + resolveTarget: () => undefined, + safeResolve: (endpoint) => endpoint, + execute: () => { + writes++ + return "applied" + }, + }, + }) + expect(denied).toEqual({ kind: "denied", reason: "unresolved_target" }) + expect(writes).toBe(0) + } + }) +}) + +describe("session user-mutation capability gating", () => { + test("legacy preserves existing behavior with a visible label and creates no ledger context", () => { + const gate = gateFor({ rootForSession: lineage("root-1", "session-1") }) + let writes = 0 + const result = SessionUserMutation.mediate({ + intent: { operation: "create", panel: "sidebar", target: { display: "notes.md" }, idempotencyKey: "op-legacy" }, + capability: { mode: "legacy", label: "legacy" }, + snapshot: snapshot(), + rootForSession: lineage("root-1", "session-1"), + gate, + provider: hostProvider(() => writes++), + }) + expect(result).toEqual({ kind: "legacy", label: "legacy" }) + // Legacy display selection performs no ledger-mediated storage work here. + expect(writes).toBe(0) + }) + + test("a partial epoch mediates through the ledger but stamps the labelled post-upgrade epoch", () => { + const gate = gateFor({ rootForSession: lineage("root-1", "session-1") }) + const result = SessionUserMutation.mediate({ + intent: { operation: "create", panel: "sidebar", target: { display: "notes.md" }, idempotencyKey: "op-partial" }, + capability: { mode: "partial", version: 1, label: "post_upgrade_partial" }, + snapshot: snapshot(), + rootForSession: lineage("root-1", "session-1"), + gate, + provider: hostProvider(), + }) + expect(result).toMatchObject({ kind: "applied", epoch: "post_upgrade_partial" }) + }) + + test("an unavailable capability denies without falling back to an untracked mutation", () => { + const gate = gateFor({ rootForSession: lineage("root-1", "session-1") }) + let writes = 0 + const result = SessionUserMutation.mediate({ + intent: { operation: "create", panel: "sidebar", target: { display: "notes.md" }, idempotencyKey: "op-unavailable" }, + capability: { mode: "unavailable" }, + snapshot: snapshot(), + rootForSession: lineage("root-1", "session-1"), + gate, + provider: hostProvider(() => writes++), + }) + expect(result).toEqual({ kind: "denied", reason: "unavailable" }) + expect(writes).toBe(0) + }) +}) + +describe("session user-mutation operation-group contract", () => { + test("an invalid mutation context denies before mutation instead of an untracked write", () => { + // Full mode, but the active lineage no longer maps the session to the snapshot root. + const gate = gateFor({ rootForSession: () => "root-live" }) + let writes = 0 + const result = SessionUserMutation.mediate({ + intent: { operation: "create", panel: "sidebar", target: { display: "notes.md" }, idempotencyKey: "op-invalid" }, + capability: full, + snapshot: snapshot({ rootID: "root-stale" }), + rootForSession: () => "root-live", + gate, + provider: hostProvider(() => writes++), + }) + expect(result).toEqual({ kind: "denied", reason: "context_unavailable" }) + expect(writes).toBe(0) + }) + + test("an exact idempotent retry returns the recorded operation-group result without a second mutation", () => { + const operations = SessionMutation.OperationStore.memory() + const gate = gateFor({ rootForSession: lineage("root-1", "session-1"), operations }) + let writes = 0 + const intent: SessionUserMutation.MutationIntent = { + operation: "create", + panel: "sidebar", + target: { display: "notes.md" }, + idempotencyKey: "op-idempotent", + } + const first = SessionUserMutation.mediate({ + intent, + capability: full, + snapshot: snapshot(), + rootForSession: lineage("root-1", "session-1"), + gate, + provider: hostProvider(() => writes++), + }) + expect(first).toMatchObject({ kind: "applied" }) + expect(writes).toBe(1) + + const retry = SessionUserMutation.mediate({ + intent, + capability: full, + snapshot: snapshot(), + rootForSession: lineage("root-1", "session-1"), + gate, + provider: hostProvider(() => writes++), + }) + expect(retry).toEqual(first) + expect(writes).toBe(1) + }) + + test("a preflight failure applies no resources and an execution failure returns explicit partial outcomes", () => { + const gate = gateFor({ rootForSession: lineage("root-1", "session-1") }) + + // Preflight: the second resource fails safe-resolution before any write -> nothing applied. + let preflightWrites = 0 + const preflight = SessionUserMutation.mediate({ + intent: { + operation: "recursive_delete", + panel: "sidebar", + targets: [{ display: "dir/a.md" }, { display: "dir/b.md" }], + idempotencyKey: "op-preflight", + recursive: { maxResources: 8 }, + }, + capability: full, + snapshot: snapshot(), + rootForSession: lineage("root-1", "session-1"), + gate, + provider: { + capabilities: { safeResolve: true, noFollowWrite: true }, + resolveTarget: (display) => ({ value: `local:existing:1:${display}`, kind: "file" }), + safeResolve: (endpoint) => (endpoint.value.endsWith("dir/b.md") ? undefined : endpoint), + execute: () => { + preflightWrites++ + return "applied" + }, + }, + }) + expect(preflight).toMatchObject({ kind: "failed" }) + expect(preflight).toMatchObject({ resources: [{ outcome: "not_started" }, { outcome: "not_started" }] }) + expect(preflightWrites).toBe(0) + + // Execution: the first write applies, the second fails -> explicit partial with truthful per-resource outcomes. + const partial = SessionUserMutation.mediate({ + intent: { + operation: "recursive_delete", + panel: "sidebar", + targets: [{ display: "dir/a.md" }, { display: "dir/b.md" }], + idempotencyKey: "op-partial-exec", + recursive: { maxResources: 8 }, + }, + capability: full, + snapshot: snapshot(), + rootForSession: lineage("root-1", "session-1"), + gate, + provider: { + capabilities: { safeResolve: true, noFollowWrite: true }, + resolveTarget: (display) => ({ value: `local:existing:1:${display}`, kind: "file" }), + safeResolve: (endpoint) => endpoint, + execute: (resource) => (resource.id === "target-1" ? "failed" : "applied"), + }, + }) + expect(partial).toMatchObject({ + kind: "partial", + resources: [ + { role: "target", outcome: "applied" }, + { role: "target", outcome: "failed" }, + ], + }) + }) +}) + +describe("session user-mutation watcher revalidation", () => { + test("watchers revalidate only server-owned receipt references and cannot determine ownership or lifecycle locally", () => { + const known = new Map([ + ["receipt-a", 1], + ["receipt-b", 4], + ]) + // An advanced assessment revision on a known receipt emits invalidation only. + expect(SessionUserMutation.Watcher.revalidate(known, { receiptRef: "receipt-a", assessmentRevision: 2 })).toEqual({ + kind: "invalidate", + receiptRef: "receipt-a", + }) + // A stale or equal revision is ignored — the watcher never rewrites lifecycle. + expect(SessionUserMutation.Watcher.revalidate(known, { receiptRef: "receipt-b", assessmentRevision: 4 })).toEqual({ + kind: "ignore", + }) + // An unknown reference cannot be adopted or attributed locally. + expect(SessionUserMutation.Watcher.revalidate(known, { receiptRef: "unowned", assessmentRevision: 9 })).toEqual({ + kind: "ignore", + }) + // The watcher signal vocabulary is references and revisions only — no ownership, paths, hashes, or evidence. + const signal: SessionUserMutation.Watcher.Signal = { receiptRef: "receipt-a", assessmentRevision: 3 } + expect(Object.keys(signal).sort()).toEqual(["assessmentRevision", "receiptRef"]) + }) +}) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 2bcf05a2a4..c879ec35eb 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -385,6 +385,10 @@ describe("tool.task", () => { expect(result.metadata.sessionId).not.toBe("ses_missing") expect(result.output).toContain(``) expect(seen?.sessionID).toBe(result.metadata.sessionId) + expect(yield* sessions.lineage(result.metadata.sessionId)).toMatchObject({ + rootID: chat.id, + descendants: [{ sessionID: result.metadata.sessionId, parentID: chat.id, edgeKind: "task_spawn" }], + }) }), ) diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 1886c670b7..a238bc4bbc 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -17,6 +17,7 @@ export { Provider } from "./provider" export { Reference } from "./reference" export { Revert } from "./revert" export { Session } from "./session" +export { SessionReceipt } from "./session-receipt" export { SessionInput } from "./session-input" export { SessionMessage } from "./session-message" export { Skill } from "./skill" diff --git a/packages/schema/src/provenance-matrix.ts b/packages/schema/src/provenance-matrix.ts new file mode 100644 index 0000000000..1290fa7195 --- /dev/null +++ b/packages/schema/src/provenance-matrix.ts @@ -0,0 +1,533 @@ +export * as ProvenanceMatrix from "./provenance-matrix" + +import { type Boundary, type Field } from "./session-receipt" + +/** + * The adversarial full-provenance matrix (amicode#1084) — the release GATE. + * + * This is the versioned manifest of case IDs (the deliberation resolution): each + * row declares its fixture, setup, trigger, expected authorization decision, + * expected receipts/assessments, permitted display-safe fields, prohibited egress + * fields, cleanup, and required mode. A case is COVERED only when its row runs and + * produces its declared observable result — the E2E fixtures in the opencode + * (engine) and app (ui) harnesses run each row against the REAL session-lineage, + * mutation, receipt, privacy, and view-model contracts. + * + * The gate ({@link gate}) consumes the manifest and a set of green case IDs and + * emits `all_required_passed` ONLY when every required case is green. Product-denied + * outcomes are asserted expected results (their rows carry + * `expectedAuthorization: "denied"`), never failed tests. The gate NEVER flips + * default enablement — that flip is a human-only release act, so + * `defaultEnablement` is always `"off"` (AC8). This module composes with the #1083 + * `SessionRollout.Release` evaluator: {@link requiredCaseIDs} is the mandatory + * matrix-case set that evaluator checks. + * + * This module is pure data + logic. It lives in `@opencode-ai/schema` — the one + * package BOTH the opencode engine and the app frontend depend on — so a single + * versioned manifest is the shared source of truth across every harness. + */ + +/** The matrix version. Bump on any row-schema or required-set change. */ +export const VERSION = 1 + +// ── Enumerated dimensions (the AC coverage axes) ──────────────────────────── + +/** Mutation origins (AC1). `opaque` = a shell / mcp / custom-tool / cli action with no ledger identity. */ +export const Origins = ["agent", "child_agent", "user", "system", "opaque"] as const +export type Origin = (typeof Origins)[number] + +/** Lineage modes (AC1 / AC6). */ +export const Modes = ["full", "partial", "legacy"] as const +export type Mode = (typeof Modes)[number] + +/** Filesystem resource classes (AC2). */ +export const ResourceClasses = [ + "workspace", + "non_git_external", + "external_repo", + "internal", + "binary", + "artifact", + "directory", + "trash", + "restore", + "symlink", + "alias", + "unsupported_provider", +] as const +export type ResourceClass = (typeof ResourceClasses)[number] + +/** Observable mutation outcomes (AC3). */ +export const Outcomes = [ + "success", + "denied", + "failed", + "aborted", + "partial", + "conflict", + "unavailable", + "opaque", + "quota", + "expiry", +] as const +export type Outcome = (typeof Outcomes)[number] + +/** + * The egress boundaries that must never carry capability, evidence, or protected + * metadata (AC5). A subset of the receipt {@link Boundary} union — `files_changed`, + * `session_metadata`, and the authenticated `external_detail` gate are excluded + * because they are not the disallowed-egress surfaces this AC guards. + */ +export const EgressBoundaries = ["browser", "transcript", "share", "export", "telemetry", "log", "error"] as const +export type EgressBoundary = (typeof EgressBoundaries)[number] & Boundary + +/** Aggregation / version concerns (AC6). */ +export const VersionConcerns = [ + "task_aggregation", + "spawn_aggregation", + "fork_separation", + "child_archival", + "child_deletion", + "legacy_session", + "upgrade_epoch", + "mixed_binaries", + "rollback", +] as const +export type VersionConcern = (typeof VersionConcerns)[number] + +/** The AC group a row belongs to. */ +export const Dimensions = [ + "origin", + "resource", + "outcome", + "concurrency", + "privacy", + "version", + "accessibility", +] as const +export type Dimension = (typeof Dimensions)[number] + +/** Which harness process runs the row. `engine` = opencode session contracts; `ui` = the app view model. */ +export type Harness = "engine" | "ui" + +/** The expected authorization decision a row declares. `denied` is an expected result, not a failure. */ +export type Authorization = "authorized" | "denied" | "not_applicable" + +/** One prohibited egress: a receipt field that must NOT cross a boundary (AC5). */ +export type ProhibitedEgress = { field: Field; boundary: EgressBoundary } + +/** One matrix row — the full case declaration (deliberation resolution). */ +export type Row = { + /** Stable case ID (the manifest is a manifest of these). */ + id: string + /** Which AC group. */ + dimension: Dimension + /** Which harness runs the row. */ + harness: Harness + /** Required for release readiness. A required case must be green for `all_required_passed`. */ + required: boolean + /** The lineage mode the case runs in (AC1 / AC6). */ + requiredMode: Mode + /** The mutation origin exercised (origin rows). */ + origin?: Origin + /** The filesystem resource class exercised (resource rows). */ + resourceClass?: ResourceClass + /** The declared observable outcome (outcome rows). */ + expectedOutcome?: Outcome + /** The aggregation / version concern (version rows). */ + concern?: VersionConcern + /** The expected authorization decision. */ + expectedAuthorization: Authorization + /** The isolated fixture — a temp root, sandboxed dir, contained symlink, disposable trash, or in-memory projection. Never a real user directory. */ + fixture: string + /** Setup steps performed before the trigger. */ + setup: string + /** The trigger that produces the observable result. */ + trigger: string + /** The declared observable result — the receipts and assessments the case must produce. */ + expectation: string + /** Fields the case may surface on the display-safe (Files Changed / browser) projection. */ + permittedDisplaySafeFields: readonly Field[] + /** Fields that must NOT cross the named egress boundary (AC5). */ + prohibitedEgress: readonly ProhibitedEgress[] + /** Cleanup + post-run containment assertion. */ + cleanup: string +} + +export type Manifest = { version: number; cases: readonly Row[] } + +// ── Field groupings used by the display/egress declarations ───────────────── + +/** The full display-safe set the Files Changed / browser projection may surface. */ +const DISPLAY_SAFE_FIELDS: readonly Field[] = [ + "operation.origin", + "operation.state", + "receipt.id", + "receipt.sequence", + "receipt.resource", + "receipt.operation", + "receipt.outcome", + "receipt.timeCreated", + "assessment.receiptID", + "assessment.confidence", + "assessment.netState", + "assessment.evidenceState", + "assessment.revision", + "assessment.expiresAt", + "assessment.timeCreated", + "evidence.receiptID", + "derived.additions", + "derived.deletions", +] + +/** Capability + evidence + protected metadata — must never cross an egress boundary (AC5). */ +const PROTECTED_FIELDS: readonly Field[] = [ + "context.capability", // capability + "evidence.content", // evidence bytes + "operation.id", // protected metadata + "operation.rootID", + "operation.sessionID", + "assessment.id", + "context.canonicalPath", + "context.rawHash", + "context.baseline", + "context.redactionDecision", +] + +// ── The manifest rows, by dimension ───────────────────────────────────────── + +const originRows: Row[] = Origins.flatMap((origin) => + Modes.map((mode): Row => { + const opaque = origin === "opaque" + return { + id: `origin:${origin}:${mode}`, + dimension: "origin", + harness: "engine", + required: true, + requiredMode: mode, + origin, + // An opaque origin never authorizes a ledger route; a legacy-mode display shows the pre-ledger view. + expectedAuthorization: opaque ? "not_applicable" : "authorized", + fixture: `In-memory mutation context + host receipt for a ${origin} origin in ${mode} mode (isolated, no filesystem).`, + setup: `Issue a mutation context for origin=${origin} against a ${mode}-mode root; build the host receipt with that origin.`, + trigger: + opaque + ? `Execute the opaque route for the ${origin} origin.` + : `Execute the ledger route and project the receipt at the capability-selected ${mode} view.`, + expectation: + mode === "legacy" + ? `The ${origin} origin resolves to the pre-ledger (legacy) projection — no full-provenance claim.` + : opaque + ? `The ${origin} action yields an unknown-mutation receipt (no fabricated resource), attributed to origin=${origin}.` + : `A ledger receipt carries origin=${origin} on the display-safe projection and is attributed to the active root.`, + permittedDisplaySafeFields: mode === "legacy" ? [] : DISPLAY_SAFE_FIELDS, + prohibitedEgress: [], + cleanup: "Contexts and receipts are in-memory; the operation store is discarded with the test scope.", + } + }), +) + +const RESOURCE_META: Record = { + workspace: { authorization: "authorized", outcome: "success", expectation: "A workspace file resolves to a stable physical identity and records a ledger receipt." }, + non_git_external: { authorization: "authorized", outcome: "success", expectation: "A sandboxed non-Git external file is assessed via host-local external-diff; the patch never crosses." }, + external_repo: { authorization: "authorized", outcome: "success", expectation: "A file in a separate sandboxed Git repo resolves to its own identity and is assessed independently." }, + internal: { authorization: "not_applicable", outcome: "success", expectation: "An internal (out-of-scope) store is never a ledger route — no session receipt is produced." }, + binary: { authorization: "authorized", outcome: "success", expectation: "A binary file records a metadata-only receipt; its bytes stay host-local and never reach display." }, + artifact: { authorization: "authorized", outcome: "success", expectation: "A generated artifact records a ledger receipt; the artifact bytes are host-local evidence only." }, + directory: { authorization: "authorized", outcome: "success", expectation: "A directory endpoint resolves with kind=directory and records an implicit-parent receipt." }, + trash: { authorization: "authorized", outcome: "success", expectation: "A move-to-trash records a delete receipt; the disposable trash fixture holds the removed file." }, + restore: { authorization: "authorized", outcome: "success", expectation: "A restore-from-trash records a revert/restore receipt as the file reappears at its identity." }, + symlink: { authorization: "authorized", outcome: "success", expectation: "A symlink binds the SAME physical identity as its target — the path is discarded before the context." }, + alias: { authorization: "authorized", outcome: "success", expectation: "A hardlink alias binds the SAME identity as its target — aliases and symlinks resolve to one resource." }, + unsupported_provider: { authorization: "denied", outcome: "denied", expectation: "A provider lacking safe-resolve / no-follow-write is DENIED (unsafe_provider) — an expected product-denied result." }, +} + +const resourceRows: Row[] = ResourceClasses.map((cls): Row => { + const meta = RESOURCE_META[cls] + return { + id: `resource:${cls}`, + dimension: "resource", + harness: "engine", + required: true, + requiredMode: "full", + resourceClass: cls, + expectedOutcome: meta.outcome, + expectedAuthorization: meta.authorization, + fixture: `Isolated temp root / sandboxed external dir for a ${cls} resource (contained symlink/alias, disposable trash — never a real user directory).`, + setup: `Create the ${cls} fixture under an isolated temporary root and register its baseline.`, + trigger: `Resolve/assess the ${cls} resource through the mutation-identity and external-diff contracts.`, + expectation: meta.expectation, + permittedDisplaySafeFields: cls === "internal" ? [] : ["receipt.resource", "receipt.operation", "receipt.outcome"], + prohibitedEgress: [], + cleanup: "Remove the temp root; assert containment (every touched path stayed under the isolated fixture).", + } +}) + +const OUTCOME_META: Record = { + success: { authorization: "authorized", expectation: "The mutation applies and records an applied receipt." }, + denied: { authorization: "denied", expectation: "A missing/invalid context is DENIED — an expected product-denied result, not a failure." }, + failed: { authorization: "authorized", expectation: "An identity mismatch during a group execute yields a failed group with not-started receipts." }, + aborted: { authorization: "not_applicable", expectation: "An aborted reservation releases root capacity and commits no receipt." }, + partial: { authorization: "authorized", expectation: "A group whose first resource applies and second fails yields a partial group." }, + conflict: { authorization: "denied", expectation: "A source identity that changed between issue and execute is DENIED (identity_changed) — a conflict." }, + unavailable: { authorization: "authorized", expectation: "A missing evidence sidecar reassesses the receipt as evidence unavailable, without a patch payload." }, + opaque: { authorization: "not_applicable", expectation: "An opaque action with no resources yields an unknown-mutation receipt." }, + quota: { authorization: "denied", expectation: "A recursive group over its resource budget is DENIED (resource_budget_exceeded) — a quota result." }, + expiry: { authorization: "denied", expectation: "An expired context is DENIED (expired_context) — an expiry result." }, +} + +const outcomeRows: Row[] = Outcomes.map((outcome): Row => { + const meta = OUTCOME_META[outcome] + return { + id: `outcome:${outcome}`, + dimension: "outcome", + harness: "engine", + required: true, + requiredMode: "full", + expectedOutcome: outcome, + expectedAuthorization: meta.authorization, + fixture: "In-memory mutation gate + isolated temp/database fixture; product-denied outcomes are expected results.", + setup: `Drive the mutation/receipt contract into the ${outcome} condition.`, + trigger: `Execute the operation that produces the ${outcome} outcome.`, + expectation: meta.expectation, + permittedDisplaySafeFields: ["receipt.outcome", "assessment.netState", "assessment.evidenceState"], + prohibitedEgress: [], + cleanup: "In-memory contexts discarded; database fixture torn down with the test scope.", + } +}) + +const concurrencyRows: Row[] = [ + { + id: "concurrency:non-attribution", + dimension: "concurrency", + harness: "engine", + required: true, + requiredMode: "full", + expectedAuthorization: "authorized", + fixture: "Two isolated lineage roots writing concurrently in an isolated database fixture.", + setup: "Create two independent roots; publish receipts to each concurrently.", + trigger: "Query the active root's committed receipts while an unrelated root is also writing.", + expectation: "The active root's committed receipts contain ONLY its own operations — an unrelated writer is never attributed to it.", + permittedDisplaySafeFields: ["operation.origin", "receipt.resource"], + prohibitedEgress: [], + cleanup: "Remove both roots; assert each root's receipt set is disjoint.", + }, + { + id: "concurrency:cross-root-context-denied", + dimension: "concurrency", + harness: "engine", + required: true, + requiredMode: "full", + expectedAuthorization: "denied", + fixture: "In-memory mutation gate with a root-scoped operation store.", + setup: "Issue a context for root A; attempt to execute it while the session maps to root B.", + trigger: "Execute a mutation whose session no longer resolves to the context's root.", + expectation: "The cross-root execution is DENIED (invalid_context) — a context is bound to one root and never leaks across.", + permittedDisplaySafeFields: [], + prohibitedEgress: [], + cleanup: "In-memory contexts discarded with the test scope.", + }, +] + +const privacyRows: Row[] = EgressBoundaries.map((boundary): Row => ({ + id: `privacy:${boundary}`, + dimension: "privacy", + harness: "engine", + required: true, + requiredMode: "full", + expectedAuthorization: "not_applicable", + fixture: "In-memory host receipt carrying capability, evidence bytes, and protected metadata sentinels.", + setup: "Build a host receipt with capability/evidence/protected-metadata sentinels, then project it at the boundary.", + trigger: `Serialize the receipt at the ${boundary} egress boundary via the privacy projector.`, + expectation: `The ${boundary} projection contains NO capability, evidence, or protected-metadata field — only display-safe / redacted values.`, + permittedDisplaySafeFields: boundary === "browser" ? DISPLAY_SAFE_FIELDS : [], + prohibitedEgress: PROTECTED_FIELDS.map((field) => ({ field, boundary })), + cleanup: "In-memory receipt discarded; assert the serialized output contains none of the protected sentinels.", +})) + +const VERSION_META: Record = { + task_aggregation: { mode: "full", expectation: "A task_spawn child aggregates under the parent root with a typed task_spawn edge." }, + spawn_aggregation: { mode: "full", expectation: "A session_spawn child aggregates under the parent root with a typed session_spawn edge." }, + fork_separation: { mode: "full", expectation: "A fork gets an independent root and inherits no lineage descendants." }, + child_archival: { mode: "full", expectation: "An archived child is hidden from the root's active descendants." }, + child_deletion: { mode: "full", expectation: "A deleted child retains only its root-owned origin projection — no private metadata is retained." }, + legacy_session: { mode: "legacy", expectation: "A session with no lineage row resolves to legacy mode with no descendants." }, + upgrade_epoch: { mode: "partial", expectation: "An explicit partial epoch tracks spawns after the boundary; pre-epoch history is never backfilled." }, + mixed_binaries: { mode: "legacy", expectation: "old-engine/new-client and new-engine/old-client both resolve to a visibly-labelled non-full view (never a false full claim)." }, + rollback: { mode: "full", expectation: "Rollback disables full discovery before withdrawing client routes — no uncontextualized full mutation window." }, +} + +const versionRows: Row[] = VersionConcerns.map((concern): Row => { + const meta = VERSION_META[concern] + return { + id: `version:${concern}`, + dimension: "version", + harness: "engine", + required: true, + requiredMode: meta.mode, + concern, + expectedAuthorization: "not_applicable", + fixture: "Isolated lineage database fixture + the deterministic rollout resolver (no release act performed).", + setup: `Arrange the ${concern} lineage/version condition in an isolated fixture.`, + trigger: `Resolve lineage / rollout for the ${concern} concern.`, + expectation: meta.expectation, + permittedDisplaySafeFields: [], + prohibitedEgress: [], + cleanup: "Remove the lineage roots; the rollout resolver is pure and mutates nothing.", + } +}) + +const accessibilityRows: Row[] = [ + { + id: "a11y:keyboard", + dimension: "accessibility", + harness: "ui", + required: true, + requiredMode: "full", + expectedAuthorization: "not_applicable", + fixture: "The pure lineage-ledger view-model reducer (no DOM, no theme dependency).", + setup: "Focus a resource row / expanded content in the unified Files Changed surface.", + trigger: "Keyboard operation: Enter/Space toggles a row; Escape collapses expanded content and refocuses the row.", + expectation: "Every row is operable by keyboard — Enter/Space toggle, Escape collapses; unrelated keys are no-ops.", + permittedDisplaySafeFields: [], + prohibitedEgress: [], + cleanup: "Pure reducer; nothing to clean up.", + }, + { + id: "a11y:themes", + dimension: "accessibility", + harness: "ui", + required: true, + requiredMode: "full", + expectedAuthorization: "not_applicable", + fixture: "The pure status descriptor + capability labels of the unified view-model (theme-independent by construction).", + setup: "Build the status descriptor for every named state and the full/partial/legacy capability labels.", + trigger: "Render legibility check across both supported themes.", + expectation: "Each state is distinguishable by text + icon (never color alone) and the tone is a semantic keyword — legible in BOTH themes.", + permittedDisplaySafeFields: [], + prohibitedEgress: [], + cleanup: "Pure descriptors; nothing to clean up.", + }, +] + +/** The versioned adversarial full-provenance matrix. */ +export const MANIFEST: Manifest = { + version: VERSION, + cases: [ + ...originRows, + ...resourceRows, + ...outcomeRows, + ...concurrencyRows, + ...privacyRows, + ...versionRows, + ...accessibilityRows, + ], +} + +// ── Selectors ──────────────────────────────────────────────────────────────── + +/** The required case IDs — the mandatory matrix-case set the #1083 release evaluator checks. */ +export function requiredCaseIDs(manifest: Manifest = MANIFEST): string[] { + return manifest.cases.filter((row) => row.required).map((row) => row.id) +} + +/** Every case ID in the manifest. */ +export function caseIDs(manifest: Manifest = MANIFEST): string[] { + return manifest.cases.map((row) => row.id) +} + +/** The sub-manifest a harness owns — used to run and gate one harness's boundary. */ +export function subset(harness: Harness, manifest: Manifest = MANIFEST): Manifest { + return { version: manifest.version, cases: manifest.cases.filter((row) => row.harness === harness) } +} + +// ── The gate ───────────────────────────────────────────────────────────────── + +export type GateResult = { + /** True iff every required case in the manifest is in the green set. */ + all_required_passed: boolean + /** Always `"off"` — the matrix reports readiness; flipping enablement is a human-only release act (AC8). */ + defaultEnablement: "off" + /** Required case IDs that are not green (empty when `all_required_passed`). */ + missingRequired: string[] + /** Totals for reporting. */ + total: number + requiredTotal: number + greenTotal: number +} + +/** + * Consume the manifest + the set of case IDs that RAN GREEN, and emit + * `all_required_passed` only when every required case is green. Default enablement + * always stays `off`. A product-denied case is green when it produced its declared + * denial (its row is `expectedAuthorization: "denied"`), so its ID belongs in + * `green` — the gate treats it exactly like any other required green result. + */ +export function gate(manifest: Manifest, green: ReadonlySet): GateResult { + const required = manifest.cases.filter((row) => row.required) + const missingRequired = required.filter((row) => !green.has(row.id)).map((row) => row.id) + return { + all_required_passed: missingRequired.length === 0, + defaultEnablement: "off", + missingRequired, + total: manifest.cases.length, + requiredTotal: required.length, + greenTotal: [...green].length, + } +} + +// ── Fail-closed coverage assertion ────────────────────────────────────────── + +/** + * Fails closed when the manifest does not COVER every required dimension + * (AC1–AC7). Called at module load so a matrix that silently dropped a dimension + * can never ship. Mirrors `SessionReceipt.assertExposureCoverage`. + */ +export function assertCoverage(manifest: Manifest): void { + if (manifest.version <= 0) throw new Error("provenance matrix must be versioned (version > 0)") + + const ids = manifest.cases.map((row) => row.id) + if (ids.some((id) => id.length === 0)) throw new Error("provenance matrix has an empty case id") + if (new Set(ids).size !== ids.length) throw new Error("provenance matrix has duplicate case ids") + + const origins = manifest.cases.filter((row) => row.dimension === "origin") + for (const origin of Origins) + for (const mode of Modes) + if (!origins.some((row) => row.origin === origin && row.requiredMode === mode)) + throw new Error(`provenance matrix missing origin coverage: ${origin} × ${mode}`) + + const resources = manifest.cases.filter((row) => row.dimension === "resource") + for (const cls of ResourceClasses) + if (!resources.some((row) => row.resourceClass === cls)) + throw new Error(`provenance matrix missing resource coverage: ${cls}`) + + const outcomes = manifest.cases.filter((row) => row.dimension === "outcome") + for (const outcome of Outcomes) + if (!outcomes.some((row) => row.expectedOutcome === outcome)) + throw new Error(`provenance matrix missing outcome coverage: ${outcome}`) + + if (!manifest.cases.some((row) => row.dimension === "concurrency")) + throw new Error("provenance matrix missing concurrency coverage") + + const privacy = manifest.cases.filter((row) => row.dimension === "privacy") + for (const boundary of EgressBoundaries) { + const prohibited = privacy.flatMap((row) => row.prohibitedEgress.filter((p) => p.boundary === boundary).map((p) => p.field)) + if (!prohibited.includes("context.capability")) + throw new Error(`provenance matrix missing capability egress guard at ${boundary}`) + if (!prohibited.includes("evidence.content")) + throw new Error(`provenance matrix missing evidence egress guard at ${boundary}`) + if (!prohibited.some((field) => field.startsWith("operation.") || field.startsWith("context.") || field === "assessment.id")) + throw new Error(`provenance matrix missing protected-metadata egress guard at ${boundary}`) + } + + const versions = manifest.cases.filter((row) => row.dimension === "version") + for (const concern of VersionConcerns) + if (!versions.some((row) => row.concern === concern)) + throw new Error(`provenance matrix missing version coverage: ${concern}`) + + const accessibility = manifest.cases.filter((row) => row.dimension === "accessibility") + if (accessibility.length < 2 || !accessibility.every((row) => row.harness === "ui")) + throw new Error("provenance matrix missing keyboard + both-theme accessibility coverage on the ui harness") +} + +assertCoverage(MANIFEST) diff --git a/packages/schema/src/session-receipt.ts b/packages/schema/src/session-receipt.ts new file mode 100644 index 0000000000..3e3f0f25a4 --- /dev/null +++ b/packages/schema/src/session-receipt.ts @@ -0,0 +1,131 @@ +export * as SessionReceipt from "./session-receipt" + +import { Schema } from "effect" + +/** Every serializer that can carry receipt-derived data. */ +export const Boundaries = [ + "files_changed", + "browser", + "transcript", + "session_metadata", + "export", + "share", + "telemetry", + "log", + "error", + "external_detail", +] as const +export const Boundary = Schema.Literals(Boundaries) +export type Boundary = typeof Boundary.Type + +export const Decision = Schema.Literals(["allow", "redact", "deny"]) +export type Decision = typeof Decision.Type + +/** + * This inventory is the source of truth. Adding a host receipt, assessment, + * evidence, context, or derived value requires an egress decision below. + */ +export const Fields = [ + "operation.id", + "operation.rootID", + "operation.sessionID", + "operation.origin", + "operation.state", + "receipt.id", + "receipt.sequence", + "receipt.resource", + "receipt.operation", + "receipt.outcome", + "receipt.timeCreated", + "assessment.id", + "assessment.receiptID", + "assessment.confidence", + "assessment.netState", + "assessment.evidenceState", + "assessment.revision", + "assessment.expiresAt", + "assessment.timeCreated", + "evidence.receiptID", + "evidence.content", + "context.capability", + "context.canonicalPath", + "context.rawHash", + "context.baseline", + "context.redactionDecision", + "derived.patch", + "derived.additions", + "derived.deletions", +] as const +export const Field = Schema.Literals(Fields) +export type Field = (typeof Fields)[number] + +export type Exposure = { readonly [field in Field]: Readonly> } + +const every = (decision: Decision): Readonly> => + Object.fromEntries(Boundaries.map((boundary) => [boundary, decision])) as Readonly> + +const display = (): Readonly> => ({ + ...every("deny"), + files_changed: "allow", + browser: "allow", + external_detail: "allow", +}) + +const redactedEgress = (): Readonly> => ({ + ...display(), + export: "redact", + share: "redact", + telemetry: "redact", + log: "redact", + error: "redact", +}) + +/** + * Browser-safe egress policy for the complete receipt vocabulary. `redact` + * retains a stable marker; `deny` omits a value altogether. + */ +export const Exposure = { + "operation.id": every("deny"), + "operation.rootID": every("deny"), + "operation.sessionID": every("deny"), + "operation.origin": redactedEgress(), + "operation.state": display(), + "receipt.id": display(), + "receipt.sequence": display(), + "receipt.resource": redactedEgress(), + "receipt.operation": display(), + "receipt.outcome": display(), + "receipt.timeCreated": display(), + "assessment.id": every("deny"), + "assessment.receiptID": display(), + "assessment.confidence": display(), + "assessment.netState": display(), + "assessment.evidenceState": display(), + "assessment.revision": display(), + "assessment.expiresAt": display(), + "assessment.timeCreated": display(), + "evidence.receiptID": display(), + "evidence.content": { ...every("deny"), external_detail: "allow" }, + "context.capability": every("deny"), + "context.canonicalPath": every("deny"), + "context.rawHash": every("deny"), + "context.baseline": every("deny"), + "context.redactionDecision": every("deny"), + "derived.patch": { ...every("deny"), external_detail: "allow" }, + "derived.additions": display(), + "derived.deletions": display(), +} as const satisfies Exposure + +/** Fails closed when a field or a serializer boundary lacks an explicit decision. */ +export function assertExposureCoverage(matrix: Partial>>>) { + for (const field of Fields) { + const row = matrix[field] + if (!row) throw new Error(`Missing receipt exposure classification for ${field}`) + for (const boundary of Boundaries) { + if (row[boundary] === undefined) + throw new Error(`Missing receipt exposure classification for ${field} at ${boundary}`) + } + } +} + +assertExposureCoverage(Exposure) diff --git a/packages/schema/test/provenance-matrix.test.ts b/packages/schema/test/provenance-matrix.test.ts new file mode 100644 index 0000000000..4b4bee7a49 --- /dev/null +++ b/packages/schema/test/provenance-matrix.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from "bun:test" +import { ProvenanceMatrix } from "../src/provenance-matrix" + +/** + * The adversarial full-provenance matrix (amicode#1084) is a VERSIONED manifest + * of case IDs, and the release gate that consumes it. These tests prove the + * shared source of truth: the manifest COVERS every required dimension (AC1–AC7), + * is versioned, and the gate emits `all_required_passed` only when every required + * case is green while default enablement stays OFF (AC8). The E2E fixtures in the + * opencode + app harnesses prove each row RUNS and produces its declared result. + */ + +const requiredIDs = () => ProvenanceMatrix.requiredCaseIDs() + +describe("ProvenanceMatrix.MANIFEST — a versioned manifest of case IDs (deliberation)", () => { + test("is versioned", () => { + expect(ProvenanceMatrix.VERSION).toBeGreaterThan(0) + expect(ProvenanceMatrix.MANIFEST.version).toBe(ProvenanceMatrix.VERSION) + }) + + test("every case id is unique and non-empty", () => { + const ids = ProvenanceMatrix.MANIFEST.cases.map((row) => row.id) + expect(ids.every((id) => id.length > 0)).toBe(true) + expect(new Set(ids).size).toBe(ids.length) + }) + + test("every row declares the full case schema (fixture/setup/trigger/expectation/cleanup/mode/authorization)", () => { + for (const row of ProvenanceMatrix.MANIFEST.cases) { + expect(row.fixture.length).toBeGreaterThan(0) + expect(row.setup.length).toBeGreaterThan(0) + expect(row.trigger.length).toBeGreaterThan(0) + expect(row.expectation.length).toBeGreaterThan(0) + expect(row.cleanup.length).toBeGreaterThan(0) + expect(ProvenanceMatrix.Modes).toContain(row.requiredMode) + expect(["authorized", "denied", "not_applicable"]).toContain(row.expectedAuthorization) + } + }) + + test("no fixture is a real user directory — every resource row is an isolated/sandboxed fixture", () => { + for (const row of ProvenanceMatrix.MANIFEST.cases) { + // The constraint: the matrix cannot mutate a real external user directory. + expect(row.fixture.toLowerCase()).toMatch(/isolated|sandbox|temp|contained|disposable|in-memory|projected|synthetic|pure/) + } + }) +}) + +describe("ProvenanceMatrix coverage — the matrix COVERS every required dimension (AC1–AC7)", () => { + const rowsByDimension = (dimension: ProvenanceMatrix.Dimension) => + ProvenanceMatrix.MANIFEST.cases.filter((row) => row.dimension === dimension) + + test("AC1: agent, child-agent, user, system, opaque origins across full, partial, legacy modes", () => { + const origins = rowsByDimension("origin") + for (const origin of ProvenanceMatrix.Origins) + expect(origins.some((row) => row.origin === origin)).toBe(true) + // Every (origin, mode) pair is present — the full cross product. + for (const origin of ProvenanceMatrix.Origins) + for (const mode of ProvenanceMatrix.Modes) + expect(origins.some((row) => row.origin === origin && row.requiredMode === mode)).toBe(true) + }) + + test("AC2: every filesystem resource class is covered", () => { + const resources = rowsByDimension("resource") + for (const cls of ProvenanceMatrix.ResourceClasses) + expect(resources.some((row) => row.resourceClass === cls)).toBe(true) + }) + + test("AC3: every outcome is covered", () => { + const outcomes = rowsByDimension("outcome") + for (const outcome of ProvenanceMatrix.Outcomes) + expect(outcomes.some((row) => row.expectedOutcome === outcome)).toBe(true) + }) + + test("AC4: unrelated concurrent writes are covered", () => { + expect(rowsByDimension("concurrency").length).toBeGreaterThanOrEqual(1) + }) + + test("AC5: capability, evidence, and protected metadata are prohibited at every egress boundary", () => { + const privacy = rowsByDimension("privacy") + for (const boundary of ProvenanceMatrix.EgressBoundaries) + expect(privacy.some((row) => row.prohibitedEgress.some((p) => p.boundary === boundary))).toBe(true) + // Every egress boundary must prohibit a capability field, an evidence field, and a protected-metadata field. + for (const boundary of ProvenanceMatrix.EgressBoundaries) { + const prohibited = privacy.flatMap((row) => row.prohibitedEgress.filter((p) => p.boundary === boundary).map((p) => p.field)) + expect(prohibited).toContain("context.capability") + expect(prohibited).toContain("evidence.content") + expect(prohibited.some((field) => field.startsWith("operation.") || field.startsWith("context.") || field === "assessment.id")).toBe(true) + } + }) + + test("AC6: every task/spawn/fork/archival/deletion/legacy/epoch/mixed-binary/rollback concern is covered", () => { + const versions = rowsByDimension("version") + for (const concern of ProvenanceMatrix.VersionConcerns) + expect(versions.some((row) => row.concern === concern)).toBe(true) + }) + + test("AC7: keyboard operation and both-theme legibility are covered on the ui harness", () => { + const accessibility = rowsByDimension("accessibility") + expect(accessibility.length).toBeGreaterThanOrEqual(2) + expect(accessibility.every((row) => row.harness === "ui")).toBe(true) + expect(accessibility.some((row) => /keyboard/i.test(row.trigger + row.expectation))).toBe(true) + expect(accessibility.some((row) => /theme/i.test(row.trigger + row.expectation))).toBe(true) + }) + + test("assertCoverage fails closed on an incomplete manifest", () => { + const incomplete: ProvenanceMatrix.Manifest = { + version: ProvenanceMatrix.VERSION, + cases: ProvenanceMatrix.MANIFEST.cases.filter((row) => row.origin !== "system"), + } + expect(() => ProvenanceMatrix.assertCoverage(incomplete)).toThrow(/system/i) + }) +}) + +describe("ProvenanceMatrix.gate — all_required_passed only when every required case is green (AC8)", () => { + test("a fully-green required set passes, and default enablement is STILL off", () => { + const green = new Set(requiredIDs()) + const result = ProvenanceMatrix.gate(ProvenanceMatrix.MANIFEST, green) + expect(result.all_required_passed).toBe(true) + expect(result.missingRequired).toEqual([]) + // AC8: the matrix reports readiness — it never flips enablement on (human-only). + expect(result.defaultEnablement).toBe("off") + }) + + test("a single missing required case blocks the gate and names it", () => { + const all = requiredIDs() + const dropped = all[0] + const green = new Set(all.slice(1)) + const result = ProvenanceMatrix.gate(ProvenanceMatrix.MANIFEST, green) + expect(result.all_required_passed).toBe(false) + expect(result.missingRequired).toContain(dropped) + expect(result.defaultEnablement).toBe("off") + }) + + test("an empty green set blocks the gate (a passing happy path cannot substitute for coverage)", () => { + const result = ProvenanceMatrix.gate(ProvenanceMatrix.MANIFEST, new Set()) + expect(result.all_required_passed).toBe(false) + expect(result.defaultEnablement).toBe("off") + }) + + test("a product-denied case that ran green still counts toward all_required_passed (denied is an expected result)", () => { + // Denied-authorization rows are asserted expected results, not failed tests: their ids are in the required set. + const deniedRows = ProvenanceMatrix.MANIFEST.cases.filter((row) => row.expectedAuthorization === "denied" && row.required) + expect(deniedRows.length).toBeGreaterThan(0) + const green = new Set(requiredIDs()) + expect(ProvenanceMatrix.gate(ProvenanceMatrix.MANIFEST, green).all_required_passed).toBe(true) + // Dropping a denied case (as if it had "failed") blocks the gate — the denial itself is the required green result. + green.delete(deniedRows[0].id) + expect(ProvenanceMatrix.gate(ProvenanceMatrix.MANIFEST, green).all_required_passed).toBe(false) + }) + + test("the gate never reports enablement on for any green set", () => { + for (const green of [new Set(), new Set(requiredIDs()), new Set(requiredIDs().slice(2))]) + expect(ProvenanceMatrix.gate(ProvenanceMatrix.MANIFEST, green).defaultEnablement).toBe("off") + }) +}) + +describe("ProvenanceMatrix per-harness selectors — each harness owns a boundary subset", () => { + test("engine and ui subsets partition the manifest", () => { + const engine = ProvenanceMatrix.subset("engine").cases.map((row) => row.id) + const ui = ProvenanceMatrix.subset("ui").cases.map((row) => row.id) + expect(new Set([...engine, ...ui]).size).toBe(ProvenanceMatrix.MANIFEST.cases.length) + expect(engine.some((id) => ui.includes(id))).toBe(false) + expect(engine.length).toBeGreaterThan(0) + expect(ui.length).toBeGreaterThan(0) + }) + + test("the required matrix case IDs are the mandatory set for the #1083 release evaluator", () => { + // Every required matrix id feeds SessionRollout.Release.evaluate as a mandatory matrix case. + expect(requiredIDs().length).toBe(ProvenanceMatrix.MANIFEST.cases.filter((row) => row.required).length) + expect(requiredIDs().length).toBeGreaterThan(0) + }) +}) diff --git a/packages/schema/test/session-receipt.test.ts b/packages/schema/test/session-receipt.test.ts new file mode 100644 index 0000000000..43cba77667 --- /dev/null +++ b/packages/schema/test/session-receipt.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test" +import { SessionReceipt } from "../src/session-receipt" + +describe("session receipt exposure schema", () => { + test("classifies every receipt and evidence field at every serializer boundary", () => { + expect(Object.keys(SessionReceipt.Exposure).sort()).toEqual([...SessionReceipt.Fields].sort()) + + for (const field of SessionReceipt.Fields) { + expect(Object.keys(SessionReceipt.Exposure[field]).sort()).toEqual([...SessionReceipt.Boundaries].sort()) + } + }) + + test("never permits host-only context values through a client or egress serializer", () => { + for (const field of [ + "context.capability", + "context.canonicalPath", + "context.rawHash", + "context.baseline", + "context.redactionDecision", + ] as const) { + for (const boundary of SessionReceipt.Boundaries) { + expect(SessionReceipt.Exposure[field][boundary]).toBe("deny") + } + } + }) + + test("fails closed when a new receipt or evidence field has no exposure decision", () => { + const { "evidence.content": _missing, ...incomplete } = SessionReceipt.Exposure + expect(() => SessionReceipt.assertExposureCoverage(incomplete)).toThrow( + "Missing receipt exposure classification for evidence.content", + ) + }) +})