diff --git a/app/api/pandora/shadow-context-packs/[id]/archive/route.ts b/app/api/pandora/shadow-context-packs/[id]/archive/route.ts new file mode 100644 index 0000000..986fc0b --- /dev/null +++ b/app/api/pandora/shadow-context-packs/[id]/archive/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 { updateShadowContextPackStatus, type ShadowContextPackDbClient } from "@/lib/services/pandora-shadow-context-pack-service"; +export const dynamic = "force-dynamic"; +export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { const body = await request.json().catch(() => ({})); 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 pack = await updateShadowContextPackStatus(supabase as unknown as ShadowContextPackDbClient, { userId: session.session.userId, shadowPackId: id, status: "archived", reason: typeof body.reason === "string" ? body.reason : undefined }); return NextResponse.json({ ok: true, pack, no_core_memory_mutation_performed: true, no_promotion_performed: true }); } catch (e) { return NextResponse.json({ ok: false, error: e instanceof Error ? e.message : "Shadow pack update failed" }, { status: 400 }); } } diff --git a/app/api/pandora/shadow-context-packs/[id]/reject/route.ts b/app/api/pandora/shadow-context-packs/[id]/reject/route.ts new file mode 100644 index 0000000..dafaafa --- /dev/null +++ b/app/api/pandora/shadow-context-packs/[id]/reject/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 { updateShadowContextPackStatus, type ShadowContextPackDbClient } from "@/lib/services/pandora-shadow-context-pack-service"; +export const dynamic = "force-dynamic"; +export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { const body = await request.json().catch(() => ({})); 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 pack = await updateShadowContextPackStatus(supabase as unknown as ShadowContextPackDbClient, { userId: session.session.userId, shadowPackId: id, status: "rejected", reason: typeof body.reason === "string" ? body.reason : undefined }); return NextResponse.json({ ok: true, pack, no_core_memory_mutation_performed: true, no_promotion_performed: true }); } catch (e) { return NextResponse.json({ ok: false, error: e instanceof Error ? e.message : "Shadow pack update failed" }, { status: 400 }); } } diff --git a/app/api/pandora/shadow-context-packs/[id]/review/route.ts b/app/api/pandora/shadow-context-packs/[id]/review/route.ts new file mode 100644 index 0000000..d179613 --- /dev/null +++ b/app/api/pandora/shadow-context-packs/[id]/review/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 { updateShadowContextPackStatus, type ShadowContextPackDbClient } from "@/lib/services/pandora-shadow-context-pack-service"; +export const dynamic = "force-dynamic"; +export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { const body = await request.json().catch(() => ({})); 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 pack = await updateShadowContextPackStatus(supabase as unknown as ShadowContextPackDbClient, { userId: session.session.userId, shadowPackId: id, status: "reviewed", reason: typeof body.reason === "string" ? body.reason : undefined }); return NextResponse.json({ ok: true, pack, no_core_memory_mutation_performed: true, no_promotion_performed: true }); } catch (e) { return NextResponse.json({ ok: false, error: e instanceof Error ? e.message : "Shadow pack update failed" }, { status: 400 }); } } diff --git a/app/api/pandora/shadow-context-packs/[id]/route.ts b/app/api/pandora/shadow-context-packs/[id]/route.ts new file mode 100644 index 0000000..300955a --- /dev/null +++ b/app/api/pandora/shadow-context-packs/[id]/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 { getShadowContextPack, type ShadowContextPackDbClient } from "@/lib/services/pandora-shadow-context-pack-service"; +export const dynamic = "force-dynamic"; +export async function GET(request: NextRequest, context: { params: Promise<{ id: string }> }) { 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 { id } = await context.params; const supabase = await createSupabaseServerClient(); const pack = await getShadowContextPack(supabase as unknown as ShadowContextPackDbClient, { userId: session.session.userId, shadowPackId: id }); return NextResponse.json({ ok: true, pack }); } catch (e) { return NextResponse.json({ ok: false, error: e instanceof Error ? e.message : "Not found" }, { status: 404 }); } } diff --git a/app/api/pandora/shadow-context-packs/route.ts b/app/api/pandora/shadow-context-packs/route.ts new file mode 100644 index 0000000..92cd635 --- /dev/null +++ b/app/api/pandora/shadow-context-packs/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 { listShadowContextPacks, type ShadowContextPackDbClient } from "@/lib/services/pandora-shadow-context-pack-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 }); const url = new URL(request.url); const supabase = await createSupabaseServerClient(); const packs = await listShadowContextPacks(supabase as unknown as ShadowContextPackDbClient, { userId: session.session.userId, namespace: url.searchParams.get("namespace") ?? undefined, status: url.searchParams.get("status") ?? undefined, limit: 25 }); return NextResponse.json({ ok: true, packs }); } diff --git a/components/pandora/OperatorActionComposer.tsx b/components/pandora/OperatorActionComposer.tsx index ccb8d14..f68c029 100644 --- a/components/pandora/OperatorActionComposer.tsx +++ b/components/pandora/OperatorActionComposer.tsx @@ -5,5 +5,5 @@ export function OperatorActionComposer() { const [actionType, setActionType] = useState("verify_namespace_invariants"); const [namespace, setNamespace] = useState("real_life"); async function prepare() { await fetch("/api/pandora/operator-actions", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action_type: actionType, namespace, mode: "dry_run", payload: { source: "operator_action_center" } }) }); window.location.reload(); } - return

Only dry-run or queued-only proposals are available. No core memory mutation is available from this card.

; + return

For prepare_shadow_context_pack: Creates shadow candidate only after approval/execution. No promotion. Only dry-run or queued-only proposals are available. No core memory mutation is available from this card.

; } diff --git a/components/pandora/PandoraDashboard.tsx b/components/pandora/PandoraDashboard.tsx index 366d334..a63c5cf 100644 --- a/components/pandora/PandoraDashboard.tsx +++ b/components/pandora/PandoraDashboard.tsx @@ -12,6 +12,7 @@ import { StatCard } from "./StatCard"; import { TopBar } from "./TopBar"; import { VerificationConsoleCard } from "./VerificationConsoleCard"; import { OperatorActionCenterCard } from "./OperatorActionCenterCard"; +import { ShadowContextPackLabCard } from "./ShadowContextPackLabCard"; import { WorkQueueCard } from "./WorkQueueCard"; import type { PandoraDashboardData, StatItem } from "./types"; import { useState } from "react"; @@ -45,6 +46,7 @@ export function PandoraDashboard({ dashboardData }: { dashboardData: PandoraDash +
diff --git a/components/pandora/ShadowContextPackControls.tsx b/components/pandora/ShadowContextPackControls.tsx new file mode 100644 index 0000000..66e3d22 --- /dev/null +++ b/components/pandora/ShadowContextPackControls.tsx @@ -0,0 +1,4 @@ +"use client"; +import type { ShadowContextPackSummary } from "./types"; +async function post(id: string, verb: "review" | "reject" | "archive") { await fetch(`/api/pandora/shadow-context-packs/${id}/${verb}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) }); window.location.reload(); } +export function ShadowContextPackControls({ pack }: { pack: ShadowContextPackSummary }) { return
; } diff --git a/components/pandora/ShadowContextPackDetail.tsx b/components/pandora/ShadowContextPackDetail.tsx new file mode 100644 index 0000000..2d3e66c --- /dev/null +++ b/components/pandora/ShadowContextPackDetail.tsx @@ -0,0 +1,4 @@ +import type { ShadowContextPackSummary } from "./types"; +import { ShadowContextPackEvidence } from "./ShadowContextPackEvidence"; +import { ShadowContextPackControls } from "./ShadowContextPackControls"; +export function ShadowContextPackDetail({ pack }: { pack: ShadowContextPackSummary }) { const payload = pack.candidate_payload; return

{pack.title}

{pack.summary}

{pack.status}
{pack.namespace}namespace
{String(payload.source_event_count ?? 0)}source events
{String(payload.active_master_pack_id ?? "none")}active master pack
{String(payload.latest_event_at ?? "none")}latest event
Candidate payload
{JSON.stringify(payload, null, 2)}
; } diff --git a/components/pandora/ShadowContextPackEvidence.tsx b/components/pandora/ShadowContextPackEvidence.tsx new file mode 100644 index 0000000..a6655f0 --- /dev/null +++ b/components/pandora/ShadowContextPackEvidence.tsx @@ -0,0 +1,2 @@ +import type { ShadowContextPackSummary } from "./types"; +export function ShadowContextPackEvidence({ pack }: { pack: ShadowContextPackSummary }) { return

Evidence summary: {String(pack.candidate_payload.evidence_summary ?? pack.summary)}

{pack.warnings.map((w) =>

⚠ {w}

)}
; } diff --git a/components/pandora/ShadowContextPackLabCard.tsx b/components/pandora/ShadowContextPackLabCard.tsx new file mode 100644 index 0000000..32ceef3 --- /dev/null +++ b/components/pandora/ShadowContextPackLabCard.tsx @@ -0,0 +1,4 @@ +import type { ShadowContextPackLabData } from "./types"; +import { ShadowContextPackList } from "./ShadowContextPackList"; +const empty: ShadowContextPackLabData = { packs: [], warnings: [], countsByStatus: { draft: 0, ready_for_review: 0, reviewed: 0, rejected: 0, archived: 0 } }; +export function ShadowContextPackLabCard({ data }: { data?: ShadowContextPackLabData }) { const lab = data ?? empty; return

Shadow Context Pack Lab

Staged candidates for operator review

Shadow only — no production memory mutation

Promotion unavailable
{Object.entries(lab.countsByStatus).map(([status, count]) =>
{count}{status}
)}
{lab.warnings.length ?
{lab.warnings.map((w) =>

⚠ {w}

)}
: null}
; } diff --git a/components/pandora/ShadowContextPackList.tsx b/components/pandora/ShadowContextPackList.tsx new file mode 100644 index 0000000..f6a5928 --- /dev/null +++ b/components/pandora/ShadowContextPackList.tsx @@ -0,0 +1,3 @@ +import type { ShadowContextPackSummary } from "./types"; +import { ShadowContextPackDetail } from "./ShadowContextPackDetail"; +export function ShadowContextPackList({ packs }: { packs: ShadowContextPackSummary[] }) { if (!packs.length) return

No shadow context-pack candidates staged yet.

; return
{packs.map((pack) => )}
; } diff --git a/components/pandora/mock-data.ts b/components/pandora/mock-data.ts index 3fc1aa3..f111a75 100644 --- a/components/pandora/mock-data.ts +++ b/components/pandora/mock-data.ts @@ -17,5 +17,5 @@ export const timelineEvents: TimelineEventData[] = [{ id: "fixture", color: "sla export const coreSystems: SystemRow[] = [{ label: "Fixture", value: "No live data", state: "idle" }]; export const gatedSystems: SystemRow[] = [{ label: "Semantic retrieval", value: "Gated", state: "gated" }]; const verification = { generatedAt: "No live data", status: "not_run" as const, namespaces: [], packSupersession: { status: "not_run" as const, namespaces: [], warnings: ["Mock only"] }, retrievalEval: { status: "not_run" as const, source: "fixture", latestRunId: null, latestRunAt: null, resultLabel: "Not run", realResultAvailable: false, warnings: ["Mock only"] }, auditEvidence: [], smokeEvidence: { status: "not_run" as const, latest: null, warnings: ["Mock only"] }, invariantStatus: { exactlyOneActiveMasterPerNamespace: "not_run" as const, noCrossNamespacePackMixing: "not_run" as const, noDuplicateActiveMaster: "not_run" as const, retrievalEvalHasNoFabricatedScore: "pass" as const, smokeEvidence: "not_run" as const }, warnings: ["Mock only"] }; -export const fixtureDashboardData: PandoraDashboardData = { generatedAt: "No live data", operatorLabel: "Fixture", live: false, warnings: ["Mock only"], hero: { title: "Fixture dashboard", description: "Mock only: no live data.", primaryAction: "No live data", secondaryAction: "Semantic gated" }, evidence: "No live data", stats: [{ id: "fixture", title: "Fixture", value: "No live data", subtitle: "Mock only", color: "slate", sparklineData: [0, 0] }], memorySpaces, workQueue, profileSnapshot, timelineEvents, diagnostics: { coreSystems, gatedSystems, envelope: { title: "Fixture", description: "Mock only: no live data." } }, verification, operatorActions: { actions: [], warnings: [], countsByStatus: { proposed: 0, dry_ran: 0, approved: 0, executing: 0, completed: 0, blocked: 0, failed: 0, cancelled: 0 } } }; +export const fixtureDashboardData: PandoraDashboardData = { generatedAt: "No live data", operatorLabel: "Fixture", live: false, warnings: ["Mock only"], hero: { title: "Fixture dashboard", description: "Mock only: no live data.", primaryAction: "No live data", secondaryAction: "Semantic gated" }, evidence: "No live data", stats: [{ id: "fixture", title: "Fixture", value: "No live data", subtitle: "Mock only", color: "slate", sparklineData: [0, 0] }], memorySpaces, workQueue, profileSnapshot, timelineEvents, diagnostics: { coreSystems, gatedSystems, envelope: { title: "Fixture", description: "Mock only: no live data." } }, verification, shadowContextPackLab: { packs: [], warnings: [], countsByStatus: { draft: 0, ready_for_review: 0, reviewed: 0, rejected: 0, archived: 0 } }, operatorActions: { actions: [], warnings: [], countsByStatus: { proposed: 0, dry_ran: 0, approved: 0, executing: 0, completed: 0, blocked: 0, failed: 0, cancelled: 0 } } }; diff --git a/components/pandora/types.ts b/components/pandora/types.ts index 0f825e4..2714221 100644 --- a/components/pandora/types.ts +++ b/components/pandora/types.ts @@ -142,8 +142,13 @@ export type PandoraVerificationData = { }; +export type ShadowContextPackStatus = "draft" | "ready_for_review" | "reviewed" | "rejected" | "archived"; +export type ShadowContextPackEvent = { id: string; shadow_pack_id: string; user_id: string; event_type: string; message: string; metadata: Record; created_at: string }; +export type ShadowContextPackSummary = { id: string; request_id: string; operator_action_id?: string | null; namespace: PandoraNamespace; pack_type: string; status: ShadowContextPackStatus; title: string; summary: string; source_window: Record; candidate_payload: Record; evidence: Record; warnings: string[]; created_at: string; updated_at: string; reviewed_at?: string | null; rejected_at?: string | null; archived_at?: string | null; event_preview?: ShadowContextPackEvent[] }; +export type ShadowContextPackLabData = { packs: ShadowContextPackSummary[]; warnings: string[]; countsByStatus: Record }; + export type OperatorActionStatus = "proposed" | "dry_ran" | "approved" | "executing" | "completed" | "blocked" | "failed" | "cancelled"; -export type OperatorActionType = "verify_namespace_invariants" | "verify_pack_supersession" | "check_retrieval_eval_status" | "refresh_dashboard_snapshot" | "prepare_distill_smoke_plan"; +export type OperatorActionType = "verify_namespace_invariants" | "verify_pack_supersession" | "check_retrieval_eval_status" | "refresh_dashboard_snapshot" | "prepare_distill_smoke_plan" | "prepare_shadow_context_pack"; export type OperatorActionMode = "dry_run" | "queued_only"; export type OperatorActionEventSummary = { id: string; action_id: string; user_id: string; event_type: string; message: string; metadata: Record; created_at: string; }; @@ -202,4 +207,5 @@ export type PandoraDashboardData = { }; verification: PandoraVerificationData; operatorActions: OperatorActionCenterData; + shadowContextPackLab: ShadowContextPackLabData; }; diff --git a/docs/pandora-operator-action-center.md b/docs/pandora-operator-action-center.md index 62f3b87..30dfcb4 100644 --- a/docs/pandora-operator-action-center.md +++ b/docs/pandora-operator-action-center.md @@ -45,3 +45,7 @@ Approved actions execute verification loaders only. Results include `no_mutation ## Idempotency and events The service hashes the server-derived `userId`, action type, namespace, normalized payload, and mode. The database enforces `unique(user_id, idempotency_key)`. Every proposed, dry-run, approved, executing, completed, failed, and cancelled transition writes an event row. + +## Shadow context-pack action + +`prepare_shadow_context_pack` is available as a dry-run/queued-only operator action. Dry-run computes deterministic shadow candidate evidence without inserting a row. Approved execution writes only to the new shadow context-pack tables plus operator action events. It does not mutate core memory tables and does not promote to `memory_context_packs`. diff --git a/docs/pandora-readonly-action-runner.md b/docs/pandora-readonly-action-runner.md index c4399ea..e421a53 100644 --- a/docs/pandora-readonly-action-runner.md +++ b/docs/pandora-readonly-action-runner.md @@ -61,3 +61,7 @@ Each state transition creates an event row with user scope, action id, event typ ## Future path to human-approved live actions Any future live action needs a separate reviewed PR, protected dry-run output, explicit human approval, production readiness checks, database verification, and post-run audit evidence. This runner is not that live-mutation path. + +## Shadow staging exception + +The existing verification actions remain read-only. `prepare_shadow_context_pack` is the only staged write exception in this phase: after approval/execution it inserts a shadow candidate in `pandora_shadow_context_packs` and a matching shadow event. This is not a production context-pack promotion and cannot update `memory_context_packs`. diff --git a/docs/pandora-shadow-context-pack-lab.md b/docs/pandora-shadow-context-pack-lab.md new file mode 100644 index 0000000..96b62fd --- /dev/null +++ b/docs/pandora-shadow-context-pack-lab.md @@ -0,0 +1,37 @@ +# Pandora Shadow Context Pack Lab v1 + +Shadow context packs are staged, operator-review candidates stored outside `memory_context_packs`. They let Pandora prepare deterministic context-pack evidence without replacing, promoting, deleting, distilling, embedding, or mutating production memory truth tables. + +## Safety boundary + +- Writes are limited to `pandora_shadow_context_packs`, `pandora_shadow_context_pack_events`, and the existing operator action/event tables. +- `memory_events`, `memory_context_packs`, `memory_profiles`, `memory_capture_candidates`, and `memory_pruning_candidates` are not mutated. +- No model calls, embeddings, semantic retrieval, GPT Actions, or MCP are used. +- Promotion to real context packs is unavailable in v1. +- All reads and writes use server-derived authenticated user identity and namespace filters. + +## Statuses + +Shadow packs may be `draft`, `ready_for_review`, `reviewed`, `rejected`, or `archived`. + +## Candidate generation + +The service reads recent namespace-scoped `memory_events`, active master pack metadata, open loops, and review queue counts. It produces deterministic payload fields: source event count, active master id, latest event timestamp, reviewed/promoted counts, open-loop count, review count, short recent summaries, and an evidence summary. Missing events or active master metadata create warnings and keep the candidate in `draft`. + +## Operator action flow + +`prepare_shadow_context_pack` can be proposed from the Operator Action Center. Dry-run computes the staged candidate and performs no insert. After approval, execution creates exactly one shadow candidate and shadow event; result metadata states `shadow_write_performed: true`, `no_core_memory_mutation_performed: true`, and `no_promotion_performed: true`. + +## Manual smoke checklist + +1. Sign in to `/pandora`. +2. Propose `prepare_shadow_context_pack` for one namespace. +3. Run dry-run and verify no shadow row was inserted. +4. Approve and execute. +5. Confirm the new candidate appears in the Shadow Context Pack Lab. +6. Mark reviewed, reject, or archive the candidate. +7. Confirm no promote, replace, delete, prune, merge, or distill controls exist. + +## Future path + +A future human-approved promotion path would require a separate PR, explicit review, additional migration/policy review, dry-run proof, and production approval. It is intentionally not implemented here. diff --git a/lib/services/pandora-dashboard-service.ts b/lib/services/pandora-dashboard-service.ts index 39f1798..b0adcd5 100644 --- a/lib/services/pandora-dashboard-service.ts +++ b/lib/services/pandora-dashboard-service.ts @@ -2,6 +2,7 @@ import type { PandoraDashboardData } from "@/components/pandora/types"; import { loadPandoraVerificationData } from "@/lib/services/pandora-verification-service"; import { listOperatorActions } from "@/lib/services/pandora-operator-action-service"; +import { listShadowContextPacks } from "@/lib/services/pandora-shadow-context-pack-service"; export type PandoraDashboardDbClient = { from: (table: string) => any }; type Namespace = "real_life" | "au"; @@ -41,6 +42,9 @@ export async function loadPandoraDashboardData(client: PandoraDashboardDbClient, const verification = await loadPandoraVerificationData(client, { userId: input.userId }); const actionWarnings: string[] = []; const operatorActions = await listOperatorActions(client, { userId: input.userId, limit: 10 }); + const shadowWarnings: string[] = []; + let shadowPacks = [] as Row[]; + try { shadowPacks = await listShadowContextPacks(client, { userId: input.userId, limit: 8 }) as Row[]; } catch { shadowWarnings.push("shadow context-pack tables unavailable; lab is showing an empty state."); } const actionEvents = await Promise.all(operatorActions.map(async (action) => { try { const result = await client.from("pandora_operator_action_events").select("*").eq("user_id", input.userId).eq("action_id", action.id).order("created_at", { ascending: false }).limit(3); @@ -52,6 +56,8 @@ export async function loadPandoraDashboardData(client: PandoraDashboardDbClient, } })); const eventsByAction = new Map(actionEvents.map((item) => [item.actionId, item.events])); + const shadowStatuses = ["draft", "ready_for_review", "reviewed", "rejected", "archived"] as const; + const shadowCountsByStatus = Object.fromEntries(shadowStatuses.map((status) => [status, shadowPacks.filter((pack) => pack.status === status).length])) as PandoraDashboardData["shadowContextPackLab"]["countsByStatus"]; const actionStatuses = ["proposed", "dry_ran", "approved", "executing", "completed", "blocked", "failed", "cancelled"] as const; const countsByStatus = Object.fromEntries(actionStatuses.map((status) => [status, operatorActions.filter((action) => action.status === status).length])) as PandoraDashboardData["operatorActions"]["countsByStatus"]; const data = await Promise.all(namespaces.map(async (namespace) => ({ @@ -79,7 +85,7 @@ export async function loadPandoraDashboardData(client: PandoraDashboardDbClient, generatedAt: new Date().toISOString(), operatorLabel: input.operatorLabel ?? input.userId, live: warnings.length === 0, - warnings: Array.from(new Set([...warnings, ...verification.warnings])), + warnings: Array.from(new Set([...warnings, ...verification.warnings, ...shadowWarnings])), hero: { title: "Pandora dashboard is reading live memory state.", description: "This route renders authenticated Supabase data for memory state while semantic retrieval, embeddings, model calls, GPT Actions, and MCP remain gated.", primaryAction: "Context pack data live", secondaryAction: "Retrieval eval Gated" }, evidence: warnings.length ? "Live dashboard read completed with safe empty states for unavailable tables." : "Live dashboard read complete from server-derived session scope.", stats: [ @@ -101,6 +107,7 @@ export async function loadPandoraDashboardData(client: PandoraDashboardDbClient, timelineEvents: events.slice(0, 6).map((event) => ({ id: String(event.id ?? `${event.namespace}-${event.created_at ?? "event"}`), title: `${event.namespace} • ${event.status ?? "unknown"}`, time: event.created_at ?? "Live read", desc: eventSummary(event), namespace: event.namespace === "au" ? "au" : "real_life", color: event.namespace === "au" ? "purple" : "emerald" })), diagnostics: { coreSystems: [{ label: "Route exposure", value: "Auth gated", state: "healthy" }, { label: "Displayed data", value: warnings.length ? "Partial live reads" : "Live reads", state: warnings.length ? "attention" : "healthy" }, { label: "Master-pack invariant", value: duplicates ? `${duplicates} duplicate` : "OK", state: duplicates ? "attention" : "healthy" }, { label: "Client user_id", value: "Rejected", state: "healthy" }], gatedSystems: [{ label: "Semantic retrieval", value: "Gated Off", state: "gated" }, { label: "Embeddings", value: "Gated Off", state: "gated" }, { label: "Model calls", value: "Gated Off", state: "gated" }, { label: "Pruning automation", value: "Review-only", state: "gated" }], envelope: { title: "Dashboard Truth Envelope", description: warnings.length ? "Unavailable reads were converted to warnings and empty UI state." : "Live loader completed from authenticated Supabase reads." } }, verification, + shadowContextPackLab: { packs: shadowPacks.map((pack) => ({ id: String(pack.id), request_id: String(pack.request_id), operator_action_id: pack.operator_action_id ? String(pack.operator_action_id) : null, namespace: pack.namespace === "au" ? "au" : "real_life", pack_type: String(pack.pack_type ?? "master_candidate"), status: String(pack.status ?? "draft") as never, title: String(pack.title ?? "Shadow context pack"), summary: String(pack.summary ?? ""), source_window: pack.source_window ?? {}, candidate_payload: pack.candidate_payload ?? {}, evidence: pack.evidence ?? {}, warnings: Array.isArray(pack.warnings) ? pack.warnings : [], created_at: String(pack.created_at ?? ""), updated_at: String(pack.updated_at ?? ""), reviewed_at: pack.reviewed_at ?? null, rejected_at: pack.rejected_at ?? null, archived_at: pack.archived_at ?? null })), warnings: shadowWarnings, countsByStatus: shadowCountsByStatus }, operatorActions: { actions: operatorActions.map((action) => { const eventPreview = eventsByAction.get(action.id) ?? []; return { id: action.id, request_id: action.request_id, idempotency_key: action.idempotency_key, action_type: action.action_type, namespace: action.namespace, mode: action.mode, status: action.status, title: action.title, description: action.description, result: action.result, warnings: action.warnings, created_at: action.created_at, updated_at: action.updated_at, approved_at: action.approved_at, completed_at: action.completed_at, failed_at: action.failed_at, event_count: eventPreview.length, event_preview: eventPreview.map((event: Row) => ({ id: String(event.id), action_id: String(event.action_id), user_id: String(event.user_id), event_type: String(event.event_type), message: String(event.message), metadata: event.metadata ?? {}, created_at: String(event.created_at) })) }; diff --git a/lib/services/pandora-operator-action-service.ts b/lib/services/pandora-operator-action-service.ts index d4ada23..a11d6a8 100644 --- a/lib/services/pandora-operator-action-service.ts +++ b/lib/services/pandora-operator-action-service.ts @@ -3,15 +3,16 @@ import { createHash, randomUUID } from "node:crypto"; import type { PandoraNamespace } from "@/components/pandora/types"; import { loadPandoraDashboardData } from "@/lib/services/pandora-dashboard-service"; import { loadPandoraVerificationData } from "@/lib/services/pandora-verification-service"; +import { createShadowContextPackCandidate, dryRunShadowContextPackCandidate } from "@/lib/services/pandora-shadow-context-pack-service"; -export type OperatorActionType = "verify_namespace_invariants" | "verify_pack_supersession" | "check_retrieval_eval_status" | "refresh_dashboard_snapshot" | "prepare_distill_smoke_plan"; +export type OperatorActionType = "verify_namespace_invariants" | "verify_pack_supersession" | "check_retrieval_eval_status" | "refresh_dashboard_snapshot" | "prepare_distill_smoke_plan" | "prepare_shadow_context_pack"; export type OperatorActionMode = "dry_run" | "queued_only"; export type OperatorActionStatus = "proposed" | "dry_ran" | "approved" | "executing" | "completed" | "blocked" | "failed" | "cancelled"; export type OperatorActionRow = { id: string; user_id: string; request_id: string; idempotency_key: string; action_type: OperatorActionType; namespace: PandoraNamespace | null; mode: OperatorActionMode; status: OperatorActionStatus; title: string; description: string; payload: Record; result: Record; warnings: string[]; created_at: string; updated_at: string; approved_at?: string | null; completed_at?: string | null; failed_at?: string | null }; export type OperatorActionEventRow = { id: string; action_id: string; user_id: string; event_type: string; message: string; metadata: Record; created_at: string }; export type OperatorActionDbClient = { from: (table: string) => any }; -const ACTIONS = new Set(["verify_namespace_invariants", "verify_pack_supersession", "check_retrieval_eval_status", "refresh_dashboard_snapshot", "prepare_distill_smoke_plan"]); +const ACTIONS = new Set(["verify_namespace_invariants", "verify_pack_supersession", "check_retrieval_eval_status", "refresh_dashboard_snapshot", "prepare_distill_smoke_plan", "prepare_shadow_context_pack"]); const MODES = new Set(["dry_run", "queued_only"]); const NAMESPACES = new Set(["real_life", "au"]); @@ -21,7 +22,7 @@ function assertNamespace(namespace?: string | null): asserts namespace is Pandor function stable(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`; if (value && typeof value === "object") return `{${Object.keys(value as Record).sort().map((k) => `${JSON.stringify(k)}:${stable((value as Record)[k])}`).join(",")}}`; return JSON.stringify(value); } export function operatorActionIdempotencyKey(input: { userId: string; actionType: string; namespace?: string | null; payload?: unknown; mode: string }) { return createHash("sha256").update([input.userId, input.actionType, input.namespace ?? "global", input.mode, stable(input.payload ?? {})].join("|")).digest("hex"); } function titleFor(actionType: OperatorActionType) { return actionType.split("_").map((p) => p[0].toUpperCase() + p.slice(1)).join(" "); } -function envelope(action: OperatorActionRow, result: Record, warnings: string[]) { return { ok: warnings.length === 0, request_id: action.request_id, action_id: action.id, action_type: action.action_type, status: action.status, evidence_summary: result, warnings, no_mutation_performed: true, executed_at: new Date().toISOString() }; } +function envelope(action: OperatorActionRow, result: Record, warnings: string[]) { return { ok: warnings.length === 0, request_id: action.request_id, action_id: action.id, action_type: action.action_type, status: action.status, evidence_summary: result, warnings, no_mutation_performed: result.shadow_write_performed === true ? false : true, no_core_memory_mutation_performed: true, no_promotion_performed: true, executed_at: new Date().toISOString() }; } const TERMINAL = new Set(["completed", "failed", "cancelled"]); function assertTransition(from: OperatorActionStatus, to: OperatorActionStatus) { const allowed: Record = { proposed: ["dry_ran", "approved", "cancelled"], dry_ran: ["approved", "cancelled"], approved: ["executing", "cancelled"], executing: ["completed", "failed"], blocked: [], completed: [], failed: [], cancelled: [] }; if (!allowed[from]?.includes(to)) throw new Error(`Invalid Pandora operator action transition: ${from} -> ${to}`); } async function single(query: any) { const res = await query; if (res.error) return null; return Array.isArray(res.data) ? res.data[0] ?? null : res.data ?? null; } @@ -65,6 +66,11 @@ export async function proposeOperatorAction(client: OperatorActionDbClient, inpu async function buildDryRunResult(client: OperatorActionDbClient, userId: string, action: OperatorActionRow) { const warnings: string[] = []; + if (action.action_type === "prepare_shadow_context_pack") { + if (!action.namespace) throw new Error("prepare_shadow_context_pack requires a namespace"); + const candidate = await dryRunShadowContextPackCandidate(client, { userId, namespace: action.namespace, sourceWindow: action.payload?.source_window as Record | undefined }); + return { warnings: candidate.warnings, result: { checked: "shadow_context_pack_candidate", ...candidate, no_core_memory_mutation_performed: true, shadow_write_performed: false, no_promotion_performed: true } }; + } const verification = await loadPandoraVerificationData(client, { userId }); if (action.action_type === "refresh_dashboard_snapshot") { const dashboard = await loadPandoraDashboardData(client, { userId }); warnings.push(...dashboard.warnings); return { warnings, result: { checked: "dashboard_snapshot", generated_at: dashboard.generatedAt, memory_spaces: dashboard.memorySpaces.map((s) => ({ namespace: s.id, memories: s.memories, status: s.status })), gated_systems: dashboard.diagnostics.gatedSystems, no_mutation_performed: true } }; } warnings.push(...verification.warnings); @@ -81,7 +87,7 @@ export async function dryRunOperatorAction(client: OperatorActionDbClient, input const status: OperatorActionStatus = "dry_ran"; const nextResult = envelope({ ...action, status }, built.result, built.warnings); const updated = await single(client.from("pandora_operator_actions").update({ status, result: nextResult, warnings: built.warnings, updated_at: new Date().toISOString() }).eq("user_id", input.userId).eq("id", input.actionId).select("*").single()); - await createActionEvent(client, { userId: input.userId, actionId: input.actionId, eventType: status, message: "Safe dry-run completed; no core memory mutation was performed.", metadata: nextResult }); + await createActionEvent(client, { userId: input.userId, actionId: input.actionId, eventType: action.action_type === "prepare_shadow_context_pack" ? "shadow_candidate_dry_run" : status, message: action.action_type === "prepare_shadow_context_pack" ? "Shadow context-pack candidate dry-run completed; no shadow row or core memory mutation was performed." : "Safe dry-run completed; no core memory mutation was performed.", metadata: nextResult }); return updated ?? { ...action, status, result: nextResult, warnings: built.warnings }; } @@ -103,9 +109,15 @@ export async function executeApprovedOperatorAction(client: OperatorActionDbClie assertMode(action.mode); const executingAt = new Date().toISOString(); const executing = await single(client.from("pandora_operator_actions").update({ status: "executing", updated_at: executingAt }).eq("user_id", input.userId).eq("id", input.actionId).select("*").single()) ?? { ...action, status: "executing" as const, updated_at: executingAt }; - await createActionEvent(client, { userId: input.userId, actionId: input.actionId, eventType: "executing", message: "Approved read-only verification execution started.", metadata: { no_mutation_performed: true } }); + await createActionEvent(client, { userId: input.userId, actionId: input.actionId, eventType: "executing", message: action.action_type === "prepare_shadow_context_pack" ? "Approved shadow staging execution started." : "Approved read-only verification execution started.", metadata: { no_mutation_performed: action.action_type !== "prepare_shadow_context_pack", no_core_memory_mutation_performed: true } }); try { - const built = await buildDryRunResult(client, input.userId, executing); + let built: { warnings: string[]; result: Record } = await buildDryRunResult(client, input.userId, executing); + if (executing.action_type === "prepare_shadow_context_pack") { + if (!executing.namespace) throw new Error("prepare_shadow_context_pack requires a namespace"); + const shadow = await createShadowContextPackCandidate(client, { userId: input.userId, namespace: executing.namespace, operatorActionId: executing.id, requestId: executing.request_id, sourceWindow: executing.payload?.source_window as Record | undefined }); + built = { warnings: shadow.warnings, result: { checked: "shadow_context_pack_candidate", shadow_pack_id: shadow.id, status: shadow.status, namespace: shadow.namespace, shadow_write_performed: true, no_core_memory_mutation_performed: true, no_promotion_performed: true } }; + await createActionEvent(client, { userId: input.userId, actionId: input.actionId, eventType: "shadow_candidate_created", message: "Shadow context-pack candidate created in staging tables only.", metadata: built.result }); + } const completedAt = new Date().toISOString(); const result = envelope({ ...executing, status: "completed" }, built.result, built.warnings); const completed = await single(client.from("pandora_operator_actions").update({ status: "completed", result, warnings: built.warnings, completed_at: completedAt, updated_at: completedAt }).eq("user_id", input.userId).eq("id", input.actionId).select("*").single()); diff --git a/lib/services/pandora-shadow-context-pack-service.ts b/lib/services/pandora-shadow-context-pack-service.ts new file mode 100644 index 0000000..37c565e --- /dev/null +++ b/lib/services/pandora-shadow-context-pack-service.ts @@ -0,0 +1,25 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { randomUUID } from "node:crypto"; +import type { PandoraNamespace, ShadowContextPackStatus } from "@/components/pandora/types"; + +export type ShadowContextPackDbClient = { from: (table: string) => any }; +export type ShadowContextPackRow = { id: string; user_id: string; request_id: string; operator_action_id?: string | null; namespace: PandoraNamespace; pack_type: string; status: ShadowContextPackStatus; title: string; summary: string; source_window: Record; candidate_payload: Record; evidence: Record; warnings: string[]; created_at: string; updated_at: string; reviewed_at?: string | null; rejected_at?: string | null; archived_at?: string | null }; +export type ShadowContextPackEventRow = { id: string; shadow_pack_id: string; user_id: string; event_type: string; message: string; metadata: Record; created_at: string }; +const NAMESPACES = new Set(["real_life", "au"]); const STATUSES = new Set(["draft", "ready_for_review", "reviewed", "rejected", "archived"]); +function assertNamespace(namespace: string): asserts namespace is PandoraNamespace { if (!NAMESPACES.has(namespace)) throw new Error(`Unsupported Pandora namespace: ${namespace}`); } +function assertStatus(status: string): asserts status is ShadowContextPackStatus { if (!STATUSES.has(status)) throw new Error(`Unsupported shadow context pack status: ${status}`); } +async function safeRows(client: ShadowContextPackDbClient, table: string, userId: string, namespace: PandoraNamespace, limit: number, warnings: string[]) { try { const r = await client.from(table).select("*").eq("user_id", userId).eq("namespace", namespace).order("created_at", { ascending: false }).limit(limit); if (r.error) { warnings.push(`${table} unavailable for ${namespace}`); return []; } return Array.isArray(r.data) ? r.data : []; } catch { warnings.push(`${table} unavailable for ${namespace}`); return []; } } +function short(value: unknown) { return String(value ?? "").replace(/\s+/g, " ").trim().slice(0, 160); } +function eventSummary(row: any) { return short(row.extracted_summary || row.summary || row.raw_text || row.title || row.id); } +async function single(query: any) { const res = await query; if (res.error) return null; return Array.isArray(res.data) ? res.data[0] ?? null : res.data ?? null; } + +export async function listShadowContextPacks(client: ShadowContextPackDbClient, input: { userId: string; namespace?: string; status?: string; limit?: number }): Promise { if (input.namespace) assertNamespace(input.namespace); if (input.status) assertStatus(input.status); let q = client.from("pandora_shadow_context_packs").select("*").eq("user_id", input.userId).order("created_at", { ascending: false }).limit(input.limit ?? 20); if (input.namespace) q = q.eq("namespace", input.namespace); if (input.status) q = q.eq("status", input.status); const r = await q; if (r.error) return []; return Array.isArray(r.data) ? r.data : []; } +export async function getShadowContextPack(client: ShadowContextPackDbClient, input: { userId: string; shadowPackId: string }): Promise { const row = await single(client.from("pandora_shadow_context_packs").select("*").eq("user_id", input.userId).eq("id", input.shadowPackId).limit(1)); if (!row) throw new Error("Shadow context pack not found for current user"); return row; } + +export async function dryRunShadowContextPackCandidate(client: ShadowContextPackDbClient, input: { userId: string; namespace: string; sourceWindow?: Record }) { assertNamespace(input.namespace); const warnings: string[] = []; const events = await safeRows(client, "memory_events", input.userId, input.namespace, 50, warnings); const packs = await safeRows(client, "memory_context_packs", input.userId, input.namespace, 10, warnings); const loops = await safeRows(client, "memory_open_loops", input.userId, input.namespace, 100, warnings); const review = await safeRows(client, "memory_review_queue_items", input.userId, input.namespace, 100, warnings); const active = packs.find((p:any)=>p.pack_type === "master" && p.status === "active"); const reviewed = events.filter((e:any)=>e.status === "reviewed").length; const promoted = events.filter((e:any)=>e.status === "promoted").length; const openLoopCount = loops.filter((l:any)=>["open","acknowledged"].includes(String(l.status ?? ""))).length; const needsReviewCount = review.filter((r:any)=>["pending_review","needs_clarification","pending"].includes(String(r.status ?? ""))).length; if (!active) warnings.push(`No active master context pack found for ${input.namespace}; readiness is not fabricated.`); if (events.length === 0) warnings.push(`No memory_events found for ${input.namespace}; candidate remains draft.`); const latest = events[0]?.created_at ?? null; const payload = { namespace: input.namespace, source_event_count: events.length, active_master_pack_id: active?.id ?? null, active_master_created_at: active?.created_at ?? null, latest_event_at: latest, reviewed_event_count: reviewed, promoted_event_count: promoted, open_loop_count: openLoopCount, needs_review_count: needsReviewCount, top_recent_summaries: events.slice(0,5).map(eventSummary), evidence_summary: `${events.length} namespace-scoped memory_events, ${openLoopCount} open loops, ${needsReviewCount} review items; no semantic retrieval or model scoring.` }; + const status: ShadowContextPackStatus = warnings.length || !active || events.length === 0 ? "draft" : "ready_for_review"; + return { status, title: `Shadow ${input.namespace} master candidate`, summary: `Shadow candidate for ${input.namespace}: ${events.length} events considered, ${openLoopCount} open loops, ${needsReviewCount} review items. Promotion is gated.`, source_window: input.sourceWindow ?? {}, candidate_payload: payload, evidence: { namespace: input.namespace, source_tables: ["memory_events", "memory_context_packs", "memory_open_loops", "memory_review_queue_items"], no_model_call: true, no_embeddings: true, no_promotion_performed: true }, warnings }; +} +export async function createShadowContextPackEvent(client: ShadowContextPackDbClient, input: { userId: string; shadowPackId: string; eventType: string; message: string; metadata?: Record }): Promise { const row = { id: randomUUID(), shadow_pack_id: input.shadowPackId, user_id: input.userId, event_type: input.eventType, message: input.message, metadata: input.metadata ?? {}, created_at: new Date().toISOString() }; return single(client.from("pandora_shadow_context_pack_events").insert(row).select("*").single()); } +export async function createShadowContextPackCandidate(client: ShadowContextPackDbClient, input: { userId: string; namespace: string; operatorActionId?: string | null; requestId: string; sourceWindow?: Record }): Promise { const c = await dryRunShadowContextPackCandidate(client, { userId: input.userId, namespace: input.namespace, sourceWindow: input.sourceWindow }); const now = new Date().toISOString(); const row = { id: randomUUID(), user_id: input.userId, request_id: input.requestId, operator_action_id: input.operatorActionId ?? null, namespace: input.namespace, pack_type: "master_candidate", status: c.status, title: c.title, summary: c.summary, source_window: c.source_window, candidate_payload: c.candidate_payload, evidence: c.evidence, warnings: c.warnings, created_at: now, updated_at: now, reviewed_at: null, rejected_at: null, archived_at: null }; const created = await single(client.from("pandora_shadow_context_packs").insert(row).select("*").single()); if (!created) throw new Error("Unable to create shadow context pack candidate"); await createShadowContextPackEvent(client, { userId: input.userId, shadowPackId: created.id, eventType: "shadow_candidate_created", message: "Shadow context-pack candidate created. No production memory mutation or promotion performed.", metadata: { request_id: input.requestId, operator_action_id: input.operatorActionId ?? null, no_core_memory_mutation_performed: true, no_promotion_performed: true } }); return created; } +export async function updateShadowContextPackStatus(client: ShadowContextPackDbClient, input: { userId: string; shadowPackId: string; status: string; reason?: string }): Promise { assertStatus(input.status); await getShadowContextPack(client, input); const now = new Date().toISOString(); const patch: Record = { status: input.status, updated_at: now }; if (input.status === "reviewed") patch.reviewed_at = now; if (input.status === "rejected") patch.rejected_at = now; if (input.status === "archived") patch.archived_at = now; const updated = await single(client.from("pandora_shadow_context_packs").update(patch).eq("user_id", input.userId).eq("id", input.shadowPackId).select("*").single()); if (!updated) throw new Error("Unable to update shadow context pack for current user"); await createShadowContextPackEvent(client, { userId: input.userId, shadowPackId: input.shadowPackId, eventType: `shadow_candidate_${input.status}`, message: input.reason ? `Shadow candidate marked ${input.status}: ${input.reason}` : `Shadow candidate marked ${input.status}. No promotion performed.`, metadata: { no_core_memory_mutation_performed: true, no_promotion_performed: true } }); return updated; } diff --git a/supabase/migrations/20260703020000_pandora_shadow_context_pack_lab.sql b/supabase/migrations/20260703020000_pandora_shadow_context_pack_lab.sql new file mode 100644 index 0000000..c4bbb0b --- /dev/null +++ b/supabase/migrations/20260703020000_pandora_shadow_context_pack_lab.sql @@ -0,0 +1,31 @@ +alter table public.pandora_operator_actions drop constraint if exists pandora_operator_actions_action_type_check; +alter table public.pandora_operator_actions add constraint pandora_operator_actions_action_type_check check (action_type in ('verify_namespace_invariants','verify_pack_supersession','check_retrieval_eval_status','refresh_dashboard_snapshot','prepare_distill_smoke_plan','prepare_shadow_context_pack')); + +create table if not exists public.pandora_shadow_context_packs ( + id uuid primary key default gen_random_uuid(), user_id uuid not null, request_id text not null, + operator_action_id uuid null references public.pandora_operator_actions(id) on delete set null, + namespace text not null check (namespace in ('real_life','au')), pack_type text not null default 'master_candidate', + status text not null default 'draft' check (status in ('draft','ready_for_review','reviewed','rejected','archived')), + title text not null, summary text not null, source_window jsonb not null default '{}'::jsonb, + candidate_payload jsonb not null default '{}'::jsonb, evidence 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(), + reviewed_at timestamptz null, rejected_at timestamptz null, archived_at timestamptz null +); +create table if not exists public.pandora_shadow_context_pack_events ( + id uuid primary key default gen_random_uuid(), shadow_pack_id uuid not null references public.pandora_shadow_context_packs(id) on delete cascade, + user_id uuid not null, 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_shadow_context_packs_user_created_idx on public.pandora_shadow_context_packs(user_id, created_at desc); +create index if not exists pandora_shadow_context_packs_user_namespace_status_idx on public.pandora_shadow_context_packs(user_id, namespace, status); +create index if not exists pandora_shadow_context_packs_user_action_idx on public.pandora_shadow_context_packs(user_id, operator_action_id); +create index if not exists pandora_shadow_context_pack_events_user_pack_created_idx on public.pandora_shadow_context_pack_events(user_id, shadow_pack_id, created_at desc); +alter table public.pandora_shadow_context_packs enable row level security; +alter table public.pandora_shadow_context_pack_events enable row level security; +create policy "pandora_shadow_context_packs_select_own" on public.pandora_shadow_context_packs for select to authenticated using (user_id = auth.uid()); +create policy "pandora_shadow_context_packs_insert_own" on public.pandora_shadow_context_packs for insert to authenticated with check (user_id = auth.uid()); +create policy "pandora_shadow_context_packs_update_own" on public.pandora_shadow_context_packs for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid()); +create policy "pandora_shadow_context_pack_events_select_own" on public.pandora_shadow_context_pack_events for select to authenticated using (user_id = auth.uid()); +create policy "pandora_shadow_context_pack_events_insert_own" on public.pandora_shadow_context_pack_events for insert to authenticated with check (user_id = auth.uid()); +create or replace function public.set_pandora_shadow_context_pack_updated_at() returns trigger language plpgsql as $$ begin new.updated_at = now(); return new; end; $$; +drop trigger if exists set_pandora_shadow_context_pack_updated_at on public.pandora_shadow_context_packs; +create trigger set_pandora_shadow_context_pack_updated_at before update on public.pandora_shadow_context_packs for each row execute function public.set_pandora_shadow_context_pack_updated_at(); diff --git a/tests/unit/pandora-operator-action-guards.test.ts b/tests/unit/pandora-operator-action-guards.test.ts index 97e1aea..ae05ac8 100644 --- a/tests/unit/pandora-operator-action-guards.test.ts +++ b/tests/unit/pandora-operator-action-guards.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; -const files = ["components/pandora/OperatorActionCenterCard.tsx", "components/pandora/OperatorActionComposer.tsx", "components/pandora/OperatorActionList.tsx", "components/pandora/OperatorActionEnvelope.tsx", "components/pandora/OperatorActionControls.tsx", "components/pandora/OperatorActionTimeline.tsx"]; +const files = ["components/pandora/OperatorActionCenterCard.tsx", "components/pandora/OperatorActionComposer.tsx", "components/pandora/OperatorActionList.tsx", "components/pandora/OperatorActionEnvelope.tsx", "components/pandora/OperatorActionControls.tsx", "components/pandora/OperatorActionTimeline.tsx", "components/pandora/ShadowContextPackLabCard.tsx", "components/pandora/ShadowContextPackControls.tsx", "components/pandora/ShadowContextPackDetail.tsx"]; 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 ["Run live", "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"); 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_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"); expect(route).toContain("assertNoClientUserIdOverride"); expect(route).not.toContain("searchParams.get(\"user_id\")"); }); + 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", "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"); 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"); expect(route).toContain("assertNoClientUserIdOverride"); expect(route).not.toContain("searchParams.get(\"user_id\")"); expect(route).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-operator-action-service.test.ts b/tests/unit/pandora-operator-action-service.test.ts index 9cdc6e3..c66d725 100644 --- a/tests/unit/pandora-operator-action-service.test.ts +++ b/tests/unit/pandora-operator-action-service.test.ts @@ -6,7 +6,7 @@ const USER = "11111111-1111-4111-8111-111111111111"; const OTHER = "22222222-2222-4222-8222-222222222222"; type Store = Record; function client(store: Store) { return { from(table: string) { const eqs: Record = {}; let lim = Infinity; let patch: any; let insertRow: any; const rows=()=> (store[table]??[]).filter(r=>Object.entries(eqs).every(([k,v])=>r[k]===v)).slice(0,lim); const b:any={ select(){return b}, eq(k:string,v:any){eqs[k]=v;return b}, order(){return b}, limit(n:number){lim=n;return b}, single(){return b}, insert(row:any){insertRow=Array.isArray(row)?row[0]:row; store[table]=store[table]??[]; store[table].push(insertRow); return b}, update(row:any){patch=row; return b}, then(res:any,rej:any){ if(patch){ const r=rows()[0]; if(r) Object.assign(r,patch); return Promise.resolve({data:r??null,error:r?null:{message:"missing"}}).then(res,rej)} return Promise.resolve({data:insertRow??rows(),error:null}).then(res,rej)}}; return b; }} as any; } -function store(): Store { return { pandora_operator_actions: [], pandora_operator_action_events: [], memory_context_packs: [{ id:"rl", user_id: USER, namespace:"real_life", pack_type:"master", status:"active", title:"RL", created_at:"2026-07-03" }, { id:"au", user_id: USER, namespace:"au", pack_type:"master", status:"active", title:"AU", created_at:"2026-07-03" }], audit_logs: [], retrieval_logs: [], memory_events: [], memory_profiles: [], memory_open_loops: [], memory_capture_candidates: [], memory_review_queue_items: [], memory_pruning_candidates: [] }; } +function store(): Store { return { pandora_operator_actions: [], pandora_operator_action_events: [], pandora_shadow_context_packs: [], pandora_shadow_context_pack_events: [], memory_context_packs: [{ id:"rl", user_id: USER, namespace:"real_life", pack_type:"master", status:"active", title:"RL", created_at:"2026-07-03" }, { id:"au", user_id: USER, namespace:"au", pack_type:"master", status:"active", title:"AU", created_at:"2026-07-03" }], audit_logs: [], retrieval_logs: [], memory_events: [{ id:"e1", user_id: USER, namespace:"real_life", status:"reviewed", extracted_summary:"RL event", created_at:"2026-07-03" }], memory_profiles: [], memory_open_loops: [], memory_capture_candidates: [], memory_review_queue_items: [], memory_pruning_candidates: [] }; } describe("pandora operator action service", () => { it("proposes allowed action and returns existing duplicate by idempotency", async () => { const s=store(); const c=client(s); const a=await proposeOperatorAction(c,{userId:USER, actionType:"verify_namespace_invariants", namespace:"real_life", mode:"dry_run", payload:{a:1}}); const b=await proposeOperatorAction(c,{userId:USER, actionType:"verify_namespace_invariants", namespace:"real_life", mode:"dry_run", payload:{a:1}}); expect(a.id).toBe(b.id); expect(s.pandora_operator_actions).toHaveLength(1); }); @@ -17,5 +17,6 @@ describe("pandora operator action service", () => { it("cancel only own action", async () => { const s=store(); const c=client(s); const a=await proposeOperatorAction(c,{userId:USER, actionType:"check_retrieval_eval_status"}); await expect(cancelOperatorAction(c,{userId:OTHER, actionId:a.id})).rejects.toThrow(/not found/); const cancelled=await cancelOperatorAction(c,{userId:USER, actionId:a.id}); expect(cancelled.status).toBe("cancelled"); }); it("approves proposed and dry_ran actions, then executes approved read-only action", async () => { const s=store(); const c=client(s); const a=await proposeOperatorAction(c,{userId:USER, actionType:"verify_pack_supersession", namespace:"real_life"}); const approved=await approveOperatorAction(c,{userId:USER, actionId:a.id}); expect(approved.status).toBe("approved"); const completed=await executeApprovedOperatorAction(c,{userId:USER, actionId:a.id}); expect(completed.status).toBe("completed"); expect(completed.result.no_mutation_performed).toBe(true); expect(JSON.stringify(completed.result)).toContain("pack_supersession"); expect(s.memory_context_packs).toHaveLength(2); }); it("blocks invalid transitions and cross-user events", async () => { const s=store(); const c=client(s); const a=await proposeOperatorAction(c,{userId:USER, actionType:"refresh_dashboard_snapshot"}); await expect(executeApprovedOperatorAction(c,{userId:USER, actionId:a.id})).rejects.toThrow(/Invalid/); await expect(approveOperatorAction(c,{userId:OTHER, actionId:a.id})).rejects.toThrow(/not found/); await expect(listOperatorActionEvents(c,{userId:OTHER, actionId:a.id})).rejects.toThrow(/not found/); const cancelled=await cancelOperatorAction(c,{userId:USER, actionId:a.id}); await expect(cancelOperatorAction(c,{userId:USER, actionId:cancelled.id})).rejects.toThrow(/terminal/); }); + it("prepare_shadow_context_pack dry-run and approved execute stage only shadow rows", async () => { const s=store(); const c=client(s); const a=await proposeOperatorAction(c,{userId:USER, actionType:"prepare_shadow_context_pack", namespace:"real_life", payload:{source:"operator_action_center"}}); const d=await dryRunOperatorAction(c,{userId:USER, actionId:a.id}); expect(d.result.evidence_summary).toMatchObject({ shadow_write_performed:false, no_core_memory_mutation_performed:true, no_promotion_performed:true }); expect(s.pandora_shadow_context_packs).toHaveLength(0); await approveOperatorAction(c,{userId:USER, actionId:a.id}); const completed=await executeApprovedOperatorAction(c,{userId:USER, actionId:a.id}); expect(completed.status).toBe("completed"); expect(completed.result.evidence_summary).toMatchObject({ shadow_write_performed:true, no_core_memory_mutation_performed:true, no_promotion_performed:true }); expect(s.pandora_shadow_context_packs).toHaveLength(1); expect(s.pandora_shadow_context_pack_events).toHaveLength(1); expect(s.memory_context_packs).toHaveLength(2); }); it("check_retrieval_eval_status reports unavailable evidence without fabricated accuracy", async () => { const s=store(); const c=client(s); const a=await proposeOperatorAction(c,{userId:USER, actionType:"check_retrieval_eval_status"}); await approveOperatorAction(c,{userId:USER, actionId:a.id}); const completed=await executeApprovedOperatorAction(c,{userId:USER, actionId:a.id}); expect(JSON.stringify(completed.result)).toContain("retrieval_eval_status"); expect(JSON.stringify(completed.result)).not.toContain("94.3"); }); }); diff --git a/tests/unit/pandora-shadow-context-pack-service.test.ts b/tests/unit/pandora-shadow-context-pack-service.test.ts new file mode 100644 index 0000000..9f5bafd --- /dev/null +++ b/tests/unit/pandora-shadow-context-pack-service.test.ts @@ -0,0 +1,16 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, expect, it } from "vitest"; +import { createShadowContextPackCandidate, dryRunShadowContextPackCandidate, listShadowContextPacks, updateShadowContextPackStatus } from "@/lib/services/pandora-shadow-context-pack-service"; +const USER="11111111-1111-4111-8111-111111111111", OTHER="22222222-2222-4222-8222-222222222222"; +type Store=Record; +function client(store:Store){ return { from(table:string){ const eqs:Record={}; let lim=Infinity; let patch:any; let inserted:any; const rows=()=> (store[table]??[]).filter(r=>Object.entries(eqs).every(([k,v])=>r[k]===v)).slice(0,lim); const b:any={ select(){return b}, eq(k:string,v:any){eqs[k]=v;return b}, order(){return b}, limit(n:number){lim=n;return b}, single(){return b}, insert(row:any){inserted=Array.isArray(row)?row[0]:row; store[table]=store[table]??[]; store[table].push(inserted); return b}, update(row:any){patch=row; return b}, then(res:any,rej:any){ if(patch){ const r=rows()[0]; if(r) Object.assign(r,patch); return Promise.resolve({data:r??null,error:r?null:{message:"missing"}}).then(res,rej)} return Promise.resolve({data:inserted??rows(),error:null}).then(res,rej)}}; return b; }} as any; } +function store():Store{return { memory_events:[{id:"e1",user_id:USER,namespace:"real_life",status:"reviewed",extracted_summary:"Real project update",created_at:"2026-07-03"},{id:"e2",user_id:USER,namespace:"au",status:"promoted",extracted_summary:"AU story beat",created_at:"2026-07-02"},{id:"e3",user_id:OTHER,namespace:"real_life",status:"reviewed",extracted_summary:"Other user",created_at:"2026-07-03"}], memory_context_packs:[{id:"m1",user_id:USER,namespace:"real_life",pack_type:"master",status:"active",created_at:"2026-07-01"},{id:"m2",user_id:USER,namespace:"au",pack_type:"master",status:"active",created_at:"2026-07-01"}], memory_open_loops:[{id:"l1",user_id:USER,namespace:"real_life",status:"open",created_at:"2026-07-03"}], memory_review_queue_items:[{id:"r1",user_id:USER,namespace:"real_life",status:"pending_review",created_at:"2026-07-03"}], pandora_shadow_context_packs:[], pandora_shadow_context_pack_events:[]};} +describe("shadow context pack service",()=>{ + it("dry-runs deterministically for real_life and never includes au rows", async()=>{const s=store(); const r=await dryRunShadowContextPackCandidate(client(s),{userId:USER,namespace:"real_life"}); expect(r.summary).toBe("Shadow candidate for real_life: 1 events considered, 1 open loops, 1 review items. Promotion is gated."); expect(JSON.stringify(r)).toContain("Real project update"); expect(JSON.stringify(r)).not.toContain("AU story beat");}); + it("dry-runs deterministically for au", async()=>{const r=await dryRunShadowContextPackCandidate(client(store()),{userId:USER,namespace:"au"}); expect(r.summary).toContain("Shadow candidate for au: 1 events considered"); expect(r.status).toBe("ready_for_review");}); + it("another user's rows never appear", async()=>{const r=await dryRunShadowContextPackCandidate(client(store()),{userId:OTHER,namespace:"real_life"}); expect(JSON.stringify(r)).toContain("Other user"); expect(JSON.stringify(r)).not.toContain("Real project update");}); + it("creates only shadow row and matching event", async()=>{const s=store(); const pack=await createShadowContextPackCandidate(client(s),{userId:USER,namespace:"real_life",requestId:"req"}); expect(pack.id).toBeTruthy(); expect(s.pandora_shadow_context_packs).toHaveLength(1); expect(s.pandora_shadow_context_pack_events).toHaveLength(1); expect(s.memory_events).toHaveLength(3); expect(s.memory_context_packs).toHaveLength(2);}); + it("missing evidence warns and stays draft", async()=>{const s=store(); s.memory_events=[]; s.memory_context_packs=[]; const r=await dryRunShadowContextPackCandidate(client(s),{userId:USER,namespace:"real_life"}); expect(r.status).toBe("draft"); expect(r.warnings.join(" ")).toContain("No active master"); expect(r.warnings.join(" ")).toContain("No memory_events");}); + it("updates status only for own pack and supports terminal review states", async()=>{const s=store(); const c=client(s); const p=await createShadowContextPackCandidate(c,{userId:USER,namespace:"real_life",requestId:"req"}); expect((await updateShadowContextPackStatus(c,{userId:USER,shadowPackId:p.id,status:"reviewed"})).status).toBe("reviewed"); expect((await updateShadowContextPackStatus(c,{userId:USER,shadowPackId:p.id,status:"rejected"})).status).toBe("rejected"); expect((await updateShadowContextPackStatus(c,{userId:USER,shadowPackId:p.id,status:"archived"})).status).toBe("archived"); await expect(updateShadowContextPackStatus(c,{userId:OTHER,shadowPackId:p.id,status:"reviewed"})).rejects.toThrow(/not found/);}); + it("list filters own namespace/status", async()=>{const s=store(); const c=client(s); await createShadowContextPackCandidate(c,{userId:USER,namespace:"real_life",requestId:"req"}); await createShadowContextPackCandidate(c,{userId:USER,namespace:"au",requestId:"req2"}); expect(await listShadowContextPacks(c,{userId:USER,namespace:"real_life"})).toHaveLength(1);}); +});