// The feature-facing WorkflowSession seam AND its one deep engine (module-contracts.md's // `session/` home): run identity + verified session-artifact operations + the named // workflow-state reads (`nodeClaim`/`activeObjective`/`activeSessionPlanRef`/`reviewPosts`) and // the closed change union, sized strictly to the callers that exist. No `stage`/`mode`/pointer-map // snapshot — no feature caller consumes them through the seam yet (stage routing and the gate // stay adapter-side; startup's lifecycle facts live in `session/lifecycle.ts`); the seam grows // only from proven callers. // // ONE ENGINE, TWO NARROW PORTS. `openWorkflowSession(deps)` implements every WorkflowChange // invariant, the full artifact discipline (name policy → identity refusal → unchanged // short-circuit → store → read-back digest → pointer construction → merged-map strict append; // the classified read tiers), and all error/problem text exactly once. Backings supply only // mechanics: a `SessionStateStore` (session/lifecycle.ts — the same workflow-state store port // the identity lifecycle uses) and an `ArtifactContentStore` (content I/O only, zero error // prose). The production binding is `branchWorkflowSession.ts` (branch/file); the deterministic // in-memory binding lives in `testing/memoryWorkflowSession.ts` (dev-only, outside the // production corpus). Both are exercised by the shared interface suite in // `workflowSession.test.ts`. // // Identity is OPTIONAL: `runId` is `string | null` and a session always opens — the plan-save // surfaces prove the shape (workflow-state appends are branch-backed and identity-independent: // an identity-less save still links `active_plan_ref`). The ARTIFACT ops classify no-identity as // `rejected` (write) / `absent` (ordinary read) / `invalid` (strict read); the state ops work without // identity. Identity is TRUST-NARROWED: a rebuilt `run_id` that is unsafe as a path component // (`isSafeRunId`) degrades to no-identity before any path derivation — a hostile persisted id // can never steer artifact reads outside the run root or reach a receipt. // // Results carry the session-owned `SessionArtifactReceipt` — validated or re-derived values // ONLY (never the persisted pointer, whose rebuilt fields are unvalidated branch data). The // full `SessionArtifactPointer` wire shape is still constructed here for the // `session_artifacts` strict append (contracts §8.3's persistence format) — derived at the // storage boundary, internal to the engine, never exposed through results. // // `apply(change)` is a CLOSED union admitted from proven callers (never a feature dispatcher); // the first two variants come from the plan-save surfaces, the second two from the objective // flows (`transitionObjectiveNode`'s planning arm records the claim; `saveObjective`'s // post-save linkage sets `active_objective`). No snapshot payloads on the applied/unchanged // arms — nothing consumes them (narrow until proven). import { isSafeRunId, type PlanRef } from "../substrate/cache.ts"; import { digestSessionData, type SessionDataRead } from "../substrate/sessionData.ts"; import { nodeClaimsEqual, planRefsEqual, type SessionArtifactPointer, } from "../substrate/workflowState.ts"; import type { SessionStateStore } from "./lifecycle.ts"; /** Session-owned vocabulary: features import the plan-ref shape through the session seam. */ export type { PlanRef }; /** One digest convention, exposed through the session boundary for bound review consumers. */ export { digestSessionData }; /** A human-readable problem description (the backing has already warned where its tier is loud). */ export type SessionProblem = string; /** The refinement stage id (the session keys its subject mapping + stage-only entry on it). */ const REFINE_STAGE_ID = "objective-refine"; /** * The refinement grounding-context artifact name — session-owned vocabulary because the review * binding fences the refinement subject on this artifact's digest (features import it here). */ export const REFINEMENT_CONTEXT_ARTIFACT = "objective-refinement-context.json"; /** * Session-owned artifact receipt: validated or re-derived values ONLY. `runId` is the * safe-narrowed active run id (matches the persisted pointer's `run_id` by construction); * `path` is re-derived from the content store (fs: repo-relative; memory: the name) — NEVER a * persisted pointer field; `digest` is proven — computed from the bytes read back (applied) or * from the stored bytes during the unchanged probe. */ export interface SessionArtifactReceipt { runId: string; path: string; digest: string; } /** * The artifact content port: content I/O only, mechanical results, zero error prose — * classification, policy, and all problem text are engine-owned. Every operation receives the * ENGINE-VALIDATED run id (the artifact namespace), so storage, pointer, and receipt share one * identity by construction — a backing never derives its own. */ export interface ArtifactContentStore { /** Persist bytes under the run; false = refusal (the port has already warned where its tier is loud). */ store(runId: string, name: string, content: string): boolean; /** The run's current bytes; null = missing/unreadable. */ load(runId: string, name: string): string | null; /** Strict reads distinguish genuine absence from I/O refusal; no fallback to load(). */ loadStrict(runId: string, name: string): SessionDataRead; /** The receipt/warning display path, re-derived from the given identity — NEVER a persisted pointer field. */ displayPath(runId: string, name: string): string; } /** * The classified artifact read. `absent` is the silent, branchable tier (no identity, no * pointer, or a cross-run fork pointer — designed isolation); `invalid` is the loud tier (a * pointer whose file is missing or digest-mismatched — rewind/tamper). Opt-in strict reads also * classify missing identity, malformed provenance, orphan bytes and I/O failure as `invalid`; * they return problems to the authorizing caller rather than emitting ordinary read warnings. */ export type ReadArtifactResult = | { status: "found"; content: string } | { status: "absent" } | { status: "invalid"; problem: SessionProblem }; /** * The classified artifact write — the verified state op: `applied` proves the file AND the * strict-appended `session_artifacts` pointer both landed and read back; `unchanged` is the * byte-identical short-circuit (the recorded pointer already proves these bytes); `rejected` * refused before any effect (including the no-identity refusal — artifacts need a run_id); * `unverified` means an effect may have landed but the read-back proof failed — never * consumable. */ export type WriteArtifactResult = | { status: "applied"; receipt: SessionArtifactReceipt } | { status: "unchanged"; receipt: SessionArtifactReceipt } | { status: "unverified"; problem: SessionProblem } | { status: "rejected"; problem: SessionProblem }; /** * The last automated `/pr-review` outcome (`last_pr_review`, contracts §8.3): exactly the * record the `post_pr_review` post surface constructs on a real success. After a recorded wave, * `angles` is the authoritative attempted manifest and `covered_angles` its schema-valid * subset; standalone posts use the caller's angles for both. */ export interface PrReviewRecord { pr: number; verdict: "clean" | "actionable"; angles: readonly string[]; covered_angles: readonly string[]; comment_count: number | null; mode: string | null; at: string; } /** * The last curated review-door outcome (`last_review`, contracts §8.3): exactly the record the * `submit_pr_review` post surface constructs on a real success. `event` stays `string` in the * record — the decode boundary already constrains it; the stored field is render-only. */ export interface ReviewSubmissionRecord { pr: number; event: string; comment_count: number | null; mode: string | null; at: string; } /** The four known review-classification count keys the finalizer records (§8.3). */ export interface ReviewBatchCounts { actionable?: number; informational?: number; praise?: number; question?: number; } /** * The last finalized review batch (`last_review_batch`, contracts §8.3): exactly the record the * address finalizer constructs after publication and corroborated full thread resolution. */ export interface ReviewBatchRecord { pr: number | null; counts: ReviewBatchCounts | null; resolved_thread_ids: string[]; at: string; } /** One `review_posts` ledger row: a REAL curated submission that reached GitHub (§8.3). */ export interface ReviewPostRow { pr: number; event: string; at: string; } /** * Tolerant re-narrow of a rebuilt `review_posts` list (best-effort tier: a malformed row is * dropped, never a refusal — the ledger only ever grows from the seam's own writes). */ export function reviewPostsOf(raw: unknown): ReviewPostRow[] { if (!Array.isArray(raw)) return []; const rows: ReviewPostRow[] = []; for (const item of raw) { if (typeof item !== "object" || item === null || Array.isArray(item)) continue; const row = item as Record; if (typeof row.pr !== "number" || !Number.isInteger(row.pr)) continue; if (typeof row.event !== "string" || typeof row.at !== "string") continue; rows.push({ pr: row.pr, event: row.event, at: row.at }); } return rows; } /** Ledger equality for the read-back verification (order-sensitive — posting order matters). */ export function reviewPostsEqual(rebuilt: unknown, expected: unknown): boolean { const a = reviewPostsOf(rebuilt); const b = reviewPostsOf(expected); if (a.length !== b.length) return false; return a.every( (row, i) => row.pr === b[i]?.pr && row.event === b[i]?.event && row.at === b[i]?.at, ); } /** * The closed workflow-state change union — admitted variant-by-variant from proven callers * (the proven callers: the plan-save surfaces). Reads stay NAMED (`nodeClaim()`); only changes * ride the union. */ export type WorkflowChange = /** Link the live session to a saved plan: append `active_plan_ref` iff it differs. */ | { kind: "link-plan-ref"; ref: PlanRef } /** * Clear `objective_node_claim` iff the live claim matches BOTH fields (never clobbers an * unrelated claim — a save linked to objective B node 1.1 must not clear objective A's 1.1). */ | { kind: "clear-node-claim"; claim: { objective: string; node: string } } /** * Record `objective_node_claim` iff the live claim differs (`nodeClaimsEqual`) — an * idempotent re-claim short-circuits `unchanged` (the re-append "refresh" carries no * semantic payload: the claim has no timestamp and rebuilds identically). */ | { kind: "record-node-claim"; claim: { objective: string; node: string } } /** Link the live session to a saved objective: append `active_objective` iff it differs. */ | { kind: "link-objective"; objective: string } /** * Enter the refinement stage from a warm session: append `stage: "objective-refine"` iff the * live stage differs — a STAGE-ONLY change (never a claim, plan-ref, objective or mode write; * the warm `/objective-refine` entry sets the stage the cold handoff would have carried). An * idempotent re-entry short-circuits `unchanged`. */ | { kind: "enter-refinement-stage" } /** * Record the last automated `/pr-review` outcome: ONE `last_pr_review` append (LWW), strict * read-back. No pre-read, no dedupe (same runtime invariant as `record-review`: * `applied`/`unverified`/`rejected` only). */ | { kind: "record-pr-review"; record: PrReviewRecord } /** * Record the last curated review-door outcome: ONE `last_review` append (LWW), strict * read-back. No pre-read, no dedupe — the `already_posted` resume guard is feature-op policy * upstream, so at runtime this yields `applied`/`unverified`/`rejected` only (a runtime * invariant, not a type claim). */ | { kind: "record-review"; record: ReviewSubmissionRecord } /** * Append one `review_posts` ledger row: read-rebuild-append of the whole ordered list, with * the order-sensitive `reviewPostsEqual` read-back. The pre-read is FAIL-CLOSED and * STRICT-DECODED (an unrebuildable or malformed persisted ledger refuses the append — see the * engine's arm); no dedupe (same invariant as `record-review`: * `applied`/`unverified`/`rejected` only at runtime). */ | { kind: "append-review-post"; row: ReviewPostRow } /** * Record the last finalized review batch: ONE `last_review_batch` append (LWW), strict * read-back. No pre-read, no dedupe — the corroborated-success-first ordering is feature-op * policy upstream (`applied`/`unverified`/`rejected` only at runtime). */ | { kind: "record-review-batch"; record: ReviewBatchRecord }; /** * The classified change outcome: `applied` proves the append landed and read back; `unchanged` * is the idempotent short-circuit (link: the rebuilt ref already equals; clear: no matching * claim); `unverified` means the append may have landed but the read-back proof failed (the * branch backing has already warned loudly); `rejected` refused before any effect. */ export type WorkflowChangeResult = | { status: "applied" } | { status: "unchanged" } | { status: "unverified"; problem: SessionProblem } | { status: "rejected"; problem: SessionProblem }; /** * The feature-facing session: optional identity + verified artifact ops + the named * workflow-state reads and the closed change union. A session ALWAYS opens; `runId: null` is * the identity-less arm (artifact ops classify it; state ops still work). */ export interface WorkflowSession { readonly runId: string | null; /** Strict live identity only; completion must not revalidate source or routing inputs. */ currentRunIdentity(): | { ok: true; runId: string } | { ok: false; reason: "no-identity" | "invalid-state" }; /** Strict single-snapshot routing read; malformed relevant claims refuse, never disappear. */ draftReviewContext(): | { ok: true; runId: string; subject: "plan" | "objective" | "gist" | "refinement"; warmNodeClaim: { objective: string; node: string } | null; } | { ok: false; reason: "no-identity" | "invalid-state" }; readArtifact(name: string, options?: { provenance: "strict" }): ReadArtifactResult; /** Strict writes refuse broken prior provenance; callers still own exclusion and failure residue. */ writeArtifact( name: string, content: string, options?: { provenance: "strict" }, ): WriteArtifactResult; /** Snapshot read of the rebuilt `objective_node_claim` (malformed ⇒ null). */ nodeClaim(): { objective: string; node: string } | null; /** Snapshot read of the rebuilt `active_objective` (malformed/throwing ⇒ null). */ activeObjective(): string | null; /** * Shape-validated, fail-open read of the LIVE SESSION's rebuilt `active_plan_ref` — session * linkage ONLY, deliberately with no checkout `cache.plan-ref` fallback (the checkout selector * can name a future plan unrelated to this session; the checkout-first read is * `substrate/workflowState.ts::activePlanRef`, a different authority). Absent, malformed, or * unreadable linkage reads null. For continuation rendering, never permission, verified * linkage, artifact validation, or review routing. */ activeSessionPlanRef(): PlanRef | null; /** Fail-open read of the rebuilt `review_posts` ledger (malformed rows dropped, never a refusal). */ reviewPosts(): ReviewPostRow[]; apply(change: WorkflowChange): WorkflowChangeResult; } // ------------------------------------------------------------------------------- the engine /** * Validate a session-artifact name at the seam: non-empty, no path separators (the artifact * name keys the pointer map and joins under the run's data dir — a separator would escape it). * Returns the problem string, or `null` when the name is safe. Name policy is refusal policy — * it lives with the engine, not the backings. */ export function sessionArtifactNameProblem(name: string): string | null { if (name.trim() === "") return "session artifact name is empty"; if (name.includes("/") || name.includes("\\")) { return `session artifact name ${JSON.stringify(name)} carries a path separator`; } return null; } /** * The ONE persisted-pointer decode: accept a rebuilt `session_artifacts` value only when it is * SHAPE-SOUND, and narrow the return to exactly the two fields the engine dereferences — * branch data is unvalidated (`rebuildWorkflowState` trusts entry data), so a malformed session * entry can put `null` — or anything else — where a pointer belongs, and the OTHER persisted * fields (`path`, `name`, `at`) stay untrusted `unknown` even on a sound value (`path` is always * re-derived, `name` is the map key, `at` has no consumer). Anything unsound reads as "no * pointer" and never throws. Tests asserting persisted-pointer facts narrow through this — * never a cast. */ export function soundPointer(candidate: unknown): { run_id: string; digest: string } | null { if (typeof candidate !== "object" || candidate === null) return null; const pointer = candidate as Record; if (typeof pointer.run_id !== "string" || typeof pointer.digest !== "string") return null; return { run_id: pointer.run_id, digest: pointer.digest }; } /** Per-name pointer identity: same run_id + same digest (each side narrowed via the decode). */ function artifactMapsEqual( rebuilt: Record | null | undefined, expected: Record | null | undefined, ): boolean { const a = rebuilt ?? {}; const b = expected ?? {}; const names = Object.keys(b); if (Object.keys(a).length !== names.length) return false; return names.every((name) => { const ra = soundPointer(a[name]); const rb = soundPointer(b[name]); // Unsound on both sides (junk siblings carried forward by the merged-map spread) compares // equal — the append is verified on the pointers it can vouch for, never on junk shape. if (ra === null || rb === null) return ra === rb; return ra.run_id === rb.run_id && ra.digest === rb.digest; }); } /** * Strict `review_posts` decode for the append pre-read — the ONE row schema is `reviewPostsOf`, * consumed strictly: a present-but-malformed persisted ledger (non-array, or any row the * tolerant decode would DROP) refuses with a problem naming the malformed variant instead of * silently narrowing (a tolerant pre-read would let the whole-list LWW re-append ERASE * malformed-but-possibly-real rows, violating the ledger invariant that a confirmed post is * never erased by a write). Extra row fields are narrowed out, never a refusal. */ function strictReviewPosts(raw: unknown): { rows: ReviewPostRow[] } | { malformed: string } { if (!Array.isArray(raw)) return { malformed: `not a list (${JSON.stringify(raw)})` }; const rows = reviewPostsOf(raw); if (rows.length !== raw.length) { return { malformed: `${raw.length - rows.length} malformed row(s) in ${JSON.stringify(raw)}`, }; } return { rows }; } /** The two ports the engine runs over — the backings supply ONLY these. */ export interface WorkflowSessionDeps { state: SessionStateStore; artifacts: ArtifactContentStore; } /** * The safe-narrowed active run id: the rebuilt `run_id`, non-empty AND safe as a path component * (`isSafeRunId` — an unsafe persisted id degrades to no-identity BEFORE any path derivation); * a throwing rebuild degrades to null (no resolvable identity, never a stamp). */ function activeRunId(state: SessionStateStore): string | null { try { const runId = state.rebuild().run_id; if (typeof runId === "string" && isSafeRunId(runId)) return runId; } catch { // a throwing rebuild means no resolvable identity — degrade to null } return null; } /** The rebuilt `objective_node_claim`, read fail-open (malformed/throwing rebuild → null). */ function readClaim(state: SessionStateStore): { objective: string; node: string } | null { try { const claim = state.rebuild().objective_node_claim ?? null; if ( claim !== null && typeof claim.objective === "string" && claim.objective !== "" && typeof claim.node === "string" && claim.node !== "" ) { return claim; } return null; } catch { return null; } } /** The rebuilt `active_objective`, read fail-open (malformed/throwing rebuild ⇒ null). */ function readActiveObjective(state: SessionStateStore): string | null { try { const value = state.rebuild().active_objective ?? null; return typeof value === "string" && value !== "" ? value : null; } catch { return null; } } /** Non-blank string guard for the session plan-ref decode (bytes are preserved, never trimmed). */ const nonblank = (value: unknown): value is string => typeof value === "string" && value.trim() !== ""; /** * The ONE session-only plan-ref decode: the rebuilt `active_plan_ref` inspected as `unknown` * (branch data is unvalidated) and accepted only in its persisted shape — non-blank string * `provider`/`pr_id`/`url`, an all-string `labels` list, a REQUIRED `objective_id` that is null * or a string, and `base` absent, null, or a string. The return is a reconstructed `PlanRef` * carrying exactly those fields (extra persisted keys never escape; `base` omission is preserved * versus an explicit null; no trimming, provider constraint, or URL parse). Fail-open: absent, * malformed, or throwing rebuilds read null — the only consumer renders optional continuation * guidance, so unreadability must degrade to "no plan named", never a thrown handler. One fresh * rebuild per call; no checkout, artifact, append, or memoization. */ function readActiveSessionPlanRef(state: SessionStateStore): PlanRef | null { try { const ref: unknown = state.rebuild().active_plan_ref; if (typeof ref !== "object" || ref === null) return null; const { provider, pr_id, url, labels, objective_id, base } = ref as Record; if (!nonblank(provider) || !nonblank(pr_id) || !nonblank(url)) return null; if (!Array.isArray(labels) || !labels.every((l): l is string => typeof l === "string")) { return null; } if (objective_id !== null && typeof objective_id !== "string") return null; if (base !== undefined && base !== null && typeof base !== "string") return null; return { provider, pr_id, url, labels: [...labels], objective_id, ...(base !== undefined ? { base } : {}), }; } catch { return null; } } /** The rebuilt `review_posts` ledger, read fail-open (malformed rows drop; a throwing rebuild ⇒ []). */ function readReviewPosts(state: SessionStateStore): ReviewPostRow[] { try { return reviewPostsOf(state.rebuild().review_posts); } catch { return []; } } function strictArtifactMap(raw: unknown): Record { if (raw === undefined || raw === null) return {}; if ( typeof raw !== "object" || Array.isArray(raw) || (Object.getPrototypeOf(raw) !== Object.prototype && Object.getPrototypeOf(raw) !== null) ) { throw new Error("session_artifacts map is malformed"); } const entries: [string, SessionArtifactPointer][] = []; for (const [key, value] of Object.entries(raw)) { if ( !isSafeRunId(key) || key.trim() === "" || typeof value !== "object" || value === null || Array.isArray(value) ) throw new Error("session_artifacts pointer is malformed"); const p = value as Record; if ( typeof p.run_id !== "string" || !isSafeRunId(p.run_id) || typeof p.digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(p.digest) || p.name !== key || typeof p.path !== "string" || p.path.length === 0 || typeof p.at !== "string" || p.at.length === 0 ) { throw new Error("session_artifacts pointer is malformed"); } entries.push([key, { run_id: p.run_id, name: key, path: p.path, digest: p.digest, at: p.at }]); } return Object.fromEntries(entries); } function strictArtifactRead(deps: WorkflowSessionDeps, name: string): ReadArtifactResult { const invalid = (problem: string): ReadArtifactResult => ({ status: "invalid", problem }); if (!isSafeRunId(name) || sessionArtifactNameProblem(name) !== null) { return invalid("unsafe session artifact name"); } try { // One snapshot: a throwing/malformed identity read is never evidence of absence. const snapshot = deps.state.rebuild(); if (typeof snapshot !== "object" || snapshot === null || Array.isArray(snapshot)) { return invalid("session artifact state is malformed"); } const runId = snapshot.run_id; if (typeof runId !== "string" || !isSafeRunId(runId)) { return invalid("session has no safe run_id — strict artifact reads need identity"); } // Validate siblings too: the next whole-map append must not launder malformed provenance. const map = strictArtifactMap(snapshot.session_artifacts); const pointer = Object.hasOwn(map, name) ? map[name] : null; // Never dereference inherited pointers. Inspect ONLY the active run's namespace, including // when there is no pointer: bytes without current-run provenance are an orphan, not absent. const loaded = deps.artifacts.loadStrict(runId, name); if (loaded.status === "io-error") return invalid(`session artifact ${name} I/O failure`); if (pointer == null || pointer.run_id !== runId) { return loaded.status === "absent" ? { status: "absent" } : invalid(`session artifact ${name} has no current-run provenance (orphan)`); } if (loaded.status === "absent") return invalid(`session artifact ${name} has a pointer but no file`); if (digestSessionData(loaded.content) !== pointer.digest) { return invalid(`session artifact ${name} digest mismatch (rewound or modified)`); } return { status: "found", content: loaded.content }; } catch { return invalid(`session artifact ${name} strict read failed`); } } /** * Open a session over the two ports — the ONE deep implementation of the seam. ALWAYS opens; * `runId: null` is the identity-less arm (artifact writes reject, reads read absent; the state * ops are store-backed and identity-independent). `runId` is captured at open and re-derived * per artifact call (a fork entry appended mid-session re-keys the artifact ops — today's * behavior). Loudness: the strict-append port reports its own read-back failures (the * classified-append seam's report() channel inside the production binding); the engine warns on * stderr for the loud read tier (rewind/tamper) and the unreadable-after-write arm. */ export function openWorkflowSession(deps: WorkflowSessionDeps): WorkflowSession { const { state, artifacts } = deps; return { runId: activeRunId(state), currentRunIdentity() { try { const snapshot = state.rebuild(); if (typeof snapshot !== "object" || snapshot === null || Array.isArray(snapshot)) return { ok: false, reason: "invalid-state" }; const runId = snapshot.run_id; return typeof runId === "string" && isSafeRunId(runId) ? { ok: true, runId } : { ok: false, reason: "no-identity" }; } catch { return { ok: false, reason: "invalid-state" }; } }, draftReviewContext() { try { const snapshot = state.rebuild(); const runId = snapshot.run_id; if (typeof runId !== "string" || !isSafeRunId(runId)) return { ok: false, reason: "no-identity" }; const stage = snapshot.stage; if (stage != null && (typeof stage !== "string" || !stage.trim())) return { ok: false, reason: "invalid-state" }; const subject = stage === "objective-author" || stage === "objective-save" ? "objective" : stage === "gist-author" ? "gist" : stage === REFINE_STAGE_ID ? "refinement" : "plan"; const raw: unknown = subject === "plan" ? snapshot.objective_node_claim : null; let warmNodeClaim: { objective: string; node: string } | null = null; if (raw != null) { if (typeof raw !== "object" || Array.isArray(raw)) return { ok: false, reason: "invalid-state" }; const claim = raw as Record; if ( Object.keys(claim).length !== 2 || typeof claim.objective !== "string" || !claim.objective.trim() || typeof claim.node !== "string" || !claim.node.trim() ) return { ok: false, reason: "invalid-state" }; warmNodeClaim = { objective: claim.objective, node: claim.node }; } return { ok: true, runId, subject, warmNodeClaim }; } catch { return { ok: false, reason: "invalid-state" }; } }, readArtifact(name: string, options): ReadArtifactResult { if (options?.provenance === "strict") return strictArtifactRead(deps, name); const runId = activeRunId(state); if (runId === null) return { status: "absent" }; // no identity — silent, branchable let pointer: { run_id: string; digest: string } | null; try { pointer = soundPointer(state.rebuild().session_artifacts?.[name]); } catch { return { status: "absent" }; } if (pointer === null) return { status: "absent" }; // no pointer — or a malformed one (no provenance) if (pointer.run_id !== runId) return { status: "absent" }; // fork isolation — by design, silent const content = artifacts.load(runId, name); if (content === null) { console.error( `perk: warning: session artifact ${name} has a pointer but no file at ` + artifacts.displayPath(runId, name), ); return { status: "invalid", problem: `session artifact ${name} has a pointer but no file` }; } if (digestSessionData(content) !== pointer.digest) { console.error( `perk: warning: session artifact ${artifacts.displayPath(runId, name)} digest mismatch ` + "(rewound or modified) — refusing", ); return { status: "invalid", problem: `session artifact ${name} digest mismatch (rewound or modified)`, }; } return { status: "found", content }; }, writeArtifact(name: string, content: string, options): WriteArtifactResult { const strict = options?.provenance === "strict"; if (strict) { const prior = strictArtifactRead(deps, name); if (prior.status === "invalid") return { status: "rejected", problem: prior.problem }; } let effectAttempted = false; try { const nameProblem = sessionArtifactNameProblem(name); if (nameProblem !== null) return { status: "rejected", problem: nameProblem }; const runId = activeRunId(state); if (runId === null) { return { status: "rejected", problem: "session has no run_id — session artifacts need identity", }; } // The unchanged short-circuit: a byte-identical rewrite is a no-op (no store, no fresh // pointer entry) — the recorded pointer already proves exactly these bytes. QUIET by // design: a stale/broken/malformed pointer simply fails the probe and the write proceeds // (the probe must never emit the read tier's rewind warnings). The receipt is fully // re-derived — junk persisted fields are unobservable. let current: { run_id: string; digest: string } | null; try { const snapshot = state.rebuild(); if (strict && snapshot.run_id !== runId) throw new Error("session identity changed"); current = strict ? (strictArtifactMap(snapshot.session_artifacts)[name] ?? null) : soundPointer(snapshot.session_artifacts?.[name]); } catch (error) { if (strict) throw error; current = null; } if (current !== null && current.run_id === runId) { const checked = strict ? strictArtifactRead(deps, name) : null; if (checked !== null && checked.status !== "found") { return { status: "rejected", problem: `session artifact ${name} provenance changed before write`, }; } const stored = checked?.status === "found" ? checked.content : artifacts.load(runId, name); if ( stored !== null && digestSessionData(stored) === current.digest && current.digest === digestSessionData(content) ) { return { status: "unchanged", receipt: { runId, path: artifacts.displayPath(runId, name), digest: digestSessionData(stored), }, }; } } effectAttempted = true; if (!artifacts.store(runId, name, content)) { // the port already warned; never point at an unwritten file return { status: "rejected", problem: `could not write session data ${name} (see warnings)`, }; } // Digest the bytes as read back from the store — catches encoding/disk surprises. const strictReadBack = strict ? artifacts.loadStrict(runId, name) : null; const readBack = strictReadBack === null ? artifacts.load(runId, name) : strictReadBack.status === "found" ? strictReadBack.content : null; if (readBack === null) { const problem = `session artifact ${artifacts.displayPath(runId, name)} unreadable after write`; console.error(`perk: warning: ${problem}`); return { status: "unverified", problem }; } if (strict && readBack !== content) { return { status: "unverified", problem: `session artifact ${name} write read-back differs`, }; } // The persisted wire shape (contracts §8.3) — constructed at the storage boundary, // internal to the engine, never exposed through results. const pointer: SessionArtifactPointer = { run_id: runId, name, path: artifacts.displayPath(runId, name), digest: digestSessionData(readBack), at: new Date().toISOString(), }; // Per-field LWW: each append must carry the WHOLE merged map so sibling artifacts survive // (junk siblings carry forward unchanged — existing LWW behavior). const latest = state.rebuild(); if (strict && latest.run_id !== runId) { return { status: "unverified", problem: "session identity changed after artifact write" }; } const merged: Record = { ...(strict ? strictArtifactMap(latest.session_artifacts) : (latest.session_artifacts ?? {})), [name]: pointer, }; const appended = state.appendVerified({ data: { session_artifacts: merged }, field: "session_artifacts", expected: merged, scope: "session-data", failure: `session_artifacts pointer read-back failed for ${name}`, equals: artifactMapsEqual, }); if (appended.status !== "applied") { // already reported through the strict-append port return { status: "unverified", problem: `session_artifacts pointer read-back failed for ${name}`, }; } if (strict) { const verified = strictArtifactRead(deps, name); if (verified.status !== "found" || verified.content !== content) { return { status: "unverified", problem: `session artifact ${name} strict write verification failed`, }; } } return { status: "applied", receipt: { runId, path: pointer.path, digest: pointer.digest }, }; } catch (error) { if (!strict) throw error; return { status: effectAttempted ? "unverified" : "rejected", problem: `session artifact ${name} strict write failed`, }; } }, nodeClaim() { return readClaim(state); }, activeObjective() { return readActiveObjective(state); }, activeSessionPlanRef() { return readActiveSessionPlanRef(state); }, reviewPosts() { return readReviewPosts(state); }, apply(change: WorkflowChange): WorkflowChangeResult { switch (change.kind) { case "link-plan-ref": { const ref = change.ref; // The pre-read dedupe is deliberately NOT try/caught — a throwing rebuild propagates. if (planRefsEqual(state.rebuild().active_plan_ref ?? null, ref)) { return { status: "unchanged" }; } // The classified strict-append distinguishes a PROVEN refusal-before-effect (the // append threw and the rebuilt field never changed — `rejected`) from a read-back // miss (`unverified`: an append may have landed unproven); its report() path stays // the loudness channel. `ClassifiedAppend` IS the seam's change vocabulary. return state.appendVerified({ data: { active_plan_ref: ref }, field: "active_plan_ref", expected: ref, scope: "plan-save", failure: `plan-ref read-back failed for ${ref.provider}:${ref.pr_id}`, equals: planRefsEqual, }); } case "clear-node-claim": { const claim = change.claim; // Never clobber an unrelated claim: clear only when the LIVE claim matches BOTH // fields (same-node/different-objective stays untouched). if (!nodeClaimsEqual(readClaim(state), claim)) return { status: "unchanged" }; return state.appendVerified({ data: { objective_node_claim: null }, field: "objective_node_claim", expected: null, scope: "plan-save", failure: `objective_node_claim clear read-back failed for node ${claim.node}`, equals: nodeClaimsEqual, }); } case "record-node-claim": { const claim = change.claim; // The idempotent re-claim short-circuit: an equal live claim rebuilds identically, so // a re-append would carry no semantic payload (the claim has no timestamp). if (nodeClaimsEqual(readClaim(state), claim)) return { status: "unchanged" }; return state.appendVerified({ data: { objective_node_claim: claim }, field: "objective_node_claim", expected: claim, scope: "objective-plan", failure: `objective_node_claim read-back failed for #${claim.objective} node ${claim.node}`, equals: nodeClaimsEqual, }); } case "link-objective": { const objective = change.objective; if (readActiveObjective(state) === objective) return { status: "unchanged" }; return state.appendVerified({ data: { active_objective: objective }, field: "active_objective", expected: objective, scope: "objective-save", failure: `active_objective read-back failed for #${objective}`, }); } case "enter-refinement-stage": { if (state.rebuild().stage === REFINE_STAGE_ID) return { status: "unchanged" }; return state.appendVerified({ data: { stage: REFINE_STAGE_ID }, field: "stage", expected: REFINE_STAGE_ID, scope: "objective-refine", failure: `stage read-back failed for ${REFINE_STAGE_ID}`, }); } case "record-pr-review": { // No pre-read/dedupe by design (the single-use wave state is feature-op policy // upstream): at runtime this yields applied/unverified/rejected only. return state.appendVerified({ data: { last_pr_review: change.record }, field: "last_pr_review", expected: change.record, scope: "pr-review", failure: "last_pr_review read-back failed", }); } case "record-review": { // No pre-read/dedupe by design (the resume guard is feature-op policy upstream): at // runtime this yields applied/unverified/rejected only. return state.appendVerified({ data: { last_review: change.record }, field: "last_review", expected: change.record, scope: "review", failure: "last_review read-back failed", }); } case "record-review-batch": { // No pre-read/dedupe by design (the corroborated-success ordering is feature-op // policy upstream): at runtime this yields applied/unverified/rejected only. return state.appendVerified({ data: { last_review_batch: change.record }, field: "last_review_batch", expected: change.record, scope: "address", failure: "last_review_batch read-back failed", }); } case "append-review-post": { // Read-rebuild-append: each write carries the whole ordered list (the resume reader // sees every confirmed post); order-sensitive read-back. The pre-read is FAIL-CLOSED // and STRICT — deliberately NOT the fail-open `reviewPosts()` read: appending over an // unrebuildable OR malformed ledger would LWW-overwrite possibly-real earlier rows, // and the resume guard would then permit duplicate GitHub reviews. Refusing before // any effect keeps the asymmetric trust rule intact (a row may be MISSING spuriously, // never PRESENT spuriously — and never erased by a write). An ABSENT field is the // normal first append (the empty prior ledger). let prior: ReviewPostRow[]; try { const raw = state.rebuild().review_posts; if (raw === undefined || raw === null) { prior = []; } else { const decoded = strictReviewPosts(raw); if ("malformed" in decoded) { return { status: "rejected", problem: "review_posts ledger is malformed — refusing to append over an unknown " + `ledger: ${decoded.malformed}`, }; } prior = decoded.rows; } } catch (error) { return { status: "rejected", problem: "review_posts ledger rebuild failed — refusing to append over an unknown " + `ledger: ${String(error)}`, }; } const posts: ReviewPostRow[] = [...prior, change.row]; return state.appendVerified({ data: { review_posts: posts }, field: "review_posts", expected: posts, scope: "review", failure: "review_posts read-back failed", equals: reviewPostsEqual, }); } } }, }; }