diff --git a/app/api/pandora/promotion-executions/[id]/rollback/route.ts b/app/api/pandora/promotion-executions/[id]/rollback/route.ts new file mode 100644 index 0000000..94f254a --- /dev/null +++ b/app/api/pandora/promotion-executions/[id]/rollback/route.ts @@ -0,0 +1,6 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { assertNoClientUserIdOverride, resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { rollbackPromotionExecution, type PromotionExecutionDbClient } from "@/lib/services/pandora-promotion-execution-service"; +export const dynamic = "force-dynamic"; +export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { let body: unknown; try { body = await request.json(); } catch { body = {}; } const rejected = await assertNoClientUserIdOverride(request, body); if (rejected) return NextResponse.json({ ok:false, blockers: rejected.blockers }, { status: 400 }); const session = await resolvePandoraServerSession({ request }); if (!session.ok) return NextResponse.json({ ok:false, blockers: session.blockers }, { status: 401 }); try { const { id } = await context.params; const confirmation = typeof (body as Record | null)?.confirmation === "string" ? String((body as Record).confirmation) : ""; const supabase = await createSupabaseServerClient(); const execution = await rollbackPromotionExecution(supabase as unknown as PromotionExecutionDbClient, { userId: session.session.userId, executionId: id, confirmation }); return NextResponse.json({ ok:true, execution }); } catch(e) { const message = e instanceof Error ? e.message : "Unable to roll back promotion"; return NextResponse.json({ ok:false, error: message }, { status: message.startsWith("promotion_execution_disabled") ? 403 : 400 }); } } diff --git a/app/api/pandora/promotion-executions/route.ts b/app/api/pandora/promotion-executions/route.ts new file mode 100644 index 0000000..af2478f --- /dev/null +++ b/app/api/pandora/promotion-executions/route.ts @@ -0,0 +1,6 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { assertNoClientUserIdOverride, resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { listPromotionExecutions, type PromotionExecutionDbClient } from "@/lib/services/pandora-promotion-execution-service"; +export const dynamic = "force-dynamic"; +export async function GET(request: NextRequest) { const rejected = await assertNoClientUserIdOverride(request, {}); if (rejected) return NextResponse.json({ ok:false, blockers: rejected.blockers }, { status: 400 }); const session = await resolvePandoraServerSession({ request }); if (!session.ok) return NextResponse.json({ ok:false, blockers: session.blockers }, { status: 401 }); try { const supabase = await createSupabaseServerClient(); const executions = await listPromotionExecutions(supabase as unknown as PromotionExecutionDbClient, { userId: session.session.userId }); return NextResponse.json({ ok:true, executions }); } catch(e) { return NextResponse.json({ ok:false, error: e instanceof Error ? e.message : "Unable to list promotion executions" }, { status: 400 }); } } diff --git a/app/api/pandora/promotion-requests/[id]/execution/dry-run/route.ts b/app/api/pandora/promotion-requests/[id]/execution/dry-run/route.ts new file mode 100644 index 0000000..c008ac8 --- /dev/null +++ b/app/api/pandora/promotion-requests/[id]/execution/dry-run/route.ts @@ -0,0 +1,6 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { assertNoClientUserIdOverride, resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { dryRunPromotionExecution, type PromotionExecutionDbClient } from "@/lib/services/pandora-promotion-execution-service"; +export const dynamic = "force-dynamic"; +export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { let body: unknown; try { body = await request.json(); } catch { body = {}; } const rejected = await assertNoClientUserIdOverride(request, body); if (rejected) return NextResponse.json({ ok:false, blockers: rejected.blockers }, { status: 400 }); const session = await resolvePandoraServerSession({ request }); if (!session.ok) return NextResponse.json({ ok:false, blockers: session.blockers }, { status: 401 }); try { const { id } = await context.params; const supabase = await createSupabaseServerClient(); const dryRun = await dryRunPromotionExecution(supabase as unknown as PromotionExecutionDbClient, { userId: session.session.userId, promotionRequestId: id }); return NextResponse.json({ ok:true, dryRun, no_promotion_performed:true, no_core_memory_mutation_performed:true }); } catch(e) { return NextResponse.json({ ok:false, error: e instanceof Error ? e.message : "Unable to dry-run promotion execution" }, { status: 400 }); } } diff --git a/app/api/pandora/promotion-requests/[id]/execution/route.ts b/app/api/pandora/promotion-requests/[id]/execution/route.ts new file mode 100644 index 0000000..ad4ea7f --- /dev/null +++ b/app/api/pandora/promotion-requests/[id]/execution/route.ts @@ -0,0 +1,6 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { assertNoClientUserIdOverride, resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { executeApprovedPromotion, type PromotionExecutionDbClient } from "@/lib/services/pandora-promotion-execution-service"; +export const dynamic = "force-dynamic"; +export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { let body: unknown; try { body = await request.json(); } catch { body = {}; } const rejected = await assertNoClientUserIdOverride(request, body); if (rejected) return NextResponse.json({ ok:false, blockers: rejected.blockers }, { status: 400 }); const session = await resolvePandoraServerSession({ request }); if (!session.ok) return NextResponse.json({ ok:false, blockers: session.blockers }, { status: 401 }); try { const { id } = await context.params; const confirmation = typeof (body as Record | null)?.confirmation === "string" ? String((body as Record).confirmation) : ""; const supabase = await createSupabaseServerClient(); const execution = await executeApprovedPromotion(supabase as unknown as PromotionExecutionDbClient, { userId: session.session.userId, promotionRequestId: id, confirmation }); return NextResponse.json({ ok:true, execution }); } catch(e) { const message = e instanceof Error ? e.message : "Unable to execute promotion"; return NextResponse.json({ ok:false, error: message }, { status: message.startsWith("promotion_execution_disabled") ? 403 : 400 }); } } diff --git a/docs/pandora-promotion-executor.md b/docs/pandora-promotion-executor.md new file mode 100644 index 0000000..2f09591 --- /dev/null +++ b/docs/pandora-promotion-executor.md @@ -0,0 +1,71 @@ +# Pandora Promotion Executor v1 + +The promotion executor is the final, gated stage of the shadow-pack promotion chain: + +Shadow Context Pack Lab → Shadow Pack Preflight → Promotion Request Board → **Promotion Executor**. + +It turns a human-approved promotion request into an actual master context-pack swap — and +nothing else. + +## Double gate + +Execution refuses unless ALL of the following hold: + +1. `PANDORA_ENABLE_CONTEXT_PACK_PROMOTION=true` (dangerous gate, defaults to false, optional — + never a required provider env, so an unset value never triggers RED drift). +2. The promotion request is `approved` with `reviewer_decision=approved`. +3. A fresh plan recomputation (live preflight, shadow pack, and active master) has zero blockers. + In particular: the preflight is still `approved_for_promotion`, risk is not `blocked`, the + shadow pack is not rejected/archived, and the active master is still the exact pack recorded + at approval time — if the master changed since approval, execution refuses and demands a new + preflight + approval cycle. +4. The request body carries the explicit confirmation phrase `"PROMOTE"` (`"ROLLBACK"` for + rollbacks). + +## What execution does + +Status-only and reversible, in this order: + +1. Insert a new `memory_context_packs` row (`pack_type=master`, `status=active`) built from the + reviewed shadow candidate payload. +2. Archive the previous active master(s) for the same `(user_id, namespace, pack_type)` — + `status=archived`, never deleted. This preserves the one-active-master invariant. +3. Mark the promotion request `promoted`. +4. Record an execution row, execution events, a promotion-request event, and an `audit_logs` + entry. + +## What it never does + +- Never deletes any row anywhere. +- Never touches `memory_events`, `memory_items`, `memory_profiles`, capture candidates, or + pruning candidates. +- Never crosses namespaces (`real_life` promotion cannot touch `au` packs and vice versa). +- Never uses service-role/admin clients; all writes go through the authenticated server client + under RLS with server-derived identity. Client-supplied `user_id` is rejected. + +## Rollback + +`POST /api/pandora/promotion-executions/[id]/rollback` (gated, confirmation `"ROLLBACK"`): +archives the promoted pack and restores the previous master to `active`. Also status-only. + +## Routes + +- `POST /api/pandora/promotion-requests/[id]/execution/dry-run` — pure compute, no writes, + works with the gate off; returns plan, blockers, warnings, `gate_enabled`, `executable`. +- `POST /api/pandora/promotion-requests/[id]/execution` — gated execute. +- `GET /api/pandora/promotion-executions` — list own executions. +- `POST /api/pandora/promotion-executions/[id]/rollback` — gated rollback. + +## Rollout sequence (per skill 07) + +1. PR reviewed and merged (this feature must not be self-merged by its author). +2. Migration `pandora_promotion_executor` applied. +3. Production deployed READY. +4. Dry-run via the dry-run route on a real approved request; output reviewed. +5. Human sets `PANDORA_ENABLE_CONTEXT_PACK_PROMOTION=true` in the deployment env. +6. One controlled execution with confirmation phrase; post-run verification of the + one-active-master invariant and audit trail. +7. Gate may be turned back off between promotions. + +Until step 5, all UI banners saying "execution unavailable" remain accurate. When the gate is +enabled, update the Promotion Request Board banner copy in the same change. diff --git a/docs/roadmap.md b/docs/roadmap.md index 8ac3045..3b06c31 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -34,9 +34,10 @@ PRs #96, #99, #118, #119 were closed with evidence comments. 1. **Promotion executor** — the gated, human-approved path that promotes an approved shadow candidate to the active master. Chain: lab → preflight → request board (all live) → - execution (NOT built). Requires its own PR, migration/policy review, dry-run proof, and - explicit production approval. Until then every surface must keep saying - "execution unavailable". + execution. Executor v1 code exists in its own PR (see `docs/pandora-promotion-executor.md`); + it requires human review + merge, migration apply, dry-run proof, and the + `PANDORA_ENABLE_CONTEXT_PACK_PROMOTION` gate (default false) before anything can execute. + Until the gate is enabled every surface saying "execution unavailable" stays accurate. 2. **Phase 5D closure** per the execution rule in `CLAUDE.md`: protected dry-runs for `real_life` and `au`, human review of dry-run output, explicit approval before any `dryRun:false` run, post-run database verification. Pruning stays review-only regardless diff --git a/lib/services/env-discovery-service.ts b/lib/services/env-discovery-service.ts index 86b3360..11ad7c3 100644 --- a/lib/services/env-discovery-service.ts +++ b/lib/services/env-discovery-service.ts @@ -16,6 +16,8 @@ knownDefaults.PANDORA_ENABLE_MEMORY_USEFULNESS_SCORING = "false"; knownDefaults.PANDORA_ENABLE_MEMORY_PRUNING = "false"; knownDefaults.PANDORA_MEMORY_PRUNING_MODE = "review_only"; knownDefaults.PANDORA_MEMORY_SCORING_VERSION = "phase-5d-v1"; +// Promotion executor v1 gate. Dangerous, defaults to false; optional (never a required provider env). +knownDefaults.PANDORA_ENABLE_CONTEXT_PACK_PROMOTION = "false"; const mustRegister = [ "PANDORA_INTERNAL_JOB_TOKEN", "PANDORA_ENV_BROKER_ENABLED", "PANDORA_VERCEL_API_TOKEN", "PANDORA_ENV_VAULT_KEY", "NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY", "SUPABASE_SERVICE_ROLE_KEY", "SUPABASE_URL", "SUPABASE_ANON_KEY", "DATABASE_URL", "DIRECT_URL", "OPENAI_API_KEY", "OPENAI_PROJECT_ID", "OPENAI_ORG_ID", "NEXTAUTH_SECRET", "AUTH_SECRET", "NEXTAUTH_URL", "AUTH_URL", "SESSION_SECRET", "COOKIE_SECRET", diff --git a/lib/services/pandora-promotion-execution-service.ts b/lib/services/pandora-promotion-execution-service.ts new file mode 100644 index 0000000..0fd47bb --- /dev/null +++ b/lib/services/pandora-promotion-execution-service.ts @@ -0,0 +1,67 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { randomUUID } from "node:crypto"; +import type { PandoraNamespace } from "@/components/pandora/types"; +import { buildPromotionRequestPlan, getPromotionRequest, createPromotionRequestEvent, type PromotionRequestDbClient, type PromotionRequestRow } from "@/lib/services/pandora-promotion-request-service"; + +// Promotion executor v1. Execution is double-gated: PANDORA_ENABLE_CONTEXT_PACK_PROMOTION +// must be "true" AND the promotion request must be human-approved with a fresh, blocker-free +// plan AND the caller must pass the explicit confirmation phrase. Promotion is status-only on +// memory_context_packs (insert new master from the reviewed shadow candidate, archive the old +// master). Nothing is ever deleted; memory_events, memory_items, memory_profiles, capture and +// pruning candidates are never touched. Rollback restores the previous master the same way. + +export type PromotionExecutionDbClient = PromotionRequestDbClient; +export type PromotionExecutionMode = "execute" | "rollback"; +export type PromotionExecutionStatus = "executed" | "rolled_back" | "blocked" | "failed"; +export type PromotionExecutionRow = { id:string; user_id:string; request_id:string; namespace:PandoraNamespace; promotion_request_id:string; preflight_id:string; shadow_pack_id:string; previous_master_pack_id:string|null; promoted_pack_id:string|null; mode:PromotionExecutionMode; status:PromotionExecutionStatus; plan:Record; result:Record; warnings:string[]; created_at:string; updated_at:string; executed_at?:string|null; rolled_back_at?:string|null }; + +export const PROMOTION_EXECUTION_GATE = "PANDORA_ENABLE_CONTEXT_PACK_PROMOTION"; +export const PROMOTE_CONFIRMATION = "PROMOTE"; +export const ROLLBACK_CONFIRMATION = "ROLLBACK"; +export function isPromotionExecutionEnabled(env: Partial = process.env): boolean { return env[PROMOTION_EXECUTION_GATE] === "true"; } + +async function single(q:any){ const r=await q; if(r.error) return null; return Array.isArray(r.data)?r.data[0]??null:r.data??null; } +async function audit(client:PromotionExecutionDbClient,input:{userId:string; namespace:PandoraNamespace; action:string; recordId:string; metadata:Record}){ await client.from("audit_logs").insert({ user_id:input.userId, namespace:input.namespace, action:input.action, table_name:"memory_context_packs", record_id:input.recordId, after_snapshot:input.metadata, metadata:{...input.metadata, phase:"promotion_executor_v1", appendOnly:true} }).select("*").single(); } +async function createExecutionEvent(client:PromotionExecutionDbClient,input:{userId:string; executionId:string; promotionRequestId:string; eventType:string; message:string; metadata?:Record}){ await client.from("pandora_promotion_execution_events").insert({ id:randomUUID(), user_id:input.userId, execution_id:input.executionId, promotion_request_id:input.promotionRequestId, event_type:input.eventType, message:input.message, metadata:input.metadata??{}, created_at:new Date().toISOString() }).select("*").single(); } +async function insertExecutionRow(client:PromotionExecutionDbClient,row:Omit & {created_at?:string; updated_at?:string}):Promise{ const now=new Date().toISOString(); const created=await single(client.from("pandora_promotion_executions").insert({...row,created_at:row.created_at??now,updated_at:row.updated_at??now}).select("*").single()); if(!created) throw new Error("Unable to record promotion execution"); return created; } + +export async function listPromotionExecutions(client:PromotionExecutionDbClient,input:{userId:string; limit?:number}):Promise{ const r=await client.from("pandora_promotion_executions").select("*").eq("user_id",input.userId).order("created_at",{ascending:false}).limit(input.limit??20); return r.error?[]:(Array.isArray(r.data)?r.data:[]); } +export async function getPromotionExecution(client:PromotionExecutionDbClient,input:{userId:string; executionId:string}):Promise{ const row=await single(client.from("pandora_promotion_executions").select("*").eq("user_id",input.userId).eq("id",input.executionId).limit(1)); if(!row) throw new Error("Promotion execution not found for current user"); return row; } + +// Pure compute. Re-derives the plan from live preflight/shadow/master state and adds the +// execution-stage blockers on top of the request-stage ones. Performs no writes. +export async function buildPromotionExecutionPlan(client:PromotionExecutionDbClient,input:{userId:string; promotionRequestId:string}){ const request:PromotionRequestRow=await getPromotionRequest(client,input); const built=await buildPromotionRequestPlan(client,{userId:input.userId,preflightId:request.preflight_id,requestId:request.request_id}); const blockers=[...built.blockers]; const warnings=[...built.warnings]; if(request.status!=="approved") blockers.push(`Promotion request status is ${request.status}; only approved requests can execute.`); if(request.reviewer_decision!=="approved") blockers.push("Promotion request has no approved reviewer decision."); if(["rejected","archived"].includes(String(built.shadow.status))) blockers.push(`Shadow pack status is ${built.shadow.status}; execution refused.`); const liveMasterId=built.currentMaster?.id??null; if((request.active_master_pack_id??null)!==liveMasterId) blockers.push("Active master changed since approval; re-run preflight and re-approve before executing."); const plan={ plan_version:"promotion_executor_v1", namespace:request.namespace, promotion_request_id:request.id, preflight_id:request.preflight_id, shadow_pack_id:request.shadow_pack_id, previous_master_pack_id:liveMasterId, operation:"archive_active_master_then_insert_shadow_candidate_as_master", status_only:true, never_deletes:true, tables_touched:["memory_context_packs","pandora_promotion_requests","pandora_promotion_executions","pandora_promotion_execution_events","pandora_promotion_request_events","audit_logs"], blockers }; return { request, built, blockers, warnings:Array.from(new Set(warnings)), plan, previousMasterId:liveMasterId }; } + +export async function dryRunPromotionExecution(client:PromotionExecutionDbClient,input:{userId:string; promotionRequestId:string},env:Partial=process.env){ const computed=await buildPromotionExecutionPlan(client,input); return { mode:"dry_run" as const, gate_enabled:isPromotionExecutionEnabled(env), executable:computed.blockers.length===0&&isPromotionExecutionEnabled(env), blockers:computed.blockers, warnings:computed.warnings, plan:computed.plan, no_core_memory_mutation_performed:true, no_promotion_performed:true }; } + +function packFromShadowCandidate(shadow:{id:string; namespace:PandoraNamespace; title:string; summary:string; candidate_payload:Record}, userId:string){ const p=shadow.candidate_payload??{}; const arr=(v:unknown)=>Array.isArray(v)?v:[]; return { id:randomUUID(), user_id:userId, namespace:shadow.namespace, pack_type:"master", status:"active", title:typeof p.title==="string"&&p.title?p.title:shadow.title, summary:typeof p.summary==="string"&&p.summary?p.summary:shadow.summary, key_points:arr(p.key_points), active_projects:arr(p.active_projects), people_map:arr(p.people_map), decisions:arr(p.decisions), risks:arr(p.risks), open_loops:arr(p.open_loops), generated_from_event_ids:arr(p.generated_from_event_ids), created_at:new Date().toISOString(), updated_at:new Date().toISOString() }; } + +export async function executeApprovedPromotion(client:PromotionExecutionDbClient,input:{userId:string; promotionRequestId:string; confirmation:string},env:Partial=process.env):Promise{ + if(input.confirmation!==PROMOTE_CONFIRMATION) throw new Error(`Execution requires the explicit confirmation phrase "${PROMOTE_CONFIRMATION}".`); + if(!isPromotionExecutionEnabled(env)) throw new Error(`promotion_execution_disabled: set ${PROMOTION_EXECUTION_GATE}=true after review to enable execution.`); + const computed=await buildPromotionExecutionPlan(client,input); const { request }=computed; const now=new Date().toISOString(); + if(computed.blockers.length){ const blocked=await insertExecutionRow(client,{ id:randomUUID(), user_id:input.userId, request_id:request.request_id, namespace:request.namespace, promotion_request_id:request.id, preflight_id:request.preflight_id, shadow_pack_id:request.shadow_pack_id, previous_master_pack_id:computed.previousMasterId, promoted_pack_id:null, mode:"execute", status:"blocked", plan:computed.plan, result:{blockers:computed.blockers,no_core_memory_mutation_performed:true,no_promotion_performed:true}, warnings:computed.warnings }); await createExecutionEvent(client,{userId:input.userId,executionId:blocked.id,promotionRequestId:request.id,eventType:"promotion_execution_blocked",message:"Promotion execution blocked; no core memory mutation performed.",metadata:{blockers:computed.blockers}}); throw new Error(`Promotion execution blocked: ${computed.blockers.join("; ")}`); } + const promoted=await single(client.from("memory_context_packs").insert(packFromShadowCandidate(computed.built.shadow,input.userId)).select("*").single()); if(!promoted) throw new Error("Promotion failed: unable to insert new master context pack. No archive was performed."); + const archived=await (client.from("memory_context_packs").update({status:"archived",updated_at:now}).eq("user_id",input.userId).eq("namespace",request.namespace).eq("pack_type","master").eq("status","active").neq("id",promoted.id).select("id") as unknown as Promise<{data:unknown[]|null; error:{message:string}|null}>); + const warnings=[...computed.warnings]; if(archived.error) warnings.push(`archive_previous_master_failed: ${archived.error.message}`); + const updatedRequest=await single(client.from("pandora_promotion_requests").update({status:"promoted",updated_at:now}).eq("user_id",input.userId).eq("id",request.id).select("*").single()); if(!updatedRequest) warnings.push("promotion_request_status_update_failed"); + const execution=await insertExecutionRow(client,{ id:randomUUID(), user_id:input.userId, request_id:request.request_id, namespace:request.namespace, promotion_request_id:request.id, preflight_id:request.preflight_id, shadow_pack_id:request.shadow_pack_id, previous_master_pack_id:computed.previousMasterId, promoted_pack_id:promoted.id, mode:"execute", status:"executed", plan:computed.plan, result:{promoted_pack_id:promoted.id,previous_master_pack_id:computed.previousMasterId,archived_pack_ids:(archived.data??[]).map((r:any)=>r.id),status_only:true,never_deletes:true}, warnings, executed_at:now }); + await createExecutionEvent(client,{userId:input.userId,executionId:execution.id,promotionRequestId:request.id,eventType:"promotion_executed",message:"Shadow candidate promoted to active master. Previous master archived (status-only, reversible).",metadata:{promoted_pack_id:promoted.id,previous_master_pack_id:computed.previousMasterId}}); + await createPromotionRequestEvent(client,{userId:input.userId,promotionRequestId:request.id,shadowPackId:request.shadow_pack_id,preflightId:request.preflight_id,eventType:"promotion_executed",message:"Approved promotion request executed via gated executor.",metadata:{execution_id:execution.id,promoted_pack_id:promoted.id}}); + await audit(client,{userId:input.userId,namespace:request.namespace,action:"memory_context_pack_promoted",recordId:promoted.id,metadata:{execution_id:execution.id,promotion_request_id:request.id,previous_master_pack_id:computed.previousMasterId,status_only:true}}); + return execution; +} + +export async function rollbackPromotionExecution(client:PromotionExecutionDbClient,input:{userId:string; executionId:string; confirmation:string},env:Partial=process.env):Promise{ + if(input.confirmation!==ROLLBACK_CONFIRMATION) throw new Error(`Rollback requires the explicit confirmation phrase "${ROLLBACK_CONFIRMATION}".`); + if(!isPromotionExecutionEnabled(env)) throw new Error(`promotion_execution_disabled: set ${PROMOTION_EXECUTION_GATE}=true after review to enable rollback.`); + const execution=await getPromotionExecution(client,input); if(execution.mode!=="execute"||execution.status!=="executed") throw new Error(`Only executed promotions can be rolled back (found mode=${execution.mode}, status=${execution.status}).`); if(!execution.promoted_pack_id) throw new Error("Execution has no promoted pack to roll back."); + const now=new Date().toISOString(); + const archivedPromoted=await single(client.from("memory_context_packs").update({status:"archived",updated_at:now}).eq("user_id",input.userId).eq("id",execution.promoted_pack_id).select("*").single()); if(!archivedPromoted) throw new Error("Rollback failed: promoted pack not found for current user."); + const warnings:string[]=[]; + if(execution.previous_master_pack_id){ const restored=await single(client.from("memory_context_packs").update({status:"active",updated_at:now}).eq("user_id",input.userId).eq("id",execution.previous_master_pack_id).select("*").single()); if(!restored) warnings.push("restore_previous_master_failed"); } else warnings.push("no_previous_master_to_restore"); + const updated=await single(client.from("pandora_promotion_executions").update({status:"rolled_back",rolled_back_at:now,updated_at:now,warnings}).eq("user_id",input.userId).eq("id",execution.id).select("*").single()); if(!updated) throw new Error("Unable to record rollback on execution row"); + await createExecutionEvent(client,{userId:input.userId,executionId:execution.id,promotionRequestId:execution.promotion_request_id,eventType:"promotion_rolled_back",message:"Promotion rolled back: promoted pack archived, previous master restored (status-only, reversible).",metadata:{promoted_pack_id:execution.promoted_pack_id,previous_master_pack_id:execution.previous_master_pack_id}}); + await audit(client,{userId:input.userId,namespace:execution.namespace,action:"memory_context_pack_promotion_rolled_back",recordId:execution.promoted_pack_id,metadata:{execution_id:execution.id,previous_master_pack_id:execution.previous_master_pack_id,status_only:true}}); + return updated; +} diff --git a/supabase/migrations/20260716000000_pandora_promotion_executor.sql b/supabase/migrations/20260716000000_pandora_promotion_executor.sql new file mode 100644 index 0000000..86effa5 --- /dev/null +++ b/supabase/migrations/20260716000000_pandora_promotion_executor.sql @@ -0,0 +1,72 @@ +-- Pandora promotion executor v1. +-- Additive: widens the promotion request status set with 'promoted' and adds +-- execution/audit tables. Execution itself stays disabled unless +-- PANDORA_ENABLE_CONTEXT_PACK_PROMOTION=true AND the request is human-approved. +-- Promotion is status-only on memory_context_packs (archive old master, insert +-- new master from the reviewed shadow candidate); nothing is ever deleted and +-- memory_events / memory_items / memory_profiles are never touched. + +alter table public.pandora_promotion_requests drop constraint if exists pandora_promotion_requests_status_check; +alter table public.pandora_promotion_requests add constraint pandora_promotion_requests_status_check + check (status in ('draft','submitted','approved','blocked','archived','promoted')); + +create table if not exists public.pandora_promotion_executions ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null, + request_id text not null, + namespace text not null check (namespace in ('real_life','au')), + promotion_request_id uuid not null references public.pandora_promotion_requests(id) on delete restrict, + preflight_id uuid not null references public.pandora_shadow_pack_preflights(id) on delete restrict, + shadow_pack_id uuid not null references public.pandora_shadow_context_packs(id) on delete restrict, + previous_master_pack_id uuid null, + promoted_pack_id uuid null, + mode text not null check (mode in ('execute','rollback')), + status text not null check (status in ('executed','rolled_back','blocked','failed')), + plan jsonb not null default '{}'::jsonb, + result jsonb not null default '{}'::jsonb, + warnings text[] not null default '{}'::text[], + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + executed_at timestamptz null, + rolled_back_at timestamptz null +); + +create index if not exists pandora_promotion_executions_user_created_idx on public.pandora_promotion_executions (user_id, created_at desc); +create index if not exists pandora_promotion_executions_user_request_idx on public.pandora_promotion_executions (user_id, promotion_request_id); +create index if not exists pandora_promotion_executions_user_namespace_status_idx on public.pandora_promotion_executions (user_id, namespace, status); + +create table if not exists public.pandora_promotion_execution_events ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null, + execution_id uuid not null references public.pandora_promotion_executions(id) on delete cascade, + promotion_request_id uuid not null references public.pandora_promotion_requests(id) on delete restrict, + event_type text not null, + message text not null, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create index if not exists pandora_promotion_execution_events_user_execution_created_idx on public.pandora_promotion_execution_events (user_id, execution_id, created_at desc); + +create or replace function public.set_pandora_promotion_executions_updated_at() +returns trigger language plpgsql as $$ +begin + new.updated_at = now(); + return new; +end; +$$; + +drop trigger if exists set_pandora_promotion_executions_updated_at on public.pandora_promotion_executions; +create trigger set_pandora_promotion_executions_updated_at +before update on public.pandora_promotion_executions +for each row execute function public.set_pandora_promotion_executions_updated_at(); + +alter table public.pandora_promotion_executions enable row level security; +alter table public.pandora_promotion_execution_events enable row level security; + +create policy "pandora_promotion_executions_select_own" on public.pandora_promotion_executions for select to authenticated using (user_id = auth.uid()); +create policy "pandora_promotion_executions_insert_own" on public.pandora_promotion_executions for insert to authenticated with check (user_id = auth.uid()); +create policy "pandora_promotion_executions_update_own" on public.pandora_promotion_executions for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid()); + +create policy "pandora_promotion_execution_events_select_own" on public.pandora_promotion_execution_events for select to authenticated using (user_id = auth.uid()); +create policy "pandora_promotion_execution_events_insert_own" on public.pandora_promotion_execution_events for insert to authenticated with check (user_id = auth.uid()); diff --git a/tests/unit/pandora-operator-action-guards.test.ts b/tests/unit/pandora-operator-action-guards.test.ts index 849086f..c6377e3 100644 --- a/tests/unit/pandora-operator-action-guards.test.ts +++ b/tests/unit/pandora-operator-action-guards.test.ts @@ -6,6 +6,24 @@ describe("operator action center safety guards", () => { it("does not render dangerous live action buttons", () => { const text = files.map((f)=>readFileSync(f,"utf8")).join("\n"); for (const bad of ["Promote live", "Replace master", "Execute promotion", "Delete memory", "Prune now", "Merge now", "Distill now"]) expect(text).not.toContain(bad); }); it("service does not import service-role/admin clients or mutate core memory tables", () => { const text=readFileSync("lib/services/pandora-operator-action-service.ts","utf8")+readFileSync("lib/services/pandora-shadow-context-pack-service.ts","utf8")+readFileSync("lib/services/pandora-promotion-request-service.ts","utf8"); expect(text).not.toContain("service-role"); expect(text).not.toContain("createSupabaseBridgeAdminClient"); expect(text).not.toContain(".delete("); for (const table of ["memory_events","memory_context_packs","memory_profiles","memory_capture_candidates","memory_pruning_candidates"]) expect(text).not.toMatch(new RegExp(`from\\(\\\"${table}\\\"\\).*\\.(insert|update|delete)`)); expect(text).toContain("no_core_memory_mutation_performed: true"); }); it("production pandora path does not import mock-data and route identity rejects client user ids", () => { expect(readFileSync("app/pandora/page.tsx","utf8")).not.toContain("mock-data"); const route=readFileSync("app/api/pandora/operator-actions/route.ts","utf8")+readFileSync("app/api/pandora/shadow-context-packs/route.ts","utf8")+readFileSync("app/api/pandora/shadow-context-packs/[id]/review/route.ts","utf8")+readFileSync("app/api/pandora/promotion-requests/route.ts","utf8")+readFileSync("app/api/pandora/promotion-requests/[id]/review/route.ts","utf8"); expect(route).toContain("assertNoClientUserIdOverride"); expect(route).not.toContain("searchParams.get(\"user_id\")"); expect(route).not.toContain("body.user_id"); }); - it("does not add promotion execution routes", () => { const routes = ["app/api/pandora/promotion-requests/[id]/execute/route.ts", "app/api/pandora/promotion-requests/[id]/promote/route.ts"]; for (const route of routes) expect(existsSync(route)).toBe(false); }); + it("promotion execution exists only behind the env gate + confirmation phrase, and never deletes", () => { + // The un-gated legacy execution paths must never exist. + for (const route of ["app/api/pandora/promotion-requests/[id]/execute/route.ts", "app/api/pandora/promotion-requests/[id]/promote/route.ts"]) expect(existsSync(route)).toBe(false); + // The gated executor (promotion executor v1) must enforce the gate and confirmation phrases, + // must not use service-role/admin clients, must never call .delete(, and may write only + // memory_context_packs among core memory tables (status-only promotion/rollback). + const svc = readFileSync("lib/services/pandora-promotion-execution-service.ts", "utf8"); + expect(svc).toContain("PANDORA_ENABLE_CONTEXT_PACK_PROMOTION"); + expect(svc).toContain('PROMOTE_CONFIRMATION = "PROMOTE"'); + expect(svc).toContain('ROLLBACK_CONFIRMATION = "ROLLBACK"'); + expect(svc).not.toContain("service-role"); + expect(svc).not.toContain("createSupabaseBridgeAdminClient"); + expect(svc).not.toContain(".delete("); + for (const table of ["memory_events", "memory_items", "memory_profiles", "memory_capture_candidates", "memory_pruning_candidates"]) expect(svc).not.toMatch(new RegExp(`from\\(\\"${table}\\"\\)`)); + // Execution routes must derive identity server-side and reject client user ids. + const routes = readFileSync("app/api/pandora/promotion-requests/[id]/execution/route.ts", "utf8") + readFileSync("app/api/pandora/promotion-requests/[id]/execution/dry-run/route.ts", "utf8") + readFileSync("app/api/pandora/promotion-executions/[id]/rollback/route.ts", "utf8"); + expect(routes).toContain("assertNoClientUserIdOverride"); + expect(routes).not.toContain("body.user_id"); + }); it("retrieval eval still has no fabricated accuracy", () => { const text=readFileSync("lib/services/pandora-verification-service.ts","utf8")+readFileSync("lib/services/pandora-dashboard-service.ts","utf8"); expect(text).not.toContain("94.3"); expect(text).not.toContain("fake accuracy"); expect(text).not.toContain("retrieval accuracy: 100%"); }); }); diff --git a/tests/unit/pandora-promotion-execution-service.test.ts b/tests/unit/pandora-promotion-execution-service.test.ts new file mode 100644 index 0000000..212531d --- /dev/null +++ b/tests/unit/pandora-promotion-execution-service.test.ts @@ -0,0 +1,136 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, expect, it } from "vitest"; +import { dryRunPromotionExecution, executeApprovedPromotion, rollbackPromotionExecution, PROMOTION_EXECUTION_GATE } from "@/lib/services/pandora-promotion-execution-service"; + +const USER = "11111111-1111-4111-8111-111111111111"; +type Row = Record; +type Store = Record; + +// In-memory client: eq/neq-filtered reads, inserts append, updates patch every matching row. +function client(store: Store, ops: any[] = []) { + return { from(table: string) { + const eqs: Record = {}; const neqs: Record = {}; let lim = Infinity; let mode: "select" | "insert" | "update" = "select"; let patch: Row | null = null; let inserted: Row[] = []; + const src = () => (store[table] ??= []); + const matches = (r: Row) => Object.entries(eqs).every(([k, v]) => r[k] === v) && Object.entries(neqs).every(([k, v]) => r[k] !== v); + const rows = () => src().filter(matches).slice(0, lim); + const b: any = { + select() { return b; }, order() { return b; }, single() { return b; }, + eq(k: string, v: any) { eqs[k] = v; return b; }, neq(k: string, v: any) { neqs[k] = v; return b; }, limit(n: number) { lim = n; return b; }, + insert(value: Row | Row[]) { mode = "insert"; inserted = Array.isArray(value) ? value : [value]; return b; }, + update(value: Row) { mode = "update"; patch = value; return b; }, + then(res: any, rej: any) { + if (mode === "insert") { src().push(...inserted); return Promise.resolve({ data: inserted, error: null }).then(res, rej); } + if (mode === "update") { const affected = rows(); for (const r of affected) Object.assign(r, patch); ops.push({ table, patch, eqs: { ...eqs }, neqs: { ...neqs }, ids: affected.map((r) => r.id) }); return Promise.resolve({ data: affected, error: null }).then(res, rej); } + return Promise.resolve({ data: rows(), error: null }).then(res, rej); + }, + }; + return b; + } } as any; +} + +function store(overrides: { requestStatus?: string; reviewerDecision?: string | null; requestMasterId?: string | null; liveMaster?: boolean } = {}): Store { + const { requestStatus = "approved", reviewerDecision = "approved", requestMasterId = "m1", liveMaster = true } = overrides; + return { + pandora_shadow_context_packs: [{ id: "s1", user_id: USER, request_id: "req", namespace: "real_life", pack_type: "master_candidate", status: "reviewed", title: "Shadow RL", summary: "shadow summary", source_window: {}, candidate_payload: { title: "Promoted RL master", summary: "promoted summary", key_points: ["kp"], active_projects: [], people_map: [], decisions: [], risks: [], open_loops: [], generated_from_event_ids: ["e1"] }, evidence: {}, warnings: [], created_at: "2026-01-01", updated_at: "2026-01-01" }], + pandora_shadow_pack_preflights: [{ id: "p1", user_id: USER, shadow_pack_id: "s1", namespace: "real_life", active_master_pack_id: "m1", request_id: "pre", status: "approved_for_promotion", diff_summary: { has_active_master: true }, risk_summary: { status: "low", score: 10, reasons: [], blockers: [], warnings: [] }, reviewer_notes: "", reviewer_decision: "approved_for_promotion", warnings: [], created_at: "2026-01-01", updated_at: "2026-01-01" }], + pandora_promotion_requests: [{ id: "r1", user_id: USER, request_id: "req-1", namespace: "real_life", shadow_pack_id: "s1", preflight_id: "p1", active_master_pack_id: requestMasterId, status: requestStatus, title: "t", summary: "s", promotion_plan: {}, rollback_plan: {}, risk_snapshot: {}, diff_snapshot: {}, reviewer_notes: "", reviewer_decision: reviewerDecision, warnings: [], created_at: "2026-01-01", updated_at: "2026-01-01" }], + pandora_promotion_request_events: [], pandora_promotion_executions: [], pandora_promotion_execution_events: [], + memory_context_packs: [ + ...(liveMaster ? [{ id: "m1", user_id: USER, namespace: "real_life", pack_type: "master", status: "active", title: "old master", summary: "old" }] : []), + { id: "au-m", user_id: USER, namespace: "au", pack_type: "master", status: "active", title: "au master", summary: "au" }, + ], + memory_events: [], memory_profiles: [], memory_capture_candidates: [], memory_pruning_candidates: [], audit_logs: [], + }; +} + +const gateOn = { [PROMOTION_EXECUTION_GATE]: "true" } as any; +const gateOff = {} as any; + +describe("promotion execution service", () => { + it("gate defaults off: execute and rollback refuse and mutate nothing", async () => { + const s = store(); + await expect(executeApprovedPromotion(client(s), { userId: USER, promotionRequestId: "r1", confirmation: "PROMOTE" }, gateOff)).rejects.toThrow(/promotion_execution_disabled/); + await expect(rollbackPromotionExecution(client(s), { userId: USER, executionId: "x", confirmation: "ROLLBACK" }, gateOff)).rejects.toThrow(/promotion_execution_disabled/); + expect(s.memory_context_packs.filter((p) => p.status === "active")).toHaveLength(2); + expect(s.pandora_promotion_executions).toHaveLength(0); + }); + + it("requires the explicit confirmation phrase even with the gate on", async () => { + const s = store(); + await expect(executeApprovedPromotion(client(s), { userId: USER, promotionRequestId: "r1", confirmation: "yes" }, gateOn)).rejects.toThrow(/confirmation phrase/); + expect(s.memory_context_packs).toHaveLength(2); + expect(s.pandora_promotion_executions).toHaveLength(0); + }); + + it("dry run computes an executable plan without writing anything", async () => { + const s = store(); + const before = JSON.stringify(s.memory_context_packs); + const dry = await dryRunPromotionExecution(client(s), { userId: USER, promotionRequestId: "r1" }, gateOff); + expect(dry.blockers).toHaveLength(0); + expect(dry.gate_enabled).toBe(false); + expect(dry.executable).toBe(false); // plan is clean but the gate is off + expect((dry.plan as any).status_only).toBe(true); + expect(JSON.stringify(s.memory_context_packs)).toBe(before); + expect(s.pandora_promotion_executions).toHaveLength(0); + expect(s.audit_logs).toHaveLength(0); + }); + + it("executes an approved promotion: new master from candidate, old archived, request promoted, audit written", async () => { + const s = store(); + const execution = await executeApprovedPromotion(client(s), { userId: USER, promotionRequestId: "r1", confirmation: "PROMOTE" }, gateOn); + expect(execution.status).toBe("executed"); + expect(execution.previous_master_pack_id).toBe("m1"); + const rlMasters = s.memory_context_packs.filter((p) => p.namespace === "real_life" && p.pack_type === "master"); + const active = rlMasters.filter((p) => p.status === "active"); + expect(active).toHaveLength(1); + expect(active[0].id).toBe(execution.promoted_pack_id); + expect(active[0].title).toBe("Promoted RL master"); + expect(s.memory_context_packs.find((p) => p.id === "m1")!.status).toBe("archived"); // archived, not deleted + expect(s.memory_context_packs.find((p) => p.id === "au-m")!.status).toBe("active"); // other namespace untouched + expect(s.pandora_promotion_requests[0].status).toBe("promoted"); + expect(s.pandora_promotion_execution_events.map((e) => e.event_type)).toContain("promotion_executed"); + expect(s.audit_logs.map((a) => a.action)).toContain("memory_context_pack_promoted"); + expect(s.memory_events).toHaveLength(0); + expect(s.memory_profiles).toHaveLength(0); + }); + + it("blocks execution when the request is not approved, recording a blocked execution row", async () => { + const s = store({ requestStatus: "submitted", reviewerDecision: null }); + await expect(executeApprovedPromotion(client(s), { userId: USER, promotionRequestId: "r1", confirmation: "PROMOTE" }, gateOn)).rejects.toThrow(/blocked/); + expect(s.memory_context_packs.filter((p) => p.status === "active")).toHaveLength(2); + expect(s.pandora_promotion_executions).toHaveLength(1); + expect(s.pandora_promotion_executions[0].status).toBe("blocked"); + }); + + it("blocks execution when the active master changed since approval", async () => { + const s = store(); + s.memory_context_packs.find((p) => p.id === "m1")!.status = "archived"; + s.memory_context_packs.push({ id: "m2", user_id: USER, namespace: "real_life", pack_type: "master", status: "active", title: "newer", summary: "n" }); + await expect(executeApprovedPromotion(client(s), { userId: USER, promotionRequestId: "r1", confirmation: "PROMOTE" }, gateOn)).rejects.toThrow(/Active master changed/); + expect(s.memory_context_packs.find((p) => p.id === "m2")!.status).toBe("active"); + }); + + it("rolls back an executed promotion: promoted archived, previous master restored", async () => { + const s = store(); + const c = client(s); + const execution = await executeApprovedPromotion(c, { userId: USER, promotionRequestId: "r1", confirmation: "PROMOTE" }, gateOn); + const rolledBack = await rollbackPromotionExecution(c, { userId: USER, executionId: execution.id, confirmation: "ROLLBACK" }, gateOn); + expect(rolledBack.status).toBe("rolled_back"); + expect(s.memory_context_packs.find((p) => p.id === execution.promoted_pack_id)!.status).toBe("archived"); + expect(s.memory_context_packs.find((p) => p.id === "m1")!.status).toBe("active"); + const active = s.memory_context_packs.filter((p) => p.namespace === "real_life" && p.pack_type === "master" && p.status === "active"); + expect(active).toHaveLength(1); + expect(s.audit_logs.map((a) => a.action)).toContain("memory_context_pack_promotion_rolled_back"); + await expect(rollbackPromotionExecution(c, { userId: USER, executionId: execution.id, confirmation: "ROLLBACK" }, gateOn)).rejects.toThrow(/Only executed promotions/); + }); + + it("never deletes rows from any table it touches", async () => { + const s = store(); + const c = client(s); + const totalRows = () => Object.values(s).reduce((n, rows) => n + rows.length, 0); + const before = totalRows(); + const execution = await executeApprovedPromotion(c, { userId: USER, promotionRequestId: "r1", confirmation: "PROMOTE" }, gateOn); + await rollbackPromotionExecution(c, { userId: USER, executionId: execution.id, confirmation: "ROLLBACK" }, gateOn); + expect(totalRows()).toBeGreaterThanOrEqual(before); // rows are only ever added or re-statused + }); +});