From eb6c7fa71682003e74f6f1bae45362d805e88802 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sun, 13 Sep 2026 21:57:37 -0400 Subject: [PATCH] feat(session): adopt app and extension user mutation routes Make user-initiated Sidebar, Preview, and Files Changed file operations host-mediated mutation intents. The browser sends a display-safe MutationIntent (operation, user-selected display target, idempotency key); the host resolves resource identity, snapshots the initiating panel and active session before input, obtains a #1077 operation-group context, and returns only display-safe facts. Capability discovery has full/partial/legacy/unavailable outcomes: only legacy selects existing behavior with a visible label; an invalid or unavailable context denies without falling back to an untracked mutation. Watchers revalidate only server-owned receipt references and assessment revisions, emitting invalidation and deriving no ownership, lifecycle, paths, hashes, or evidence. User origins stay distinguishable from agent, child-agent, and system. Reuse the #1077 mutation registry (new user-sidebar-op/user-preview-edit/ user-review-edit ledger routes; version 4) and its operation-group contract (idempotent retry, preflight-no-applied, explicit partials). Does not render the unified UI (#1082) or touch release (#1083). Refs harmoniqs/amicode#1080 --- packages/opencode/src/session/mutation.ts | 8 +- .../opencode/src/session/user-mutation.ts | 265 ++++++++++ .../opencode/test/session/mutation.test.ts | 5 +- .../test/session/user-mutation.test.ts | 500 ++++++++++++++++++ 4 files changed, 776 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/session/user-mutation.ts create mode 100644 packages/opencode/test/session/user-mutation.test.ts diff --git a/packages/opencode/src/session/mutation.ts b/packages/opencode/src/session/mutation.ts index 3d9176a37..e9873134d 100644 --- a/packages/opencode/src/session/mutation.ts +++ b/packages/opencode/src/session/mutation.ts @@ -15,6 +15,9 @@ export namespace SessionMutation { | "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" } @@ -42,6 +45,9 @@ export namespace SessionMutation { { 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" }, @@ -56,7 +62,7 @@ export namespace SessionMutation { ] as const satisfies ReadonlyArray export namespace Registry { - export const version = 3 + export const version = 4 export function manifest() { return { version, routes: [...routes] } diff --git a/packages/opencode/src/session/user-mutation.ts b/packages/opencode/src/session/user-mutation.ts new file mode 100644 index 000000000..b74c38b10 --- /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/test/session/mutation.test.ts b/packages/opencode/test/session/mutation.test.ts index cdd29a1d4..db5fe0111 100644 --- a/packages/opencode/test/session/mutation.test.ts +++ b/packages/opencode/test/session/mutation.test.ts @@ -23,7 +23,7 @@ const mutationGate = (input: { describe("session mutation registry", () => { test("classifies every registered route before storage is available", () => { expect(SessionMutation.Registry.manifest()).toEqual({ - version: 3, + version: 4, routes: [ { id: "local-file-write", kind: "ledger" }, { id: "tool-write", kind: "ledger" }, @@ -33,6 +33,9 @@ describe("session mutation registry", () => { { 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" }, 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 000000000..1ea6b5567 --- /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"]) + }) +})