diff --git a/packages/opencode/src/session/business-record.ts b/packages/opencode/src/session/business-record.ts new file mode 100644 index 000000000..1dc2724ef --- /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/mutation.ts b/packages/opencode/src/session/mutation.ts index 73d14dce2..3d9176a37 100644 --- a/packages/opencode/src/session/mutation.ts +++ b/packages/opencode/src/session/mutation.ts @@ -6,13 +6,32 @@ 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" + id: + | "local-file-write" + | "tool-write" + | "tool-edit" + | "tool-apply-patch" + | "direct-file-write" + | "plugin-problem-record" + | "runner-run-metadata" + | "runner-artifact" kind: "ledger" } - | { id: "shell-action" | "mcp-action" | "custom-tool-action"; kind: "opaque" } - | { id: "ledger-infrastructure"; kind: "out_of_scope" } + | { 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" }, @@ -20,14 +39,24 @@ export namespace SessionMutation { { 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: "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 = 2 + export const version = 3 export function manifest() { return { version, routes: [...routes] } @@ -176,7 +205,7 @@ export namespace SessionMutation { } | { kind: "opaque" - routeID: "shell-action" | "mcp-action" | "custom-tool-action" + routeID: OpaqueRouteID panelID: string sessionID: string rootID: string @@ -190,7 +219,7 @@ export namespace SessionMutation { type Issue = Omit & { kind: "local" | "opaque"; expiresAt: number } type GroupIssue = Omit & { expiresAt: number } type OpaqueRequest = Omit & { - routeID: "shell-action" | "mcp-action" | "custom-tool-action" + routeID: OpaqueRouteID resources?: ReadonlyArray } export function create(input: { 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 000000000..b45dbe60c --- /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 index 1bee4ea51..cdd29a1d4 100644 --- a/packages/opencode/test/session/mutation.test.ts +++ b/packages/opencode/test/session/mutation.test.ts @@ -23,17 +23,27 @@ const mutationGate = (input: { describe("session mutation registry", () => { test("classifies every registered route before storage is available", () => { expect(SessionMutation.Registry.manifest()).toEqual({ - version: 2, + version: 3, 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: "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()