diff --git a/fixtures/phase-5/awaiting-human-approval.daemon-summary.json b/fixtures/phase-5/awaiting-human-approval.daemon-summary.json index 561c7ba40..b8762cf1d 100644 --- a/fixtures/phase-5/awaiting-human-approval.daemon-summary.json +++ b/fixtures/phase-5/awaiting-human-approval.daemon-summary.json @@ -10,7 +10,7 @@ ], "approvalPath": { "variant": "human_approval_with_rationale", - "label": "human_review", + "label": "human_required", "veto_deadline": null, "affected_surfaces": [ "MEMORY.md" diff --git a/fixtures/phase-5/blocked.daemon-summary.json b/fixtures/phase-5/blocked.daemon-summary.json index 663ddbe6a..5da8e3ce1 100644 --- a/fixtures/phase-5/blocked.daemon-summary.json +++ b/fixtures/phase-5/blocked.daemon-summary.json @@ -10,7 +10,7 @@ ], "approvalPath": { "variant": "human_approval", - "label": "policy_blocked", + "label": "human_review", "veto_deadline": null, "affected_surfaces": [ "MEMORY.md" diff --git a/fixtures/phase-5/mismatched.daemon-summary.json b/fixtures/phase-5/mismatched.daemon-summary.json index f03142511..a244154f7 100644 --- a/fixtures/phase-5/mismatched.daemon-summary.json +++ b/fixtures/phase-5/mismatched.daemon-summary.json @@ -11,7 +11,7 @@ ], "approvalPath": { "variant": "familiar_coherence", - "label": "mismatch_review", + "label": "familiar_review", "veto_deadline": "2026-07-15T10:22:00Z", "affected_surfaces": [ "MEMORY.md", diff --git a/fixtures/phase-5/ready-for-replay.daemon-summary.json b/fixtures/phase-5/ready-for-replay.daemon-summary.json index 3122dd0d0..c3bc5869d 100644 --- a/fixtures/phase-5/ready-for-replay.daemon-summary.json +++ b/fixtures/phase-5/ready-for-replay.daemon-summary.json @@ -10,7 +10,7 @@ ], "approvalPath": { "variant": "familiar_coherence", - "label": "replay_ready", + "label": "familiar_review", "veto_deadline": "2026-07-15T09:52:00Z", "affected_surfaces": [ "MEMORY.md" diff --git a/fixtures/phase-5/unknown.daemon-summary.json b/fixtures/phase-5/unknown.daemon-summary.json index e5189fc17..ccf62ac97 100644 --- a/fixtures/phase-5/unknown.daemon-summary.json +++ b/fixtures/phase-5/unknown.daemon-summary.json @@ -10,7 +10,7 @@ ], "approvalPath": { "variant": "human_approval_with_rationale", - "label": "mystery_review", + "label": "human_required", "veto_deadline": null, "affected_surfaces": [ "MEMORY.md" diff --git a/fixtures/phase-5/veto-window-open.daemon-summary.json b/fixtures/phase-5/veto-window-open.daemon-summary.json index c243c42c5..efbe82dbb 100644 --- a/fixtures/phase-5/veto-window-open.daemon-summary.json +++ b/fixtures/phase-5/veto-window-open.daemon-summary.json @@ -10,7 +10,7 @@ ], "approvalPath": { "variant": "familiar_coherence", - "label": "coherence_review", + "label": "familiar_review", "veto_deadline": "2026-07-15T09:42:00Z", "affected_surfaces": [ "MEMORY.md" diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 982c5df62..efa630560 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -1221,6 +1221,7 @@ const ALIAS_LOADER = new Set([ "src/lib/cave-inbox-bulk.test.ts", "src/lib/cave-inbox-create.test.ts", "src/app/api/chat/stream/route.test.ts", + "src/app/api/proposals-flow-e2e.test.ts", "src/app/api/prompts/route.test.ts", "src/app/api/marketplace/pack-prompts-route.test.ts", "src/lib/cave-backdrop.test.ts", diff --git a/src/app/api/api-contracts.test.ts b/src/app/api/api-contracts.test.ts index f1c23591c..79407b62b 100644 --- a/src/app/api/api-contracts.test.ts +++ b/src/app/api/api-contracts.test.ts @@ -270,10 +270,16 @@ function usesJsonResponse(source: string): boolean { } function effectiveRouteSource(file: string, source: string): string { + const parts = [source]; const reexport = source.match(/from\s+"(\.[^"]+\/route)";/); - if (!reexport) return source; - const target = path.resolve(path.dirname(file), `${reexport[1]}.ts`); - return `${source}\n${readFileSync(target, "utf8")}`; + if (reexport) { + const target = path.resolve(path.dirname(file), `${reexport[1]}.ts`); + parts.push(readFileSync(target, "utf8")); + } + if (source.includes('from "@/lib/proposal-decision-body"')) { + parts.push(readFileSync(path.join(apiRoot, "..", "..", "lib", "proposal-decision-body.ts"), "utf8")); + } + return parts.join("\n"); } const routeFiles = walkRoutes(apiRoot); @@ -306,7 +312,7 @@ for (const contract of contracts) { assert.doesNotMatch(source, /invalid json|invalid JSON/, `${contract.route} legacy invalid-JSON behavior changed`); } if (contract.optionalJsonBody) { - assert.match(source, /rawBody\.trim\(\)/, `${contract.route} must accept a missing request body`); + assert.match(effectiveSource, /rawBody\.trim\(\)/, `${contract.route} must accept a missing request body`); } if (contract.pathGuard) { assert.match(source, /path not allowed|collection path not allowed/, `${contract.route} must preserve path-deny errors`); diff --git a/src/app/api/proposals-flow-e2e.test.ts b/src/app/api/proposals-flow-e2e.test.ts index 7183d6621..1a883b156 100644 --- a/src/app/api/proposals-flow-e2e.test.ts +++ b/src/app/api/proposals-flow-e2e.test.ts @@ -1,14 +1,9 @@ // E2E for the proposal decision flow with REAL staged-write fixtures // (threads-986.17.6, spec §3.7). // -// `next/server` cannot be imported under the bare-node test runner, so this -// test drives the exact composition the route handlers execute — -// activeThreadsAdapter() (env-selected, no mocks) + httpStatusForEnvelope — -// over the checked-in fixtures/phase-4/pending/ staged writes and a real -// temp COVEN_HOME, then pins the route sources to that composition so the -// handlers cannot drift from what is tested here. Guard behavior -// (rejectNonLocalRequest, invalid-JSON 400) is enforced per-route by -// src/app/api/api-contracts.test.ts. +// This test drives both the route handlers and the exact composition they +// forward into — activeThreadsAdapter() (env-selected, no mocks) + +// httpStatusForEnvelope — over checked-in and isolated staged-write fixtures. import assert from "node:assert/strict"; import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -16,6 +11,10 @@ import path from "node:path"; import { after, afterEach, describe, it } from "node:test"; import { activeThreadsAdapter, httpStatusForEnvelope } from "../../lib/threads-adapters.ts"; +import { parseProposalDecisionBody } from "../../lib/proposal-decision-body.ts"; + +const { POST: approveProposal } = await import("./proposals/[id]/approve/route.ts"); +const { POST: rejectProposal } = await import("./proposals/[id]/reject/route.ts"); const PROPOSAL_OK = "cccccccc-0001-4001-8001-000000000001"; const CORRUPT_ID = "dddddddd-0001-4001-8001-000000000001"; @@ -56,28 +55,80 @@ describe("route-source pins: handlers are exactly the composition under test", ( } }); - it("decision routes accept an optional body, validate revisions, and forward revision + note", () => { + it("decision routes preserve their local guard and forward the shared parser result exactly", () => { for (const file of ["proposals/[id]/approve/route.ts", "proposals/[id]/reject/route.ts"]) { const source = readFileSync(new URL(`./${file}`, import.meta.url), "utf8"); assert.match(source, /rejectNonLocalRequest\(req\)/, `${file} keeps the local-origin guard`); - assert.match(source, /await req\.text\(\)/, `${file} reads an optional raw body`); - assert.match(source, /rawBody\.trim\(\)/, `${file} preserves bodyless legacy calls`); - assert.match(source, /invalid json body/, `${file} answers 400 on malformed JSON`); - assert.match(source, /\^\[0-9a-f\]\{64\}\$/, `${file} requires exactly 64 lowercase hex characters`); - assert.match(source, /invalid expectedRevision/, `${file} answers 400 on malformed revisions`); assert.match( source, - /\.(approve|reject)\(id, expectedRevision, note\)/, - `${file} forwards id + expectedRevision + note to the adapter only`, + /parseProposalDecisionBody\(await req\.text\(\)\)/, + `${file} parses the optional body through the shared pure helper`, ); - assert.ok( - source.indexOf("invalid expectedRevision") < source.search(/\.(approve|reject)\(id, expectedRevision, note\)/), - `${file} validates the revision before forwarding`, + assert.match( + source, + /\.(approve|reject)\(id, decision\.expectedRevision, decision\.note\)/, + `${file} forwards the parser's exact expectedRevision + note result`, ); } }); }); +describe("proposal decision body parser", () => { + it("preserves omitted and structurally empty legacy bodies", () => { + for (const rawBody of ["", " \n ", "{}", "null", "[]", '"legacy primitive"']) { + assert.deepEqual(parseProposalDecisionBody(rawBody), { + ok: true, + expectedRevision: undefined, + note: undefined, + }); + } + }); + + it("normalizes null notes to omitted and preserves string notes exactly", () => { + assert.deepEqual(parseProposalDecisionBody('{"note":null}'), { + ok: true, + expectedRevision: undefined, + note: undefined, + }); + assert.deepEqual(parseProposalDecisionBody('{"note":" principal note "}'), { + ok: true, + expectedRevision: undefined, + note: " principal note ", + }); + }); + + it("rejects every non-null, non-string note", () => { + for (const note of [42, false, { text: "no" }, ["no"]]) { + assert.deepEqual(parseProposalDecisionBody(JSON.stringify({ note })), { + ok: false, + error: "invalid note", + }); + } + }); + + it("rejects malformed JSON", () => { + assert.deepEqual(parseProposalDecisionBody("{"), { + ok: false, + error: "invalid json body", + }); + }); + + it("accepts only omitted or exact lowercase SHA-256 revisions", () => { + const revision = "a".repeat(64); + assert.deepEqual(parseProposalDecisionBody(JSON.stringify({ expectedRevision: revision, note: "ship it" })), { + ok: true, + expectedRevision: revision, + note: "ship it", + }); + for (const expectedRevision of [null, "", "a".repeat(63), "A".repeat(64), 42]) { + assert.deepEqual(parseProposalDecisionBody(JSON.stringify({ expectedRevision })), { + ok: false, + error: "invalid expectedRevision", + }); + } + }); +}); + describe("GET flow — real checked-in staged writes through the env-selected adapter", () => { it("serves the legacy and Phase 5 pending fixtures with the freshness envelope", async () => { process.env.COVEN_THREADS_ADAPTER = "fixtures"; @@ -110,6 +161,28 @@ describe("GET flow — real checked-in staged writes through the env-selected ad }); describe("decision flow — forward-only, fail-closed, staged files untouched", () => { + for (const [decision, handler] of [ + ["approve", approveProposal], + ["reject", rejectProposal], + ] as const) { + it(`${decision} route rejects non-null, non-string notes with 400`, async () => { + process.env.COVEN_THREADS_ADAPTER = "fixtures"; + for (const note of [42, false, { text: "no" }, ["no"]]) { + const response = await handler( + new Request(`http://127.0.0.1/api/proposals/${PROPOSAL_OK}/${decision}`, { + method: "POST", + headers: { "content-type": "application/json", host: "127.0.0.1" }, + body: JSON.stringify({ note }), + }), + { params: Promise.resolve({ id: PROPOSAL_OK }) }, + ); + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { ok: false, error: "invalid note" }); + } + }); + + } + it("R5: fixtures mode (no daemon) answers 503 and the checked-in files never change", async () => { process.env.COVEN_THREADS_ADAPTER = "fixtures"; delete process.env.COVEN_THREADS_FIXTURE_SCENARIO; diff --git a/src/app/api/proposals/[id]/approve/route.ts b/src/app/api/proposals/[id]/approve/route.ts index 8f26037d2..1b239cf03 100644 --- a/src/app/api/proposals/[id]/approve/route.ts +++ b/src/app/api/proposals/[id]/approve/route.ts @@ -1,12 +1,11 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server.js"; +import { parseProposalDecisionBody } from "@/lib/proposal-decision-body"; import { rejectNonLocalRequest } from "@/lib/server/api-security"; import { activeThreadsAdapter, httpStatusForEnvelope } from "@/lib/threads-adapters"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; -type DecisionBody = { expectedRevision?: unknown; note?: unknown }; - // POST /api/proposals/[id]/approve — thin daemon-forwarder (spec §3.7). The // daemon re-validates, applies or refuses, audits, and removes the pending // file; this route never mutates anything itself. Fails closed (503) when @@ -15,27 +14,8 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str const rejected = rejectNonLocalRequest(req); if (rejected) return rejected; const { id } = await params; - let body: DecisionBody = {}; - const rawBody = await req.text(); - if (rawBody.trim() !== "") { - let parsed: unknown; - try { - parsed = JSON.parse(rawBody); - } catch { - return NextResponse.json({ ok: false, error: "invalid json body" }, { status: 400 }); - } - if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { - body = parsed as DecisionBody; - } - } - const expectedRevision = body.expectedRevision; - if ( - expectedRevision !== undefined && - (typeof expectedRevision !== "string" || !/^[0-9a-f]{64}$/.test(expectedRevision)) - ) { - return NextResponse.json({ ok: false, error: "invalid expectedRevision" }, { status: 400 }); - } - const note = typeof body.note === "string" ? body.note : undefined; - const envelope = await activeThreadsAdapter().approve(id, expectedRevision, note); + const decision = parseProposalDecisionBody(await req.text()); + if (!decision.ok) return NextResponse.json({ ok: false, error: decision.error }, { status: 400 }); + const envelope = await activeThreadsAdapter().approve(id, decision.expectedRevision, decision.note); return NextResponse.json(envelope, { status: httpStatusForEnvelope(envelope, "POST") }); } diff --git a/src/app/api/proposals/[id]/reject/route.ts b/src/app/api/proposals/[id]/reject/route.ts index 75c6807d8..c0bb3ffec 100644 --- a/src/app/api/proposals/[id]/reject/route.ts +++ b/src/app/api/proposals/[id]/reject/route.ts @@ -1,12 +1,11 @@ -import { NextResponse } from "next/server"; +import { NextResponse } from "next/server.js"; +import { parseProposalDecisionBody } from "@/lib/proposal-decision-body"; import { rejectNonLocalRequest } from "@/lib/server/api-security"; import { activeThreadsAdapter, httpStatusForEnvelope } from "@/lib/threads-adapters"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; -type DecisionBody = { expectedRevision?: unknown; note?: unknown }; - // POST /api/proposals/[id]/reject — thin daemon-forwarder (spec §3.7). // Rejection is a daemon-side decision too: the daemon audits it and removes // the pending file. Fails closed (503) without a daemon. @@ -14,27 +13,8 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str const rejected = rejectNonLocalRequest(req); if (rejected) return rejected; const { id } = await params; - let body: DecisionBody = {}; - const rawBody = await req.text(); - if (rawBody.trim() !== "") { - let parsed: unknown; - try { - parsed = JSON.parse(rawBody); - } catch { - return NextResponse.json({ ok: false, error: "invalid json body" }, { status: 400 }); - } - if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { - body = parsed as DecisionBody; - } - } - const expectedRevision = body.expectedRevision; - if ( - expectedRevision !== undefined && - (typeof expectedRevision !== "string" || !/^[0-9a-f]{64}$/.test(expectedRevision)) - ) { - return NextResponse.json({ ok: false, error: "invalid expectedRevision" }, { status: 400 }); - } - const note = typeof body.note === "string" ? body.note : undefined; - const envelope = await activeThreadsAdapter().reject(id, expectedRevision, note); + const decision = parseProposalDecisionBody(await req.text()); + if (!decision.ok) return NextResponse.json({ ok: false, error: decision.error }, { status: 400 }); + const envelope = await activeThreadsAdapter().reject(id, decision.expectedRevision, decision.note); return NextResponse.json(envelope, { status: httpStatusForEnvelope(envelope, "POST") }); } diff --git a/src/lib/proposal-authority.ts b/src/lib/proposal-authority.ts index 24b08fe3e..fced4cf11 100644 --- a/src/lib/proposal-authority.ts +++ b/src/lib/proposal-authority.ts @@ -75,6 +75,12 @@ const APPROVAL_PATH_VARIANTS = new Map( ["human_approval", "human-approval"], ["human_approval_with_rationale", "human-approval-with-rationale"], ]); +const APPROVAL_PATH_LABELS = new Map([ + ["auto_regression", "auto"], + ["familiar_coherence", "familiar_review"], + ["human_approval", "human_review"], + ["human_approval_with_rationale", "human_required"], +]); const CHANNEL_VALUES = new Set(["Deliberate", "Forced", "Serialization", "Mutation"]); const FRAY_REASON_VALUES = new Set([ "ContentHashMismatch", @@ -328,6 +334,40 @@ function canonicalStagedInstant(value: unknown): string | null { )}:${padNumber(second, 2)}${fraction}${offset}`; } +function stagedInstantEpochNanos(value: unknown): bigint | null { + const canonical = canonicalStagedInstant(value); + if (!canonical) return null; + const match = RFC3339_RE.exec(canonical)!; + const [, yearText, monthText, dayText, hourText, minuteText, secondText, fraction, offset] = match; + const year = Number(yearText); + const month = Number(monthText); + const adjustedYear = year - (month <= 2 ? 1 : 0); + const era = Math.floor(adjustedYear / 400); + const yearOfEra = adjustedYear - era * 400; + const shiftedMonth = month + (month > 2 ? -3 : 9); + const dayOfYear = Math.floor((153 * shiftedMonth + 2) / 5) + Number(dayText) - 1; + const dayOfEra = + yearOfEra * 365 + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100) + dayOfYear; + const days = BigInt(era * 146_097 + dayOfEra); + const secondsOfDay = + Number(hourText) * 3_600 + Number(minuteText) * 60 + Number(secondText); + let offsetSeconds = 0; + if (offset !== "Z") { + const direction = offset[0] === "-" ? -1 : 1; + offsetSeconds = direction * (Number(offset.slice(1, 3)) * 3_600 + Number(offset.slice(4, 6)) * 60); + } + const nanoseconds = BigInt((fraction ?? "").padEnd(9, "0") || "0"); + const nanosPerSecond = BigInt(1_000_000_000); + return (days * BigInt(86_400) + BigInt(secondsOfDay - offsetSeconds)) * nanosPerSecond + nanoseconds; +} + +function sameStagedInstant(left: unknown, right: unknown): boolean { + if (left === null || right === null) return left === null && right === null; + const leftNanos = stagedInstantEpochNanos(left); + const rightNanos = stagedInstantEpochNanos(right); + return leftNanos !== null && rightNanos !== null && leftNanos === rightNanos; +} + function isByteArray(value: unknown, length?: number): value is number[] { return ( Array.isArray(value) && @@ -373,6 +413,31 @@ function isVetoWindowShape(value: unknown): boolean { return isDurationAtMost(value.min_visible as RawRecord, value.duration as RawRecord); } +function durationNanos(value: RawRecord): bigint { + return BigInt(Number(value.secs)) * BigInt(1_000_000_000) + BigInt(Number(value.nanos)); +} + +function hasConsistentStagedVetoMetadata(raw: RawRecord): boolean { + if (!isRecord(raw.classification) || !isRecord(raw.classification.approval_path)) return false; + const approvalPath = raw.classification.approval_path; + const veto = + approvalPath.kind === "auto_regression" || approvalPath.kind === "familiar_coherence" + ? approvalPath.veto + : null; + + if (veto === null) return raw.veto_deadline === null && raw.earliest_close === null; + if (!isRecord(veto) || !isRecord(veto.duration) || !isRecord(veto.min_visible)) return false; + + const stagedAt = stagedInstantEpochNanos(raw.staged_at); + const vetoDeadline = stagedInstantEpochNanos(raw.veto_deadline); + const earliestClose = stagedInstantEpochNanos(raw.earliest_close); + return ( + stagedAt !== null && + vetoDeadline === stagedAt + durationNanos(veto.duration) && + earliestClose === stagedAt + durationNanos(veto.min_visible) + ); +} + function isApprovalPathShape(value: unknown): boolean { if (!isRecord(value) || typeof value.kind !== "string") return false; switch (value.kind) { @@ -643,6 +708,7 @@ function isCompleteScheduledEnvelope(raw: RawRecord): boolean { canonicalStagedInstant(raw.staged_at) !== null && isNullableStagedInstant(raw.veto_deadline) && isNullableStagedInstant(raw.earliest_close) && + hasConsistentStagedVetoMetadata(raw) && hasConsistentScheduledEnvelopeBindings(raw) ); } @@ -740,9 +806,17 @@ function normalizeApprovalPath(source: RawRecord): ProposalAuthorityVerifiedView } const variantKey = source.variant; const variant = APPROVAL_PATH_VARIANTS.get(variantKey); - if (!variant) return null; + const expectedLabel = APPROVAL_PATH_LABELS.get(variantKey); + if (!variant || source.label !== expectedLabel) return null; const vetoDeadline = daemonNullableIso(source.veto_deadline); if (!vetoDeadline.valid) return null; + if (variantKey === "familiar_coherence" && vetoDeadline.value === null) return null; + if ( + (variantKey === "human_approval" || variantKey === "human_approval_with_rationale") && + vetoDeadline.value !== null + ) { + return null; + } return { variant, label: source.label, @@ -846,13 +920,15 @@ function normalizeVerifiedAuthority( if (!stagedFamiliarUuid || daemonSummary.familiarUuid !== stagedFamiliarUuid) return null; if (daemonSummary.proposalId !== payload.id || daemonSummary.writer !== payload.writer) return null; - const envelopeStagedAt = canonicalStagedInstant(stagedEnvelope.staged_at); - const pendingStagedAt = canonicalStagedInstant(findPendingCandidate(stagedEnvelope).staged_at); + const envelopeStagedAt = stagedInstantEpochNanos(stagedEnvelope.staged_at); + const pendingStagedAt = stagedInstantEpochNanos(findPendingCandidate(stagedEnvelope).staged_at); + const daemonStagedAt = stagedInstantEpochNanos(daemonSummary.stagedAt); if ( - !envelopeStagedAt || - !pendingStagedAt || + envelopeStagedAt === null || + pendingStagedAt === null || + daemonStagedAt === null || pendingStagedAt !== envelopeStagedAt || - daemonSummary.stagedAt !== envelopeStagedAt + daemonStagedAt !== envelopeStagedAt ) { return null; } @@ -862,6 +938,17 @@ function normalizeVerifiedAuthority( if (!sameStringSet(targets, daemonSummary.targets)) return null; if (!sameStringSet(targets, daemonSummary.approvalPath.affectedSurfaces)) return null; if (canonicalProposalRevision(stagedEnvelope) !== daemonSummary.proposalRevision) return null; + if (!isRecord(stagedEnvelope.classification) || !isRecord(stagedEnvelope.classification.approval_path)) { + return null; + } + const stagedVariant = APPROVAL_PATH_VARIANTS.get(String(stagedEnvelope.classification.approval_path.kind)); + if ( + stagedVariant !== daemonSummary.approvalPath.variant || + !sameStagedInstant(stagedEnvelope.veto_deadline, daemonSummary.approvalPath.vetoDeadline) || + !sameStagedInstant(stagedEnvelope.earliest_close, daemonSummary.earliestClose) + ) { + return null; + } return { state: "verified", diff --git a/src/lib/proposal-decision-body.ts b/src/lib/proposal-decision-body.ts new file mode 100644 index 000000000..24094aae5 --- /dev/null +++ b/src/lib/proposal-decision-body.ts @@ -0,0 +1,37 @@ +type DecisionBody = { expectedRevision?: unknown; note?: unknown }; + +export type ProposalDecisionBodyResult = + | { ok: true; expectedRevision: string | undefined; note: string | undefined } + | { ok: false; error: "invalid json body" | "invalid expectedRevision" | "invalid note" }; + +export function parseProposalDecisionBody(rawBody: string): ProposalDecisionBodyResult { + let body: DecisionBody = {}; + if (rawBody.trim() !== "") { + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch { + return { ok: false, error: "invalid json body" }; + } + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + body = parsed as DecisionBody; + } + } + + const expectedRevision = body.expectedRevision; + if ( + expectedRevision !== undefined && + (typeof expectedRevision !== "string" || !/^[0-9a-f]{64}$/.test(expectedRevision)) + ) { + return { ok: false, error: "invalid expectedRevision" }; + } + if ("note" in body && body.note !== null && typeof body.note !== "string") { + return { ok: false, error: "invalid note" }; + } + + return { + ok: true, + expectedRevision, + note: typeof body.note === "string" ? body.note : undefined, + }; +} diff --git a/src/lib/threads-adapters.test.ts b/src/lib/threads-adapters.test.ts index 5dd938b4a..d0f7286e2 100644 --- a/src/lib/threads-adapters.test.ts +++ b/src/lib/threads-adapters.test.ts @@ -444,7 +444,7 @@ describe("daemon adapter — proposals and decisions", () => { return { home, pendingFile, staged, summary }; } - it("GETs the daemon proposal envelope and joins summaries without rereading staged details", async () => { + it("fails closed when pending changes during the daemon summary request", async () => { const { home, pendingFile, summary } = homeWithScheduled("awaiting-human-approval"); const calls: string[] = []; const adapter = new DaemonThreadsAdapter({ @@ -456,6 +456,25 @@ describe("daemon adapter — proposals and decisions", () => { covenHomeDir: home, }); + const res = await adapter.proposals(); + assert.deepEqual(calls, [DAEMON_PROPOSALS_PATH]); + assert.equal(res.blocked, true); + assert.equal(res.why, "unparseable"); + assert.equal(res.data, null); + assert.equal(res.meta.verified, false); + }); + + it("returns joined proposals when the certified pending snapshot stays stable", async () => { + const { home, summary } = homeWithScheduled("awaiting-human-approval"); + const calls: string[] = []; + const adapter = new DaemonThreadsAdapter({ + call: async (req: { path: string }) => { + calls.push(req.path); + return { ok: true, status: 200, data: { proposals: [summary] } as T }; + }, + covenHomeDir: home, + }); + const res = await adapter.proposals(); assert.deepEqual(calls, [DAEMON_PROPOSALS_PATH]); assert.equal(res.blocked, false); @@ -465,7 +484,7 @@ describe("daemon adapter — proposals and decisions", () => { assert.equal(proposal?.authority?.state, "verified"); if (proposal?.authority?.state !== "verified") return; assert.equal(proposal.authority.lifecycle, "awaiting-human-approval"); - assert.equal(proposal.authority.approvalPath.label, "human_review"); + assert.equal(proposal.authority.approvalPath.label, "human_required"); }); it("accepts the real daemon non-blocked summary shape that omits blockedReason", async () => { @@ -875,10 +894,10 @@ describe("fail-closed sweep: no adapter state renders healthy from unverifiable describe("phase 5 proposal fixtures", () => { const verifiedCases = [ - ["awaiting-human-approval", "awaiting-human-approval", ["approve", "reject"], null, "human_review"], - ["veto-window-open", "veto-window-open", ["reject"], null, "coherence_review"], - ["ready-for-replay", "ready-for-replay", [], null, "replay_ready"], - ["blocked", "blocked", [], "daemon-reported-block", "policy_blocked"], + ["awaiting-human-approval", "awaiting-human-approval", ["approve", "reject"], null, "human_required"], + ["veto-window-open", "veto-window-open", ["reject"], null, "familiar_review"], + ["ready-for-replay", "ready-for-replay", [], null, "familiar_review"], + ["blocked", "blocked", [], "daemon-reported-block", "human_review"], ] as const; for (const [name, lifecycle, decisions, blockedReason, label] of verifiedCases) { diff --git a/src/lib/threads-adapters.ts b/src/lib/threads-adapters.ts index 003267aab..240970464 100644 --- a/src/lib/threads-adapters.ts +++ b/src/lib/threads-adapters.ts @@ -92,12 +92,20 @@ type StagedProposal = { raw: unknown; }; +type PendingSnapshot = { + staged: StagedProposal[]; + cursor: string; +}; + type ProposalSummarySource = | { state: "available"; byId: Map } | { state: "unavailable" } | { state: "unparseable" }; -function readPendingDir(dir: string, fileFilter: (file: string) => boolean = (file) => file.endsWith(".json")): StagedProposal[] | null { +function readPendingSnapshot( + dir: string, + fileFilter: (file: string) => boolean = (file) => file.endsWith(".json"), +): PendingSnapshot | null { // null = the listing itself could not be verified (unreadable dir, not a // dir, permissions). Callers fail closed — a throw here would 500 the route // instead of rendering blocked. @@ -109,15 +117,28 @@ function readPendingDir(dir: string, fileFilter: (file: string) => boolean = (fi } catch { return null; } - return files.map((file) => { + const cursor = createHash("sha256"); + const staged = files.map((file) => { + let serialized: string; try { - const raw: unknown = JSON.parse(readFileSync(path.join(/* turbopackIgnore: true */ dir, file), "utf8")); + serialized = readFileSync(path.join(/* turbopackIgnore: true */ dir, file), "utf8"); + cursor.update(file).update("\0").update(serialized).update("\0"); + const raw: unknown = JSON.parse(serialized); return { file, raw }; } catch { + cursor.update(file).update("\0unreadable-or-corrupt\0"); // R6: corrupt pending file — listed, actions disabled, never dropped. return { file, raw: null }; } }); + return { staged, cursor: `pending:${cursor.digest("hex").slice(0, 16)}` }; +} + +function readPendingDir( + dir: string, + fileFilter: (file: string) => boolean = (file) => file.endsWith(".json"), +): StagedProposal[] | null { + return readPendingSnapshot(dir, fileFilter)?.staged ?? null; } function stagedProposalId(raw: unknown): string | null { @@ -539,20 +560,24 @@ export class DaemonThreadsAdapter implements ThreadsReadAdapter { if (existsSync(/* turbopackIgnore: true */ this.home)) return okEnvelope([], this.meta("pending:empty", true)); return blockedEnvelope("daemon-unavailable", this.meta("pending:absent", false)); } - const staged = readPendingDir(pendingDir); - if (staged === null) { + const snapshot = readPendingSnapshot(pendingDir); + if (snapshot === null) { // The staging area exists but its listing cannot be verified: blocked, // never a throw and never an empty-healthy answer. return blockedEnvelope("unparseable", this.meta("pending:unreadable", false)); } - if (staged.length === 0) return okEnvelope([], this.meta(pendingDirCursor(pendingDir), true)); + if (snapshot.staged.length === 0) return okEnvelope([], this.meta(snapshot.cursor, true)); const res = await this.call({ path: DAEMON_PROPOSALS_PATH, timeoutMs: this.timeoutMs }); + const confirmedSnapshot = readPendingSnapshot(pendingDir); + if (confirmedSnapshot === null || confirmedSnapshot.cursor !== snapshot.cursor) { + return blockedEnvelope("unparseable", this.meta("pending:changed-during-summary", false)); + } const summaries = res.ok ? proposalSummarySource(res.data) : { state: "unavailable" as const }; const summariesCursor = res.ok ? proposalSummaryCursor(res.data) : `unavailable:${res.status}`; return okEnvelope( - joinProposalSummaries(staged, summaries), - this.meta(`${pendingDirCursor(pendingDir)}:${summariesCursor}`, true), + joinProposalSummaries(snapshot.staged, summaries), + this.meta(`${snapshot.cursor}:${summariesCursor}`, true), ); } diff --git a/src/lib/threads-read.test.ts b/src/lib/threads-read.test.ts index 004b26db2..5f97f5f4d 100644 --- a/src/lib/threads-read.test.ts +++ b/src/lib/threads-read.test.ts @@ -1176,7 +1176,7 @@ describe("normalizeProposal (§2.6)", () => { ); }); - it("binds the daemon to the staged timestamp's canonical textual representation", () => { + it("binds the daemon staged timestamp by exact instant across equivalent offsets", () => { const { staged: fixture, daemon } = daemonContractFixture(); const stagedAt = [2026, 196, 9, 0, 2, 0, 2, 0, 0]; const staged = { @@ -1192,16 +1192,23 @@ describe("normalizeProposal (§2.6)", () => { }; assert.equal(normalizeProposalAuthority(staged, exact).state, "verified"); - assert.deepEqual( + assert.equal( normalizeProposalAuthority(staged, { ...exact, stagedAt: "2026-07-15T07:00:02Z", + }).state, + "verified", + ); + assert.deepEqual( + normalizeProposalAuthority(staged, { + ...exact, + stagedAt: "2026-07-15T07:00:02.000000001Z", }), { state: "blocked", why: "daemon-mismatch" }, ); }); - it("canonicalizes strict string-form staged timestamps before binding", () => { + it("binds strict string-form staged timestamps by nanosecond instant, not spelling", () => { const { staged: fixture, daemon } = daemonContractFixture(); const stagedAt = "2026-07-15T09:00:02.1200+00:00"; const staged = { @@ -1217,10 +1224,17 @@ describe("normalizeProposal (§2.6)", () => { }; assert.equal(normalizeProposalAuthority(staged, summary).state, "verified"); - assert.deepEqual( + assert.equal( normalizeProposalAuthority(staged, { ...summary, stagedAt, + }).state, + "verified", + ); + assert.deepEqual( + normalizeProposalAuthority(staged, { + ...summary, + stagedAt: "2026-07-15T09:00:02.120000001Z", }), { state: "blocked", why: "daemon-mismatch" }, ); @@ -1448,25 +1462,16 @@ describe("normalizeProposal (§2.6)", () => { assert.deepEqual(view.authority.affectedRegions, ["display-only-region"]); }); - it("treats daemon approval labels and deadline metadata as authoritative", () => { + it("rejects noncanonical daemon approval labels and deadline shapes", () => { const { staged, daemon } = daemonContractFixture(); const { staged: vetoStaged, daemon: vetoDaemon } = vetoContractFixture(); - const accepted = [ + const rejected = [ { staged, summary: { ...daemon, approvalPath: { ...daemon.approvalPath, label: "Review with your own words" }, }, - label: "Review with your own words", - }, - { - staged, - summary: { - ...daemon, - approvalPath: { ...daemon.approvalPath, label: "human_required" }, - }, - label: "human_required", }, { staged, @@ -1477,24 +1482,28 @@ describe("normalizeProposal (§2.6)", () => { veto_deadline: "2026-07-15T09:30:02Z", }, }, - label: "human_review", + }, + { + staged: vetoStaged, + summary: { + ...vetoDaemon, + approvalPath: { ...vetoDaemon.approvalPath, label: "coherence_review" }, + }, }, { staged: vetoStaged, summary: { ...vetoDaemon, approvalPath: { ...vetoDaemon.approvalPath, veto_deadline: null }, - lifecycle: "ready_for_replay", }, - label: "familiar_review", }, ]; - for (const [index, { staged: candidate, summary, label }] of accepted.entries()) { - const authority = normalizeProposal(`phase5-authoritative-${index}.json`, candidate, summary).authority; - assert.equal(authority.state, "verified"); - if (authority.state !== "verified") continue; - assert.equal(authority.approvalPath.label, label); + for (const [index, { staged: candidate, summary }] of rejected.entries()) { + assert.deepEqual( + normalizeProposal(`phase5-noncanonical-${index}.json`, candidate, summary).authority, + { state: "blocked", why: "daemon-unparseable" }, + ); } }); @@ -1527,7 +1536,7 @@ describe("normalizeProposal (§2.6)", () => { } }); - it("preserves arbitrary daemon approval labels verbatim", () => { + it("renders each validated canonical daemon approval label verbatim", () => { const { staged, daemon } = daemonContractFixture(); const { staged: vetoStaged, daemon: vetoDaemon } = vetoContractFixture(); const autoStaged = { @@ -1554,27 +1563,21 @@ describe("normalizeProposal (§2.6)", () => { approvalPath: { ...daemon.approvalPath, variant: "auto_regression", - label: "Automatic after checks", + label: "auto", }, lifecycle: "ready_for_replay", }, - label: "Automatic after checks", + label: "auto", }, { staged: vetoStaged, - summary: { - ...vetoDaemon, - approvalPath: { ...vetoDaemon.approvalPath, label: "Sage may veto" }, - }, - label: "Sage may veto", + summary: vetoDaemon, + label: "familiar_review", }, { staged, - summary: { - ...daemon, - approvalPath: { ...daemon.approvalPath, label: "Principal review" }, - }, - label: "Principal review", + summary: daemon, + label: "human_review", }, { staged: rationaleStaged, @@ -1584,10 +1587,10 @@ describe("normalizeProposal (§2.6)", () => { approvalPath: { ...daemon.approvalPath, variant: "human_approval_with_rationale", - label: "Review with your own words", + label: "human_required", }, }, - label: "Review with your own words", + label: "human_required", }, ]; @@ -1599,21 +1602,23 @@ describe("normalizeProposal (§2.6)", () => { } }); - it("does not derive daemon approval or lifecycle metadata from the staged envelope", () => { + it("binds daemon approval path variant and deadline to staged classification and root", () => { const { staged, daemon } = daemonContractFixture(); const { staged: vetoStaged, daemon: vetoDaemon } = vetoContractFixture(); - const authoritative = [ + const rationaleStaged = { + ...staged, + classification: { + ...staged.classification, + approval_path: { kind: "human_approval_with_rationale" }, + }, + }; + const mismatched = [ { - staged, + staged: rationaleStaged, summary: { ...daemon, - approvalPath: { - ...daemon.approvalPath, - variant: "human_approval_with_rationale", - label: "Review with your own words", - }, + proposalRevision: canonicalProposalRevision(rationaleStaged), }, - decisions: ["approve", "reject"], }, { staged: vetoStaged, @@ -1621,31 +1626,205 @@ describe("normalizeProposal (§2.6)", () => { ...vetoDaemon, approvalPath: { ...vetoDaemon.approvalPath, - variant: "auto_regression", - label: "Automatic after veto", veto_deadline: "2026-07-15T09:30:03Z", }, }, - decisions: ["reject"], }, { staged, summary: { ...daemon, + approvalPath: { + ...daemon.approvalPath, + variant: "human_approval_with_rationale", + label: "human_required", + veto_deadline: null, + }, + }, + }, + ]; + + for (const { staged: candidate, summary } of mismatched) { + assert.deepEqual(normalizeProposalAuthority(candidate, summary), { + state: "blocked", + why: "daemon-mismatch", + }); + } + }); + + it("requires root deadline presence to match whether the staged approval path has a veto", () => { + const { staged, daemon } = daemonContractFixture(); + const { staged: vetoStaged, daemon: vetoDaemon } = vetoContractFixture(); + const autoWithNoVeto = { + ...staged, + classification: { + ...staged.classification, + approval_path: { kind: "auto_regression", veto: null }, + }, + lifecycle: { state: "ready_for_replay" }, + veto_deadline: "2026-07-15T09:30:02Z", + }; + const humanWithClose = { + ...staged, + earliest_close: "2026-07-15T09:05:02Z", + }; + const vetoWithoutDeadline = { + ...vetoStaged, + veto_deadline: null, + }; + const vetoWithoutEarliestClose = { + ...vetoStaged, + earliest_close: null, + }; + const cases = [ + { + staged: autoWithNoVeto, + summary: { + ...daemon, + proposalRevision: canonicalProposalRevision(autoWithNoVeto), + approvalPath: { + ...daemon.approvalPath, + variant: "auto_regression", + label: "auto", + veto_deadline: "2026-07-15T09:30:02Z", + }, lifecycle: "ready_for_replay", }, - decisions: [], + }, + { + staged: humanWithClose, + summary: { + ...daemon, + proposalRevision: canonicalProposalRevision(humanWithClose), + earliestClose: "2026-07-15T09:05:02Z", + }, + }, + { + staged: vetoWithoutDeadline, + summary: { + ...vetoDaemon, + proposalRevision: canonicalProposalRevision(vetoWithoutDeadline), + approvalPath: { ...vetoDaemon.approvalPath, veto_deadline: null }, + }, + }, + { + staged: vetoWithoutEarliestClose, + summary: { + ...vetoDaemon, + proposalRevision: canonicalProposalRevision(vetoWithoutEarliestClose), + earliestClose: null, + }, }, ]; - for (const { staged: candidate, summary, decisions } of authoritative) { - const authority = normalizeProposalAuthority(candidate, summary); - assert.equal(authority.state, "verified"); - if (authority.state !== "verified") continue; - assert.deepEqual(authority.availableDecisions, decisions); + for (const testCase of cases) { + assert.deepEqual(normalizeProposalAuthority(testCase.staged, testCase.summary), { + state: "blocked", + why: "daemon-mismatch", + }); } }); + it("requires veto deadlines to equal staged_at plus duration and min_visible exactly", () => { + const { staged, daemon } = vetoContractFixture(); + const durationMismatch = { + ...staged, + veto_deadline: "2026-07-15T09:30:01.999999999Z", + }; + const minVisibleMismatch = { + ...staged, + earliest_close: "2026-07-15T09:05:02.000000001Z", + }; + const cases = [ + { + staged: durationMismatch, + summary: { + ...daemon, + proposalRevision: canonicalProposalRevision(durationMismatch), + approvalPath: { + ...daemon.approvalPath, + veto_deadline: "2026-07-15T09:30:01.999999999Z", + }, + }, + }, + { + staged: minVisibleMismatch, + summary: { + ...daemon, + proposalRevision: canonicalProposalRevision(minVisibleMismatch), + earliestClose: "2026-07-15T09:05:02.000000001Z", + }, + }, + ]; + + for (const testCase of cases) { + assert.deepEqual(normalizeProposalAuthority(testCase.staged, testCase.summary), { + state: "blocked", + why: "daemon-mismatch", + }); + } + }); + + it("derives veto deadlines with nanosecond precision across equivalent timezone offsets", () => { + const { staged: fixture, daemon } = vetoContractFixture(); + const stagedAt = [2026, 196, 9, 0, 2, 900_000_000, 0, 0, 0]; + const staged = { + ...fixture, + pending: { ...fixture.pending, staged_at: stagedAt }, + classification: { + ...fixture.classification, + classified_at: stagedAt, + approval_path: { + kind: "familiar_coherence", + veto: { + duration: { secs: 1800, nanos: 200_000_000 }, + min_visible: { secs: 300, nanos: 300_000_000 }, + }, + }, + }, + staged_at: stagedAt, + veto_deadline: "2026-07-15T11:30:03.1+02:00", + earliest_close: "2026-07-15T04:05:03.2-05:00", + }; + const summary = { + ...daemon, + stagedAt: "2026-07-15T04:00:02.9-05:00", + proposalRevision: canonicalProposalRevision(staged), + approvalPath: { + ...daemon.approvalPath, + veto_deadline: "2026-07-15T09:30:03.1Z", + }, + earliestClose: "2026-07-15T11:05:03.2+02:00", + }; + + assert.equal(normalizeProposalAuthority(staged, summary).state, "verified"); + }); + + it("does not let a daemon summary downgrade a staged rationale path", () => { + const { staged, daemon } = daemonContractFixture(); + const rationaleStaged = { + ...staged, + classification: { + ...staged.classification, + approval_path: { kind: "human_approval_with_rationale" }, + }, + }; + const downgraded = { + ...daemon, + proposalRevision: canonicalProposalRevision(rationaleStaged), + approvalPath: { + ...daemon.approvalPath, + variant: "human_approval", + label: "human_review", + }, + }; + + assert.deepEqual(normalizeProposalAuthority(rationaleStaged, downgraded), { + state: "blocked", + why: "daemon-mismatch", + }); + }); + it("accepts a nullable blocked reason and exposes no blocked actions", () => { const { staged, daemon } = daemonContractFixture(); const authority = normalizeProposalAuthority(staged, { @@ -1713,24 +1892,38 @@ describe("normalizeProposal (§2.6)", () => { assert.deepEqual(authority.availableDecisions, []); }); - it("treats earliestClose as independent display data", () => { + it("binds daemon earliestClose to the staged derived instant, including null presence", () => { const { staged, daemon } = daemonContractFixture(); + const { staged: vetoStaged, daemon: vetoDaemon } = vetoContractFixture(); const summaries = [ { - ...daemon, - earliestClose: "2026-07-15T09:05:00Z", + staged: vetoStaged, + summary: { + ...vetoDaemon, + earliestClose: null, + }, }, { - ...daemon, - earliestClose: null, + staged: vetoStaged, + summary: { + ...vetoDaemon, + earliestClose: "2026-07-15T09:05:02.000000001Z", + }, + }, + { + staged, + summary: { + ...daemon, + earliestClose: "2026-07-15T09:05:02Z", + }, }, ]; - for (const [index, summary] of summaries.entries()) { - assert.equal( - normalizeProposal(`phase5-independent-close-${index}.json`, staged, summary).authority.state, - "verified", - `independent earliestClose case ${index}`, + for (const [index, testCase] of summaries.entries()) { + assert.deepEqual( + normalizeProposal(`phase5-bound-close-${index}.json`, testCase.staged, testCase.summary).authority, + { state: "blocked", why: "daemon-mismatch" }, + `bound earliestClose case ${index}`, ); } });