// The `dream_report` gate for the objective draft/review/save path (contracts.md §8.63). // // ONE resolver implements the whole gate matrix — `reviseObjectiveDraft` (draft.ts) and // `saveObjective` (save.ts) both consume its typed outcome, so no parallel branch/message // implementation can drift. "Dream session" is detected structurally, exactly // like `run_dream_wave` (pi/v1/learning/dream.ts): the session's claimed `run_id` plus the // existence of the run-scoped dream manifest (no claimed run counts as non-dream). The gate is // fail-closed in BOTH directions: a dream session refuses a report-less objective (the // objective and its report review as ONE bundle — an approval is always savable), and a // `dream_report` outside a dream session refuses rather than being silently dropped. Absence // on a non-dream path is byte-identical no-op behavior. // // Trusted-context recovery follows the session-artifacts digest-pointer doctrine: the bare // run-scratch bundle is never trusted — the `dream_bundle_digest` workflow-state marker // (cleared at wave entry, set to the finalized bytes' digest after a successful finalize) is // the freshness/integrity authority, and the bundle is strictly re-decoded through the // finalized-bundle decoder on every recovery read (untrusted-at-rest posture). The recovery // MECHANICS are edge-owned: the resolver consumes the runtime-minted `DreamGateRecovery` // capability (production: `pi/v1/objectiveDreamGate.ts`), so this module stays storage-free. // After a successful recovery the revalidation bracket (contracts.md §8.65) re-proves // HEAD-unchanged + tree-clean against the manifest's stamped `commit_sha` — at draft-write AND // save, since both consumers flow through the one resolver; drift refuses `bad_state` (the // analysis is stale). // // Imports only the dream wave siblings — cycle-free (nothing in `waves/` imports `authoring/`) // and loadable under `node --test`. import { codePointLength, type DreamLaneAnalysis, type DreamManifest, } from "../../learning/dream.ts"; import type { DreamReducerAnalysis } from "../../learning/dreamReducer.ts"; import { buildDreamReport, type DreamReportContext } from "../../learning/dreamReport.ts"; /** * The shared part-invariance + size rule's comment-body cap (contracts §8.64) — the full * rendered companion comment (marker + blank line + part) must fit with margin under GitHub's * 65,536-char issue-comment limit. The Python twin is * `perk.learn.dream_companion.COMPANION_COMMENT_MAX_CHARS` (parity-pinned fixtures). */ export const COMPANION_COMMENT_MAX_CHARS = 65_000; // The invariance shapes (the exact shapes the Linear transcoder `to_linear_markdown` // rewrites/drops — derived locally by the same rule, mirroring the Python twin). const MARKER_TEXT = "perk:learn-dream-report"; const PERK_HTML_MARKER_RE = //; const DETAILS_OPEN_RE = /^
[^<]*<\/code><\/summary>$/; const DETAILS_CLOSE = "
"; // Every line boundary Python's `str.splitlines()` recognizes EXCEPT `\n` — the Linear // transcoder splits on all of them and rejoins with `\n`, so any other boundary form would be // normalized in the stored body and defeat the persistence-side byte comparison forever. const NON_CANONICAL_LINE_BOUNDARIES = [ "\r", "\v", "\f", "\u001c", "\u001d", "\u001e", "\u0085", "\u2028", "\u2029", ]; /** * The TS mirror of Python's `validate_report_parts` (contracts §8.64) — run over the freshly * rendered parts at draft-write AND save (both flow through `resolveDreamReportGate`), so an * approved report is always Python-savable: no empty/blank part, no perk HTML-comment marker, * no literal companion marker text, no perk-rendered `
` wrapper line (the shapes the * Linear transcoder rewrites/drops — transcode-invariance keeps the persistence-side * dual-candidate byte comparison exact), and every rendered comment body (marker + blank line + * part) within `COMPANION_COMMENT_MAX_CHARS` code points. Returns named violations (`[]` = * valid). Parity-pinned against the Python twin by the shared fixture set. */ export function reportPartInvarianceViolations(parts: string[], runId: string): string[] { const violations: string[] = []; parts.forEach((part, i) => { const index = i + 1; const where = `part ${index}`; if (part.trim() === "") { violations.push(`${where}: empty part`); return; } if (part.includes(MARKER_TEXT)) { violations.push(`${where}: carries the literal '${MARKER_TEXT}' marker text`); } if (PERK_HTML_MARKER_RE.test(part)) { violations.push( `${where}: carries a perk HTML-comment marker ( is rewritten by the ` + "Linear transcoder)", ); } if ( part.split(/\r\n|\r|\n/).some((line) => DETAILS_OPEN_RE.test(line) || line === DETAILS_CLOSE) ) { violations.push( `${where}: carries a perk-rendered
wrapper line (dropped by the Linear ` + "transcoder)", ); } if (NON_CANONICAL_LINE_BOUNDARIES.some((boundary) => part.includes(boundary))) { violations.push( `${where}: carries a line boundary other than \\n (normalized by the Linear transcoder)`, ); } const bodyLength = codePointLength(`\n\n${part}`); if (bodyLength > COMPANION_COMMENT_MAX_CHARS) { violations.push( `${where}: rendered comment body is ${bodyLength} chars (cap ${COMPANION_COMMENT_MAX_CHARS})`, ); } }); if (parts.length === 0) violations.push("parts: empty list"); return violations; } /** * The `dream_report` block the objective-draft artifact carries (tool-written only — the model * never writes the artifact): the validated model input, the ONE `generated_at` stamp that * keeps re-rendering deterministic across review and save, and the stored CANONICAL parts the * review surface renders and the save byte-compares. */ export interface ObjectiveDreamReportBlock { input: unknown; generated_at: string; parts: string[]; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** * The artifact-side shape check `readObjectiveDraft` uses: a plain object carrying a * plain-object `input`, a non-blank string `generated_at`, and a non-empty all-string `parts`. * Deep validation stays with the resolver (the save re-runs the full gate); `null` = malformed. */ export function decodeDreamReportBlock(value: unknown): ObjectiveDreamReportBlock | null { if (!isRecord(value)) return null; if (!isRecord(value.input)) return null; if (typeof value.generated_at !== "string" || !value.generated_at.trim()) return null; if (!Array.isArray(value.parts) || value.parts.length === 0) return null; const parts: string[] = []; for (const part of value.parts) { if (typeof part !== "string") return null; parts.push(part); } return { input: value.input, generated_at: value.generated_at, parts }; } /** * The narrow per-operation trusted-context recovery capability the Pi/session edge mints * (production: `productionDreamGateRecovery` in `pi/v1/objectiveDreamGate.ts`; tests inject * fakes). ANTI-PROOF-OBJECT CONTRACT: the decode/digest/revalidation checks behind * `recoverContext` and `bracket` are runtime verification executed on EVERY consuming * operation (draft-write and save), never replaced by a structural type, assertion, or * previously computed proof object. */ export interface DreamGateRecovery { /** ONE fresh workflow-state snapshot per gate resolution (run identity + freshness marker + dream detection). * `detail` is always the RAW CAUSE (a caught message or a fixed cause literal) — the resolver * owns the one rendering prefix; the capability never pre-renders. */ readSession(): | { kind: "unreadable"; detail: string } | { kind: "read"; runId: string | null; dream: boolean; marker: string | undefined }; /** Fresh manifest+bundle read + the full decode/digest ladder — re-executed on EVERY call, never cached. * `detail` here IS the final text (today's recovery details are complete sentences; byte-preserved). */ recoverContext( runId: string, marker: string | undefined, ): | { ok: true; manifest: DreamManifest; analyses: DreamLaneAnalysis[]; reducers: DreamReducerAnalysis[]; } | { ok: false; detail: string }; /** The §8.65 revalidation bracket against the recovered manifest's stamped commit_sha. */ bracket(expectedSha: string): { ok: boolean; detail: string | null }; } /** The typed gate outcome both consumers branch on — the whole matrix, one vocabulary. */ export type DreamReportGateOutcome = | { kind: "absent" } | { kind: "block"; block: ObjectiveDreamReportBlock } | { kind: "refuse"; errorType: "invalid_input" | "bad_state"; detail: string }; /** * The ONE gate resolver (contracts §8.63) — identical at draft-write and save. `input` is the * model-supplied `dream_report` value, or `undefined` for "no dream_report" (callers pass the * value only when present; an `{input: undefined}` carrier is never constructed). The matrix: * * | session | `dream_report` | outcome | * | --------- | -------------- | ------- | * | non-dream | absent | `absent` — unchanged, byte-identical behavior | * | non-dream | present | refuse `invalid_input` (never silently dropped) | * | dream | absent | refuse `invalid_input` (one approval bundle) | * | dream | present | recover context → `buildDreamReport` → refuse or `block` | * * An UNREADABLE workflow state (`readSession()`'s `unreadable` arm — a throwing branch read, * or the capability's fail-closed run-id/marker narrowing) refuses `bad_state` BEFORE the * matrix — it is never conflated with a confirmed non-dream session (the `activeSessionRunId` * null-on-throw sentinel would otherwise let a transient read failure surface as `absent`). * The capability's `detail` is the RAW CAUSE; this resolver owns the one rendering prefix. * * Failure taxonomy: gate violations + `buildDreamReport` refusals → `invalid_input` (the * bounded ≤25 named details newline-joined); an unreadable workflow state and * context-recovery failures → `bad_state` (recovery details pass through UNPREFIXED). */ export function resolveDreamReportGate( recovery: DreamGateRecovery, input: unknown, generatedAt: string, ): DreamReportGateOutcome { // ONE workflow-state snapshot for the whole gate (run identity + the freshness marker + // dream detection), read with error distinction: unreadable state fails closed, never // "non-dream". const session = recovery.readSession(); if (session.kind === "unreadable") { return { kind: "refuse", errorType: "bad_state", detail: `session workflow state is unreadable — cannot resolve the dream_report gate: ${session.detail}`, }; } const runId = session.runId; if (runId === null || !session.dream) { if (input === undefined) return { kind: "absent" }; return { kind: "refuse", errorType: "invalid_input", detail: "dream_report is only valid inside a perk learn dream session — refusing rather than " + "silently dropping it", }; } if (input === undefined) { return { kind: "refuse", errorType: "invalid_input", detail: "this dream session's objective must carry dream_report — the objective and its " + "report review as one bundle", }; } // The marker rides the SAME snapshot as the run identity (the one-snapshot rule); the // capability re-executes the full read+decode+digest ladder on every call, never caching. const recovered = recovery.recoverContext(runId, session.marker); if (!recovered.ok) { return { kind: "refuse", errorType: "bad_state", detail: recovered.detail }; } const context: DreamReportContext = { manifest: recovered.manifest, analyses: recovered.analyses, reducers: recovered.reducers, run_id: runId, generated_at: generatedAt, }; // The revalidation-bracket re-check (contracts.md §8.65): the manifest — with its stamped // commit_sha — is now decoded and authenticated, so re-prove HEAD-unchanged + tree-clean // against it. Both `writeObjectiveDraft` and `saveObjective` flow through this resolver, so // the bracket re-fires at draft-write AND save; non-dream paths never reach it (the matrix // above returned already). const drift = recovery.bracket(context.manifest.commit_sha); if (!drift.ok) { return { kind: "refuse", errorType: "bad_state", detail: `the repository moved since the dream snapshot (${drift.detail}) — the analysis is ` + "stale; re-run perk learn dream", }; } const built = buildDreamReport(input, context); if (!built.ok) { return { kind: "refuse", errorType: "invalid_input", detail: built.details.join("\n") }; } // The §8.64 invariance mirror, at draft-write AND save (this resolver is both), so an // approved report is always savable by the Python door's identical rule. const violations = reportPartInvarianceViolations(built.parts, runId); if (violations.length > 0) { return { kind: "refuse", errorType: "invalid_input", detail: `dream report parts violate the invariance rule: ${violations.join("; ")}`, }; } return { kind: "block", block: { input, generated_at: generatedAt, parts: built.parts } }; }