Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/api/pandora/promotion-executions/[id]/rollback/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null)?.confirmation === "string" ? String((body as Record<string, unknown>).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 }); } }
6 changes: 6 additions & 0 deletions app/api/pandora/promotion-executions/route.ts
Original file line number Diff line number Diff line change
@@ -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 }); } }
Original file line number Diff line number Diff line change
@@ -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 }); } }
6 changes: 6 additions & 0 deletions app/api/pandora/promotion-requests/[id]/execution/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null)?.confirmation === "string" ? String((body as Record<string, unknown>).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 }); } }
71 changes: 71 additions & 0 deletions docs/pandora-promotion-executor.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 4 additions & 3 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/services/env-discovery-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading