-
Notifications
You must be signed in to change notification settings - Fork 1
Phase 4A: Add controlled operator-approved memory persistence #99
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import { AppShell } from "@/components/layout/app-shell"; | ||
| import { PageHeader } from "@/components/ui/page-header"; | ||
| import { SectionCard } from "@/components/ui/section-card"; | ||
| import { resolvePandoraRuntimeSafetyConfig } from "@/lib/config/pandora-runtime-safety-config"; | ||
| import { resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; | ||
| import { createSupabaseServerClient } from "@/lib/supabase/server"; | ||
| import { getMemoryProposal } from "@/lib/services/memory-proposal-service"; | ||
| import { approveProposalAction, persistProposalAction, rejectProposalAction, reviseProposalAction } from "../actions"; | ||
| export const dynamic="force-dynamic"; | ||
| type Props={params:Promise<{id:string}>}; | ||
| export default async function Page({params}:Props){const {id}=await params; const runtime=resolvePandoraRuntimeSafetyConfig(); const session=await resolvePandoraServerSession(); const supabase=await createSupabaseServerClient() as never; const result=await getMemoryProposal(supabase,id,session,runtime); const p=result.ok?result.data:null; const gate=runtime.config.approvedReviewPersistenceEnabled; return <AppShell><PageHeader eyebrow="Internal admin" title="Memory proposal detail" description="Proposal review is admin-only, namespace-scoped, POST/server-action based, audited, and gated. Public writes remain disabled."/><SectionCard title="Safety copy" description="This does not write active memory until approved."><p>Approval is audited.</p><p>Public writes remain disabled.</p><p>Model calls, embeddings, retrieval, GPT Actions, and MCP remain disabled.</p><p>Reviewed write gate enabled: {String(gate)}</p></SectionCard>{!p?<SectionCard title="Unavailable" description={result.ok?"No proposal found.":result.error}><p>No proposal rows are exposed.</p></SectionCard>:<><SectionCard title={p.title??p.id} description={`Status: ${p.status}`}><dl><dt>Proposal id</dt><dd>{p.id}</dd><dt>Namespace</dt><dd>{p.namespace}</dd><dt>Memory text</dt><dd>{p.memory_text}</dd><dt>Source</dt><dd>{p.source_type} {p.source_ref}</dd><dt>Proposed by</dt><dd>{p.proposed_by}</dd><dt>Reviewed by</dt><dd>{p.reviewed_by??"—"}</dd><dt>Reviewed at</dt><dd>{p.reviewed_at??"—"}</dd><dt>Persisted memory id</dt><dd>{p.persisted_memory_id??"—"}</dd></dl></SectionCard><SectionCard title="Review actions" description="All actions are blocked while the reviewed persistence gate is disabled."><form action={approveProposalAction}><input type="hidden" name="id" value={p.id}/><button disabled={!gate||p.status!=="pending"}>Approve</button></form><form action={rejectProposalAction}><input type="hidden" name="id" value={p.id}/><input name="reason" placeholder="Rejection reason"/><button disabled={!gate||p.status!=="pending"}>Reject</button></form><form action={reviseProposalAction}><input type="hidden" name="id" value={p.id}/><input name="note" placeholder="Revision note"/><button disabled={!gate||p.status!=="pending"}>Request revision</button></form><form action={persistProposalAction}><input type="hidden" name="id" value={p.id}/><button disabled={!gate||p.status!=="approved"}>Persist approved proposal</button></form></SectionCard><SectionCard title="Audit timeline" description="Audit events are stored in audit_logs for proposal_created, proposal_approved, proposal_rejected, proposal_needs_revision, and proposal_persisted."><p>Use /admin/memory/audit with namespace {p.namespace} to inspect append-only audit proof.</p></SectionCard></>}</AppShell>} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| "use server"; | ||
|
|
||
| import { redirect } from "next/navigation"; | ||
| import { revalidatePath } from "next/cache"; | ||
| import { resolvePandoraRuntimeSafetyConfig } from "@/lib/config/pandora-runtime-safety-config"; | ||
| import { resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; | ||
| import { createSupabaseServerClient } from "@/lib/supabase/server"; | ||
| import { approveMemoryProposal, createMemoryProposal, persistApprovedMemoryProposal, rejectMemoryProposal, requestMemoryProposalRevision } from "@/lib/services/memory-proposal-service"; | ||
|
|
||
| function text(formData: FormData, key: string) { const value = formData.get(key); return typeof value === "string" && value.trim() ? value.trim() : undefined; } | ||
| function num(formData: FormData, key: string) { const value = text(formData, key); return value ? Number(value) : undefined; } | ||
| async function deps() { return { supabase: await createSupabaseServerClient() as never, session: await resolvePandoraServerSession(), runtime: resolvePandoraRuntimeSafetyConfig() }; } | ||
| function fail(message: string): never { throw new Error(message); } | ||
|
|
||
| export async function createProposalAction(formData: FormData) { | ||
| const { supabase, session, runtime } = await deps(); | ||
| const result = await createMemoryProposal(supabase, { namespace: (text(formData, "namespace") ?? "real_life") as "real_life" | "au", title: text(formData, "title"), memory_text: text(formData, "memory_text") ?? "", memory_type: text(formData, "memory_type"), source_type: text(formData, "source_type"), source_ref: text(formData, "source_ref"), confidence: num(formData, "confidence") }, session, runtime); | ||
| if (!result.ok) fail(result.error); | ||
| revalidatePath("/admin/memory/proposals"); | ||
| redirect(`/admin/memory/proposals/${result.data.id}`); | ||
| } | ||
| export async function approveProposalAction(formData: FormData) { const { supabase, session, runtime } = await deps(); const id = text(formData, "id") ?? fail("id required"); const r = await approveMemoryProposal(supabase, id, "server-action", session, runtime); if (!r.ok) fail(r.error); revalidatePath(`/admin/memory/proposals/${id}`); } | ||
| export async function rejectProposalAction(formData: FormData) { const { supabase, session, runtime } = await deps(); const id = text(formData, "id") ?? fail("id required"); const r = await rejectMemoryProposal(supabase, id, text(formData, "reason") ?? "Rejected by operator.", "server-action", session, runtime); if (!r.ok) fail(r.error); revalidatePath(`/admin/memory/proposals/${id}`); } | ||
| export async function reviseProposalAction(formData: FormData) { const { supabase, session, runtime } = await deps(); const id = text(formData, "id") ?? fail("id required"); const r = await requestMemoryProposalRevision(supabase, id, text(formData, "note") ?? "Revision requested by operator.", "server-action", session, runtime); if (!r.ok) fail(r.error); revalidatePath(`/admin/memory/proposals/${id}`); } | ||
| export async function persistProposalAction(formData: FormData) { const { supabase, session, runtime } = await deps(); const id = text(formData, "id") ?? fail("id required"); const r = await persistApprovedMemoryProposal(supabase, id, "server-action", session, runtime); if (!r.ok) fail(r.error); revalidatePath(`/admin/memory/proposals/${id}`); revalidatePath("/admin/memory/browser"); } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| import { AppShell } from "@/components/layout/app-shell";import { PageHeader } from "@/components/ui/page-header";import { SectionCard } from "@/components/ui/section-card";import { resolvePandoraRuntimeSafetyConfig } from "@/lib/config/pandora-runtime-safety-config";import { createProposalAction } from "../actions"; | ||
| export const dynamic="force-dynamic";export default function Page(){const runtime=resolvePandoraRuntimeSafetyConfig();return <AppShell><PageHeader eyebrow="Internal admin" title="New memory proposal" description="This does not write active memory until approved. Approval is audited. Public writes remain disabled. Model calls, embeddings, retrieval, GPT Actions, and MCP remain disabled."/><SectionCard title="Proposal form" description={runtime.config.approvedReviewPersistenceEnabled?"Reviewed write gate is enabled.":"Reviewed write gate is disabled; creation is blocked."}><form action={createProposalAction} className="memory-browser-filter-form"><label>Namespace<select name="namespace" defaultValue="real_life"><option value="real_life">real_life</option><option value="au">au</option></select></label><label>Title<input name="title"/></label><label>Memory text<textarea name="memory_text" required/></label><label>Memory type<input name="memory_type" placeholder="observation"/></label><label>Source type<input name="source_type" placeholder="manual_admin_entry"/></label><label>Source ref<input name="source_ref"/></label><label>Confidence<input name="confidence" type="number" min="0" max="1" step="0.0001"/></label><button disabled={!runtime.config.approvedReviewPersistenceEnabled}>Create pending proposal</button></form></SectionCard></AppShell>} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import Link from "next/link"; | ||
| import { AppShell } from "@/components/layout/app-shell"; | ||
| import { PageHeader } from "@/components/ui/page-header"; | ||
| import { SectionCard } from "@/components/ui/section-card"; | ||
| import { resolvePandoraServerSession } from "@/lib/auth/pandora-server-session-resolver"; | ||
| import { resolvePandoraRuntimeSafetyConfig } from "@/lib/config/pandora-runtime-safety-config"; | ||
| import { createSupabaseServerClient } from "@/lib/supabase/server"; | ||
| import { listMemoryProposals } from "@/lib/services/memory-proposal-service"; | ||
| export const dynamic = "force-dynamic"; | ||
| type Props = { searchParams?: Promise<Record<string,string|undefined>> }; | ||
| export default async function Page({ searchParams }: Props) { const p = await searchParams; const namespace = p?.namespace ?? "real_life"; const status = p?.status; const session = await resolvePandoraServerSession(); const runtime = resolvePandoraRuntimeSafetyConfig(); const supabase = await createSupabaseServerClient() as never; const proposals = await listMemoryProposals(supabase, namespace, status, session, runtime); return <AppShell><PageHeader eyebrow="Internal admin" title="Memory proposals" description="Controlled proposal → review → approve → persist → audit workflow. Public writes remain disabled. Model calls, embeddings, retrieval, GPT Actions, and MCP remain disabled."/><SectionCard title="Phase 4A gate" description="This does not write active memory until approved. Approval is audited."><p>Reviewed persistence gate enabled: {String(runtime.config.approvedReviewPersistenceEnabled)}</p><p>Public writes remain disabled: {String(!runtime.config.publicMemoryPersistenceEnabled)}</p><Link className="button-link button-link--primary" href="/admin/memory/proposals/new">New proposal</Link></SectionCard><SectionCard title="Filters" description="Namespace-scoped proposal list."><form><select name="namespace" defaultValue={namespace}><option value="real_life">real_life</option><option value="au">au</option></select><select name="status" defaultValue={status ?? ""}><option value="">all</option>{["pending","approved","rejected","needs_revision","persisted","disabled"].map(s=><option key={s} value={s}>{s}</option>)}</select><button>Apply</button></form>{!proposals.ok ? <p>{proposals.error}</p> : <ul>{proposals.data.map(x => <li key={x.id}><Link href={`/admin/memory/proposals/${x.id}`}>{x.title ?? x.id}</Link> — {x.status} — {x.namespace}</li>)}</ul>}</SectionCard></AppShell> } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| # Phase 4A: Controlled operator-approved memory persistence | ||
|
|
||
| Phase 4A exists to make Pandora Memory able to create memory only through a reviewed operator workflow. Phase 3F closed the read-only foundation: authenticated admin readback, admin browser, audit route, public-read blocking, and dangerous runtime gates disabled. | ||
|
|
||
| ## What Phase 4A adds | ||
|
|
||
| - Pending `memory_proposals` records. | ||
| - Operator review actions: approve, reject, request revision, persist approved proposal. | ||
| - Append-only writes to active memory only after approval. | ||
| - Audit events for proposal lifecycle actions. | ||
| - Admin UI under `/admin/memory/proposals`. | ||
|
|
||
| ## Proposal lifecycle | ||
|
|
||
| 1. Authenticated operator creates a pending proposal. | ||
| 2. Proposal is not active memory. | ||
| 3. Authenticated operator approves, rejects, or requests revision. | ||
| 4. Only an approved proposal can be persisted. | ||
| 5. Persistence links the proposal to a memory item and writes source, patch, and audit proof. | ||
|
|
||
| ## Env gates | ||
|
|
||
| `PANDORA_ENABLE_APPROVED_REVIEW_MEMORY_PERSISTENCE=true` is required for all mutations. The default is disabled. Public reads and public writes remain disabled. | ||
|
|
||
| ## RLS expectations | ||
|
|
||
| `memory_proposals` is RLS-enabled. Authenticated users can access only their own namespace-scoped proposal rows. App routes must use server-derived Supabase Auth identity and must not use service-role clients. | ||
|
|
||
| ## Audit requirements | ||
|
|
||
| Lifecycle events include `proposal_created`, `proposal_approved`, `proposal_rejected`, `proposal_needs_revision`, `proposal_persisted`, and `proposal_disabled` if disabling is used later. | ||
|
|
||
| ## Manual verification checklist | ||
|
|
||
| - Confirm `/admin/memory/verification` says read-only foundation remains closed. | ||
| - Confirm Phase 4A is disabled when the reviewed write gate is absent. | ||
| - Enable the reviewed write gate in a reviewed environment. | ||
| - Create a pending proposal as an authenticated operator. | ||
| - Approve it. | ||
| - Persist it. | ||
| - Confirm the row appears in `/admin/memory/browser?namespace=real_life`. | ||
| - Confirm audit events exist. | ||
| - Confirm `/memory/browser` redirects and never renders public rows. | ||
|
|
||
| ## Rollback/no-close rules | ||
|
|
||
| Do not close Phase 4A if proposal mutation is public, unauthenticated, unaudited, not namespace-scoped, or bypasses RLS. Roll back by disabling `PANDORA_ENABLE_APPROVED_REVIEW_MEMORY_PERSISTENCE`. | ||
|
|
||
| ## Must remain disabled | ||
|
|
||
| - Public reads | ||
| - Public writes | ||
| - Model calls | ||
| - Embeddings | ||
| - Semantic retrieval | ||
| - GPT Actions | ||
| - MCP |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| # Memory proposal approval proof | ||
|
|
||
| - Proposal id: | ||
| - Namespace: | ||
| - Proposed by: | ||
| - Reviewed by: | ||
| - Reviewed at: | ||
| - Status: | ||
| - Persisted memory id: | ||
| - Audit event ids: | ||
| - Source proof: | ||
| - Final decision: | ||
| - Blockers: | ||
| - Reviewer sign-off: |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -18,7 +18,7 @@ export async function resolvePandoraServerSession(input: { request?: NextRequest | |||||||||||||||||
| if (input.testSession) return { ok: true, session: { ...input.testSession, serverDerivedOnly: true, clientUserIdAccepted: false, serviceRoleUsed: false }, blockers: [] }; | ||||||||||||||||||
| const user = await getCurrentUser(); | ||||||||||||||||||
| if (!user?.id) return { ok: false, session: null, blockers: [{ code: "auth_required", message: "Authenticated server session is required." }, { code: "session_unavailable", message: "No real auth provider session was resolved; no production user is faked." }] }; | ||||||||||||||||||
| const session: PandoraServerSession = { userId: user.id, email: user.email, displayName: user.user_metadata?.name ?? user.user_metadata?.full_name, authProvider: user.app_metadata?.provider, authenticated: true, allowedNamespaces: ["real_life", "au"], adminCapabilities: [], isInternalOperator: false, isPersistenceOperator: false, sessionSource: "supabase", serverDerivedOnly: true, clientUserIdAccepted: false, publicReadAllowed: false, publicPersistenceAllowed: false, serviceRoleUsed: false }; | ||||||||||||||||||
| const session: PandoraServerSession = { userId: user.id, email: user.email, displayName: user.user_metadata?.name ?? user.user_metadata?.full_name, authProvider: user.app_metadata?.provider, authenticated: true, allowedNamespaces: ["real_life", "au"], adminCapabilities: Array.isArray(user.app_metadata?.adminCapabilities) ? user.app_metadata.adminCapabilities : Array.isArray(user.app_metadata?.roles) ? user.app_metadata.roles : [], isInternalOperator: user.app_metadata?.role === "admin" || user.app_metadata?.role === "operator" || user.app_metadata?.isInternalOperator === true, isPersistenceOperator: user.app_metadata?.isPersistenceOperator === true || user.app_metadata?.role === "persistence_operator", sessionSource: "supabase", serverDerivedOnly: true, clientUserIdAccepted: false, publicReadAllowed: false, publicPersistenceAllowed: false, serviceRoleUsed: false }; | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Include array-based roles when deriving operator flags.
Suggested fix+ const roles = Array.isArray(user.app_metadata?.roles)
+ ? user.app_metadata.roles.filter((role): role is string => typeof role === "string")
+ : [];
+ const adminCapabilities = Array.isArray(user.app_metadata?.adminCapabilities)
+ ? user.app_metadata.adminCapabilities.filter((cap): cap is string => typeof cap === "string")
+ : roles;
- const session: PandoraServerSession = { userId: user.id, email: user.email, displayName: user.user_metadata?.name ?? user.user_metadata?.full_name, authProvider: user.app_metadata?.provider, authenticated: true, allowedNamespaces: ["real_life", "au"], adminCapabilities: Array.isArray(user.app_metadata?.adminCapabilities) ? user.app_metadata.adminCapabilities : Array.isArray(user.app_metadata?.roles) ? user.app_metadata.roles : [], isInternalOperator: user.app_metadata?.role === "admin" || user.app_metadata?.role === "operator" || user.app_metadata?.isInternalOperator === true, isPersistenceOperator: user.app_metadata?.isPersistenceOperator === true || user.app_metadata?.role === "persistence_operator", sessionSource: "supabase", serverDerivedOnly: true, clientUserIdAccepted: false, publicReadAllowed: false, publicPersistenceAllowed: false, serviceRoleUsed: false };
+ const session: PandoraServerSession = { userId: user.id, email: user.email, displayName: user.user_metadata?.name ?? user.user_metadata?.full_name, authProvider: user.app_metadata?.provider, authenticated: true, allowedNamespaces: ["real_life", "au"], adminCapabilities, isInternalOperator: roles.includes("admin") || roles.includes("operator") || user.app_metadata?.role === "admin" || user.app_metadata?.role === "operator" || user.app_metadata?.isInternalOperator === true, isPersistenceOperator: roles.includes("persistence_operator") || user.app_metadata?.isPersistenceOperator === true || user.app_metadata?.role === "persistence_operator", sessionSource: "supabase", serverDerivedOnly: true, clientUserIdAccepted: false, publicReadAllowed: false, publicPersistenceAllowed: false, serviceRoleUsed: false };📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| return { ok: true, session, blockers: [] }; | ||||||||||||||||||
| } | ||||||||||||||||||
| export function createRepositoryContextFromPandoraSession(input: PandoraRepositoryContextFromSessionInput): PandoraRepositoryContextFromSessionResult { | ||||||||||||||||||
|
|
||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🔴 Critical
🧩 Analysis chain
🏁 Script executed:
Repository: besfeng23/Memory
Length of output: 247
🏁 Script executed:
Repository: besfeng23/Memory
Length of output: 501
🏁 Script executed:
Repository: besfeng23/Memory
Length of output: 1520
Missing authentication guard exposes admin UI to unauthenticated users.
middleware.tscallsawait supabase.auth.getUser()but unconditionally returnsNextResponse.next()without checking the result or redirecting unauthorized users. Additionally,app/adminlacks alayout.tsxguard. Consequently, thePagecomponent renders the admin shell (headers, CTAs, filters) before verifyingsession.ok.Wrap the return value in
if (!session.ok) return <ForbiddenOrRedirect />;or add a layout guard that redirects ifsession.erroris present.🤖 Prompt for AI Agents