diff --git a/packages/opencode/src/session/rollout.ts b/packages/opencode/src/session/rollout.ts new file mode 100644 index 000000000..15d43ef91 --- /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/test/session/rollout.test.ts b/packages/opencode/test/session/rollout.test.ts new file mode 100644 index 000000000..4ad7ad4fc --- /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) + }) +})