Skip to content

Phase 4A: Add controlled operator-approved memory persistence#99

Open
besfeng23 wants to merge 1 commit into
mainfrom
codex/add-controlled-operator-approved-memory-persistence
Open

Phase 4A: Add controlled operator-approved memory persistence#99
besfeng23 wants to merge 1 commit into
mainfrom
codex/add-controlled-operator-approved-memory-persistence

Conversation

@besfeng23

@besfeng23 besfeng23 commented Jun 26, 2026

Copy link
Copy Markdown
Owner

Motivation

  • Implement Phase 4A to enable operator-approved, audited, append-only memory persistence behind a reviewed runtime gate while preserving the Phase 3F read-only closure.
  • Ensure every mutation is admin-gated, namespace-scoped, RLS-protected, server-derived, and audited, and avoid any model/embedding/retrieval/GPT/MCP/service-role exposure.

Description

  • Add database migration supabase/migrations/20260626040000_phase_4a_memory_proposals.sql that creates public.memory_proposals, RLS policies scoped to authenticated user + namespace, and a security invoker RPC memory_persist_approved_proposal that appends memory item, source, patch, and audit proof for approved proposals.
  • Implement proposal lifecycle backend at lib/services/memory-proposal-service.ts with createMemoryProposal, listMemoryProposals, getMemoryProposal, approveMemoryProposal, rejectMemoryProposal, requestMemoryProposalRevision, and persistApprovedMemoryProposal enforcing session, admin capability, namespace checks, gate enforcement (PANDORA_ENABLE_APPROVED_REVIEW_MEMORY_PERSISTENCE), input validation, and audit writes.
  • Add admin-only server-actions and UI under app/admin/memory/proposals (list, new, detail/review) in actions.ts, page.tsx, new/page.tsx, and [id]/page.tsx, with blunt safety copy and gate-disabled UI states; mutations use server actions/POST only and call server Supabase client.
  • Extend app/admin/memory/verification/page.tsx with a Phase 4A readiness section while preserving Phase 3F read-only closure messaging, adjust server session resolution to derive admin/operator capabilities from Supabase app metadata, and add docs docs/phase-4a-controlled-memory-persistence.md and proof template docs/templates/memory-proposal-approval-proof.md.
  • Add unit tests tests/unit/memory-proposal-service.test.ts covering auth/gate blocks, empty text rejection, create/approve/reject audit events, approved persistence, double-persist blocking, and rejected-proposal persist blocking.

Testing

  • Ran npm run typecheck, which passed without errors.
  • Ran npm run lint, which passed; remaining pre-existing warnings were noted in lib/db/core-repositories.ts and vitest.config.ts and did not block the change.
  • Ran npm run test, which passed (73 files, 439 tests), and the new unit tests in tests/unit/memory-proposal-service.test.ts passed.
  • Ran npm run build, which completed successfully; the build reported an existing Supabase Edge Runtime warning but did not fail.

Codex Task

Summary by CodeRabbit

  • New Features

    • Added an admin memory proposal workflow, including list, create, review, approve, reject, revise, and persist screens/actions.
    • Added a read-only verification section showing controlled persistence readiness and gate status.
    • Introduced database support for storing proposals, approval tracking, audit history, and approved persistence.
  • Documentation

    • Added guidance for the controlled memory persistence phase and a review-proof template.
  • Bug Fixes

    • Improved session role detection so admin and persistence access reflects user metadata more accurately.
  • Tests

    • Added unit coverage for proposal creation, review transitions, persistence, and error handling.

@vercel

vercel Bot commented Jun 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
memory Ready Ready Preview, Comment Jun 26, 2026 2:45pm

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a controlled memory-proposal flow for admins: a new proposal table and persistence function, server-side proposal services and actions, admin pages for listing, creation, review, and persistence, plus phase-4a documentation, readiness notes, and unit tests.

Changes

Phase 4A memory proposals

Layer / File(s) Summary
Storage and access contract
supabase/migrations/20260626040000_phase_4a_memory_proposals.sql, lib/auth/pandora-server-session-resolver.ts, lib/services/memory-proposal-service.ts, docs/phase-4a-controlled-memory-persistence.md
memory_proposals is added with RLS and a persistence RPC, server session roles now come from app_metadata, and the service defines proposal types, namespace checks, audit writes, and create/list/get operations.
Submission pages and create action
app/admin/memory/proposals/actions.ts, app/admin/memory/proposals/page.tsx, app/admin/memory/proposals/new/page.tsx
The admin list and new pages render proposal filters and the submission form, and createProposalAction creates a proposal, revalidates the list, and redirects to the new detail page.
Review and persistence flow
app/admin/memory/proposals/[id]/page.tsx, app/admin/memory/verification/page.tsx, lib/services/memory-proposal-service.ts, supabase/migrations/20260626040000_phase_4a_memory_proposals.sql, tests/unit/memory-proposal-service.test.ts, docs/templates/memory-proposal-approval-proof.md, docs/phase-4a-controlled-memory-persistence.md
Review actions update proposal status, the detail page renders approve/reject/revise/persist forms, persistence uses the SQL RPC path, the verification page adds Phase 4A readiness checks, and the unit tests cover create, approve, reject, and persist outcomes.

Sequence Diagram(s)

sequenceDiagram
  participant Admin as Admin reviewer
  participant NewPage as app/admin/memory/proposals/new/page.tsx
  participant CreateAction as createProposalAction
  participant ProposalService as createMemoryProposal
  participant ProposalTable as public.memory_proposals
  Admin->>NewPage: submit proposal form
  NewPage->>CreateAction: FormData
  CreateAction->>ProposalService: createMemoryProposal(...)
  ProposalService->>ProposalTable: insert pending proposal + audit
  CreateAction->>Admin: redirect to /app/admin/memory/proposals/[id]
Loading
sequenceDiagram
  participant Admin as Admin reviewer
  participant DetailPage as app/admin/memory/proposals/[id]/page.tsx
  participant ProposalActions as app/admin/memory/proposals/actions.ts
  participant ProposalService as persistApprovedMemoryProposal
  participant Rpc as public.memory_persist_approved_proposal
  participant MemoryTables as public.memory_items/public.audit_logs
  Admin->>DetailPage: submit review or persist form
  DetailPage->>ProposalActions: action request
  ProposalActions->>ProposalService: approve/reject/revise/persist
  ProposalService->>Rpc: persist approved proposal
  Rpc->>MemoryTables: write persisted memory and audit rows
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

A bunny hops through gates of light,
proposals bloom both day and night.
Approved, persisted, audit-signed,
with carrot trails of code aligned. 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: Phase 4A controlled, operator-approved memory persistence.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/add-controlled-operator-approved-memory-persistence

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fc435cfcdb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

async function audit(client: Client, p: MemoryProposal, action: string, reviewer: string, extra: Record<string, unknown> = {}) {
return client.from("audit_logs").insert({ user_id: p.user_id, namespace: p.namespace, action, table_name: "memory_proposals", record_id: p.id, after_snapshot: { status: p.status, ...extra }, metadata: { proposalId: p.id, reviewer, phase: "4A", appendOnly: true } }).select("*").single();
}
export async function createMemoryProposal(client: Client, input: ProposalInput, session: PandoraServerSessionResult, runtime: PandoraRuntimeSafetyConfigResult): Promise<Result<MemoryProposal>> { const pf = preflight(session, runtime, input.namespace, true); if (!pf.ok) return pf; if (!input.memory_text?.trim()) return { ok: false, code: "empty_memory_text", error: "memory_text must not be empty." }; const row = { ...input, memory_text: input.memory_text.trim(), user_id: pf.data.userId, proposed_by: pf.data.userId, status: "pending" }; const res = await client.from("memory_proposals").insert(row).select("*").single(); if (res.error || !res.data) return { ok: false, code: "write_error", error: res.error?.message ?? "Proposal insert failed." }; await audit(client, res.data, "proposal_created", pf.data.userId); return { ok: true, data: res.data }; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block proposal writes when audit insertion fails

When audit_logs rejects the insert, Supabase returns { error } rather than throwing, but this path ignores that result and still returns success after creating the proposal; the same pattern is used for review transitions. I checked the migrations and audit_logs has RLS enabled with no insert policy, so in the current authenticated public-key server client these lifecycle writes can succeed without the required audit proof.

Useful? React with 👍 / 👎.

Comment on lines +74 to +81
insert into public.memory_items (id,user_id,namespace,memory_type,title,body,confidence,source_summary,metadata,created_at,updated_at)
values (v_memory_item_id,v_user_id,v_proposal.namespace::public.pandora_namespace,v_memory_type,coalesce(v_proposal.title,left(v_proposal.memory_text,120)),v_proposal.memory_text,coalesce(v_proposal.confidence,0.5),v_proposal.source_ref,jsonb_build_object('proposalId',v_proposal.id,'proposedBy',v_proposal.proposed_by,'reviewedBy',v_proposal.reviewed_by,'reviewedAt',v_proposal.reviewed_at,'phase','4A','appendOnly',true),v_now,v_now);
insert into public.memory_sources (id,user_id,namespace,memory_item_id,source_type,source_ref,excerpt,confidence,metadata,created_at)
values (v_source_id,v_user_id,v_proposal.namespace::public.pandora_namespace,v_memory_item_id,v_source_type,v_proposal.source_ref,v_proposal.memory_text,coalesce(v_proposal.confidence,0.5),jsonb_build_object('proposalId',v_proposal.id,'appendOnly',true),v_now);
insert into public.memory_patches (id,user_id,namespace,memory_item_id,patch_type,reason,before_snapshot,after_snapshot,metadata,created_at)
values (v_patch_id,v_user_id,v_proposal.namespace::public.pandora_namespace,v_memory_item_id,'append','phase_4a_approved_proposal',null,jsonb_build_object('body',v_proposal.memory_text,'namespace',v_proposal.namespace),jsonb_build_object('proposalId',v_proposal.id,'appendOnly',true),v_now);
insert into public.audit_logs (id,user_id,namespace,action,table_name,record_id,before_snapshot,after_snapshot,metadata,created_at)
values (v_audit_id,v_user_id,v_proposal.namespace::public.pandora_namespace,'proposal_persisted','memory_proposals',v_proposal.id,null,jsonb_build_object('memoryItemId',v_memory_item_id),jsonb_build_object('proposalId',v_proposal.id,'sourceId',v_source_id,'patchId',v_patch_id,'appendOnly',true),v_now);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add an RLS-allowed path for persistence inserts

This RPC is security invoker, and I checked the existing migrations: memory_items, memory_sources, memory_patches, and audit_logs have RLS enabled but no authenticated insert policies. When an operator clicks “Persist approved proposal”, the function will reach these inserts as the authenticated user and be rejected by RLS, so Phase 4A persistence cannot actually append the memory/source/patch/audit rows.

Useful? React with 👍 / 👎.

Comment on lines +37 to +40
create policy "memory_proposals_authenticated_update_own_namespace"
on public.memory_proposals for update to authenticated
using (auth.uid() = user_id)
with check (auth.uid() = user_id and namespace in ('real_life','au'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict proposal status updates to the review boundary

This policy lets any authenticated owner update any column on their proposal, including status, reviewed_by, and persisted_memory_id, without the server-side admin/capability preflight or the reviewed persistence gate. In a normal Supabase deployment with the public client available, that means proposal approval state is not operator-only at the database boundary, so direct clients can manufacture reviewed-looking rows.

Useful? React with 👍 / 👎.

Comment on lines +67 to +69
if v_proposal.memory_type in ('observation','user_preference','soft_canon','hard_canon','contradiction','retcon_candidate','real_life_fact','business_fact','relationship_signal','risk_signal') then
v_memory_type := v_proposal.memory_type::public.memory_type;
end if;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate memory type against the selected namespace

Because proposals store memory_type as free-form text and the form accepts arbitrary input, this allow-list persists AU-only types such as soft_canon/hard_canon into real_life rows and real-life types such as business_fact into au rows. The existing validators split allowed types by namespace, so approving one of these proposals breaks the namespace separation rules instead of rejecting it before append.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/admin/memory/proposals/page.tsx`:
- Line 11: The admin memory proposals page renders the full admin shell before
verifying authentication, so add an authorization guard in Page after
resolvePandoraServerSession() and before any UI is returned. Use the session
result from resolvePandoraServerSession() to short-circuit unauthenticated users
with a redirect or forbidden/unauthorized component, or move the check into an
app/admin layout guard so Page only runs for authorized sessions. Keep the guard
tied to the existing Page and session.ok/session.error handling so the admin UI,
filters, and proposal list are never exposed to unauthenticated users.

In `@lib/auth/pandora-server-session-resolver.ts`:
- Line 21: The session derivation in PandoraServerSessionResolver currently
checks adminCapabilities against app_metadata.roles but does not apply the same
array fallback when computing isInternalOperator and isPersistenceOperator.
Update the session object construction so the operator flags in the
PandoraServerSession derived from user.app_metadata also consider roles arrays
(for example alongside role and explicit booleans), keeping the logic consistent
with the existing adminCapabilities fallback.

In `@lib/services/memory-proposal-service.ts`:
- Around line 27-30: The transition() helper in memory-proposal-service.ts only
rejects persisted and disabled states, so reviewed proposals can still be moved
between approved, rejected, and needs_revision via approveMemoryProposal,
rejectMemoryProposal, and requestMemoryProposalRevision. Update transition() to
enforce the full review state machine by allowing status changes only from
pending into a review outcome and blocking any transition from already reviewed
or terminal statuses, keeping the guard centralized in transition().
- Around line 21-24: The createMemoryProposal flow and the audit() helper
currently perform separate best-effort writes, so a proposal can succeed even if
the audit_logs insert fails. Refactor memory-proposal-service.ts so
createMemoryProposal (and any other callers that use audit()) uses a single
transactional DB operation or RPC that writes the proposal mutation and audit
record atomically, and make the caller fail if the audit write does not succeed.
Keep the existing audit() and createMemoryProposal symbols easy to locate when
updating the flow.

In `@supabase/migrations/20260626040000_phase_4a_memory_proposals.sql`:
- Around line 33-40: The Phase 4A write path is still bypassable because the
`memory_proposals_authenticated_insert_own_namespace` and
`memory_proposals_authenticated_update_own_namespace` policies let a normal
authenticated user create and mutate proposal state directly, which then allows
`memory_persist_approved_proposal()` to trust a forged `approved` row and write
active-memory tables. Tighten the database boundary by changing the
`public.memory_proposals` policies to keep direct writes pending-only or
read-only, and ensure the approval/persistence transition is only reachable
through a DB-controlled routine that verifies operator/reviewed-write claims
before any insert into `memory_items`, `memory_sources`, `memory_patches`, or
`audit_logs`. Also revoke broad `UPDATE` and `EXECUTE` access so the reviewed
gate is enforced in the database, not only in the TS service.

In `@tests/unit/memory-proposal-service.test.ts`:
- Line 18: Tighten the mock in the test helper’s single() implementation so it
only returns a record when the filters match exactly. Remove the store.at(-1)
fallback and return a “not found” error when no item satisfies
filters.every(...), so the assertions around MemoryProposalService queries
continue to verify the id/user_id contract.
- Around line 41-44: The test around persistApprovedMemoryProposal only checks
the ok flag, so it can miss cases where the proposal is not actually moved to
persisted. Update the assertions in memory-proposal-service.test.ts to verify
the refreshed proposal state after the first call, including that the status is
persisted and persisted_memory_id is set, and keep the existing audit check plus
the second-call no-op expectation to catch regressions in
persistApprovedMemoryProposal.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 27d90f82-773c-4200-8f97-09c11af06265

📥 Commits

Reviewing files that changed from the base of the PR and between 789d655 and fc435cf.

📒 Files selected for processing (11)
  • app/admin/memory/proposals/[id]/page.tsx
  • app/admin/memory/proposals/actions.ts
  • app/admin/memory/proposals/new/page.tsx
  • app/admin/memory/proposals/page.tsx
  • app/admin/memory/verification/page.tsx
  • docs/phase-4a-controlled-memory-persistence.md
  • docs/templates/memory-proposal-approval-proof.md
  • lib/auth/pandora-server-session-resolver.ts
  • lib/services/memory-proposal-service.ts
  • supabase/migrations/20260626040000_phase_4a_memory_proposals.sql
  • tests/unit/memory-proposal-service.test.ts

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> }

Copy link
Copy Markdown

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:

#!/bin/bash
set -euo pipefail

echo "== app/admin layouts with auth/redirect logic =="
fd -a 'layout.(ts|tsx)' app/admin | xargs -r rg -n -C3 'resolvePandoraServerSession|redirect\(|forbidden\(|unauthorized\(|notFound\('

echo "== middleware touching /admin routes =="
fd -a 'middleware.(ts|tsx|js|mjs)' . | xargs -r rg -n -C3 '/admin|redirect\(|forbidden\(|unauthorized\('

echo "== local page body =="
sed -n '1,160p' app/admin/memory/proposals/page.tsx

Repository: besfeng23/Memory

Length of output: 247


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "Checking directory structure..."
ls -la app/admin/ 2>/dev/null || echo "app/admin not found or empty"
echo "Checking for layout files..."
ls -la app/admin/layout.* 2>/dev/null || echo "No layout files in app/admin"
echo "Checking for middleware..."
ls -la middleware.* 2>/dev/null || echo "No middleware file in root"

Repository: besfeng23/Memory

Length of output: 501


🏁 Script executed:

cat middleware.ts

Repository: besfeng23/Memory

Length of output: 1520


Missing authentication guard exposes admin UI to unauthenticated users.

middleware.ts calls await supabase.auth.getUser() but unconditionally returns NextResponse.next() without checking the result or redirecting unauthorized users. Additionally, app/admin lacks a layout.tsx guard. Consequently, the Page component renders the admin shell (headers, CTAs, filters) before verifying session.ok.

Wrap the return value in if (!session.ok) return <ForbiddenOrRedirect />; or add a layout guard that redirects if session.error is present.

// app/admin/memory/proposals/page.tsx
if (!session.ok) {
  return <NotAuthorizedOrRedirect />; // or redirect('/login')
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/admin/memory/proposals/page.tsx` at line 11, The admin memory proposals
page renders the full admin shell before verifying authentication, so add an
authorization guard in Page after resolvePandoraServerSession() and before any
UI is returned. Use the session result from resolvePandoraServerSession() to
short-circuit unauthenticated users with a redirect or forbidden/unauthorized
component, or move the check into an app/admin layout guard so Page only runs
for authorized sessions. Keep the guard tied to the existing Page and
session.ok/session.error handling so the admin UI, filters, and proposal list
are never exposed to unauthenticated users.

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 };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.

adminCapabilities already falls back to app_metadata.roles, but isInternalOperator / isPersistenceOperator ignore that same array. A session shaped like { roles: ["admin"] } or { roles: ["persistence_operator"] } will still resolve to false here, so the new proposal preflights reject legitimate operators.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 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, 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 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/auth/pandora-server-session-resolver.ts` at line 21, The session
derivation in PandoraServerSessionResolver currently checks adminCapabilities
against app_metadata.roles but does not apply the same array fallback when
computing isInternalOperator and isPersistenceOperator. Update the session
object construction so the operator flags in the PandoraServerSession derived
from user.app_metadata also consider roles arrays (for example alongside role
and explicit booleans), keeping the logic consistent with the existing
adminCapabilities fallback.

Comment on lines +21 to +24
async function audit(client: Client, p: MemoryProposal, action: string, reviewer: string, extra: Record<string, unknown> = {}) {
return client.from("audit_logs").insert({ user_id: p.user_id, namespace: p.namespace, action, table_name: "memory_proposals", record_id: p.id, after_snapshot: { status: p.status, ...extra }, metadata: { proposalId: p.id, reviewer, phase: "4A", appendOnly: true } }).select("*").single();
}
export async function createMemoryProposal(client: Client, input: ProposalInput, session: PandoraServerSessionResult, runtime: PandoraRuntimeSafetyConfigResult): Promise<Result<MemoryProposal>> { const pf = preflight(session, runtime, input.namespace, true); if (!pf.ok) return pf; if (!input.memory_text?.trim()) return { ok: false, code: "empty_memory_text", error: "memory_text must not be empty." }; const row = { ...input, memory_text: input.memory_text.trim(), user_id: pf.data.userId, proposed_by: pf.data.userId, status: "pending" }; const res = await client.from("memory_proposals").insert(row).select("*").single(); if (res.error || !res.data) return { ok: false, code: "write_error", error: res.error?.message ?? "Proposal insert failed." }; await audit(client, res.data, "proposal_created", pf.data.userId); return { ok: true, data: res.data }; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make the proposal mutation and audit write atomic.

Line 24 and Line 27 both await audit(), but neither caller checks whether the audit_logs insert succeeded. A proposal can therefore be created or transitioned successfully without the append-only proof this Phase 4A flow is supposed to guarantee. This should be one transactional database operation/RPC, not two best-effort writes.

Also applies to: 27-27

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/services/memory-proposal-service.ts` around lines 21 - 24, The
createMemoryProposal flow and the audit() helper currently perform separate
best-effort writes, so a proposal can succeed even if the audit_logs insert
fails. Refactor memory-proposal-service.ts so createMemoryProposal (and any
other callers that use audit()) uses a single transactional DB operation or RPC
that writes the proposal mutation and audit record atomically, and make the
caller fail if the audit write does not succeed. Keep the existing audit() and
createMemoryProposal symbols easy to locate when updating the flow.

Comment on lines +27 to +30
async function transition(client: Client, id: string, status: MemoryProposalStatus, session: PandoraServerSessionResult, runtime: PandoraRuntimeSafetyConfigResult, fields: Record<string, unknown> = {}) { const pf = preflight(session, runtime, undefined, true); if (!pf.ok) return pf; const cur = await getMemoryProposal(client, id, session, runtime); if (!cur.ok) return cur; if (["persisted", "disabled"].includes(cur.data.status)) return { ok: false as const, code: "terminal_status", error: "Terminal proposals cannot transition." }; const reviewed_at = new Date().toISOString(); const res = await client.from("memory_proposals").update({ status, reviewed_by: pf.data.userId, reviewed_at, updated_at: reviewed_at, ...fields }).eq("id", id).eq("user_id", pf.data.userId).select("*").single(); if (res.error || !res.data) return { ok: false as const, code: "write_error", error: res.error?.message ?? "Transition failed." }; await audit(client, res.data, `proposal_${status}`, pf.data.userId, fields); return { ok: true as const, data: res.data }; }
export const approveMemoryProposal = (client: Client, id: string, reviewer: string, session: PandoraServerSessionResult, runtime: PandoraRuntimeSafetyConfigResult) => transition(client, id, "approved", session, runtime, { reviewer });
export const rejectMemoryProposal = (client: Client, id: string, reason: string, reviewer: string, session: PandoraServerSessionResult, runtime: PandoraRuntimeSafetyConfigResult) => transition(client, id, "rejected", session, runtime, { rejection_reason: reason, reviewer });
export const requestMemoryProposalRevision = (client: Client, id: string, note: string, reviewer: string, session: PandoraServerSessionResult, runtime: PandoraRuntimeSafetyConfigResult) => transition(client, id, "needs_revision", session, runtime, { revision_note: note, reviewer });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Enforce the review state machine in transition().

This helper only blocks persisted and disabled, so a crafted POST can still approve, reject, or revise proposals that are already reviewed. The detail page disables those buttons unless the status is pending, but the exported server actions call this function directly, so rejected / needs_revision proposals can be re-approved and later persisted.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/services/memory-proposal-service.ts` around lines 27 - 30, The
transition() helper in memory-proposal-service.ts only rejects persisted and
disabled states, so reviewed proposals can still be moved between approved,
rejected, and needs_revision via approveMemoryProposal, rejectMemoryProposal,
and requestMemoryProposalRevision. Update transition() to enforce the full
review state machine by allowing status changes only from pending into a review
outcome and blocking any transition from already reviewed or terminal statuses,
keeping the guard centralized in transition().

Comment on lines +33 to +40
create policy "memory_proposals_authenticated_insert_own_namespace"
on public.memory_proposals for insert to authenticated
with check (auth.uid() = user_id and auth.uid() = proposed_by and status = 'pending' and reviewed_by is null and persisted_memory_id is null);

create policy "memory_proposals_authenticated_update_own_namespace"
on public.memory_proposals for update to authenticated
using (auth.uid() = user_id)
with check (auth.uid() = user_id and namespace in ('real_life','au'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

Lock down the DB write path to reviewed operators only.

These policies plus the invoker RPC make the Phase 4A boundary bypassable with a normal authenticated JWT. A row owner can insert their own proposal, update status/review fields directly under Lines 37-40, then call memory_persist_approved_proposal() because the function only trusts auth.uid() and status = 'approved' before writing memory_items, memory_sources, memory_patches, and audit_logs. That means the reviewed runtime gate and admin approval flow are enforced only in the TS service, not at the actual database boundary.

Please move the approval/persist transition behind a DB-enforced privilege boundary as well: keep direct table writes pending-only (or read-only), revoke broad UPDATE/EXECUTE access, and perform approval/persistence through routines that validate operator claims and the reviewed-write gate before any active-memory insert happens.

Also applies to: 42-83

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260626040000_phase_4a_memory_proposals.sql` around
lines 33 - 40, The Phase 4A write path is still bypassable because the
`memory_proposals_authenticated_insert_own_namespace` and
`memory_proposals_authenticated_update_own_namespace` policies let a normal
authenticated user create and mutate proposal state directly, which then allows
`memory_persist_approved_proposal()` to trust a forged `approved` row and write
active-memory tables. Tighten the database boundary by changing the
`public.memory_proposals` policies to keep direct writes pending-only or
read-only, and ensure the approval/persistence transition is only reachable
through a DB-controlled routine that verifies operator/reviewed-write claims
before any insert into `memory_items`, `memory_sources`, `memory_patches`, or
`audit_logs`. Also revoke broad `UPDATE` and `EXECUTE` access so the reviewed
gate is enforced in the database, not only in the TS service.

update(v: any){ pendingUpdate = v; return this; },
eq(k: string, v: any){ filters.push([k,v]); return this; },
order(){ return this; },
async single(){ const store = table === "audit_logs" ? audits : rows; const data = store.find(x => filters.every(([k,v]) => x[k] === v)) ?? store.at(-1); if (data && pendingUpdate) Object.assign(data, pendingUpdate); return { data, error: data ? null : { message: "not found" } }; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Tighten the mock's single() behavior.

Falling back to store.at(-1) when no row matches the filters makes these tests pass even if the service stops constraining by id or user_id. Return not found on a filter miss so the auth/query assertions actually protect the query contract.

Suggested test fix
-        async single(){ const store = table === "audit_logs" ? audits : rows; const data = store.find(x => filters.every(([k,v]) => x[k] === v)) ?? store.at(-1); if (data && pendingUpdate) Object.assign(data, pendingUpdate); return { data, error: data ? null : { message: "not found" } }; }
+        async single(){
+          const store = table === "audit_logs" ? audits : rows;
+          const data = store.find((x) => filters.every(([k, v]) => x[k] === v));
+          if (!data) return { data: null, error: { message: "not found" } };
+          if (pendingUpdate) Object.assign(data, pendingUpdate);
+          return { data, error: null };
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async single(){ const store = table === "audit_logs" ? audits : rows; const data = store.find(x => filters.every(([k,v]) => x[k] === v)) ?? store.at(-1); if (data && pendingUpdate) Object.assign(data, pendingUpdate); return { data, error: data ? null : { message: "not found" } }; }
async single(){
const store = table === "audit_logs" ? audits : rows;
const data = store.find((x) => filters.every(([k, v]) => x[k] === v));
if (!data) return { data: null, error: { message: "not found" } };
if (pendingUpdate) Object.assign(data, pendingUpdate);
return { data, error: null };
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/memory-proposal-service.test.ts` at line 18, Tighten the mock in
the test helper’s single() implementation so it only returns a record when the
filters match exactly. Remove the store.at(-1) fallback and return a “not found”
error when no item satisfies filters.every(...), so the assertions around
MemoryProposalService queries continue to verify the id/user_id contract.

Comment on lines +41 to +44
const persisted = await persistApprovedMemoryProposal(c, created.ok ? created.data.id : "", "reviewer", session(), runtime(true));
expect(persisted.ok).toBe(true);
expect(c.audits.some((a: any) => a.action === "proposal_persisted")).toBe(true);
expect((await persistApprovedMemoryProposal(c, created.ok ? created.data.id : "", "reviewer", session(), runtime(true))).ok).toBe(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the persisted proposal state, not just ok.

This test still passes if persistence returns success without actually leaving the proposal in persisted state. Please assert the refreshed proposal status and persisted_memory_id so RPC no-ops/regressions are caught.

Suggested assertion upgrade
     const persisted = await persistApprovedMemoryProposal(c, created.ok ? created.data.id : "", "reviewer", session(), runtime(true));
     expect(persisted.ok).toBe(true);
+    expect(persisted.ok && persisted.data.proposal.status).toBe("persisted");
+    expect(persisted.ok && persisted.data.proposal.persisted_memory_id).toBe("00000000-0000-0000-0000-000000000099");
     expect(c.audits.some((a: any) => a.action === "proposal_persisted")).toBe(true);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const persisted = await persistApprovedMemoryProposal(c, created.ok ? created.data.id : "", "reviewer", session(), runtime(true));
expect(persisted.ok).toBe(true);
expect(c.audits.some((a: any) => a.action === "proposal_persisted")).toBe(true);
expect((await persistApprovedMemoryProposal(c, created.ok ? created.data.id : "", "reviewer", session(), runtime(true))).ok).toBe(false);
const persisted = await persistApprovedMemoryProposal(c, created.ok ? created.data.id : "", "reviewer", session(), runtime(true));
expect(persisted.ok).toBe(true);
expect(persisted.ok && persisted.data.proposal.status).toBe("persisted");
expect(persisted.ok && persisted.data.proposal.persisted_memory_id).toBe("00000000-0000-0000-0000-000000000099");
expect(c.audits.some((a: any) => a.action === "proposal_persisted")).toBe(true);
expect((await persistApprovedMemoryProposal(c, created.ok ? created.data.id : "", "reviewer", session(), runtime(true))).ok).toBe(false);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/memory-proposal-service.test.ts` around lines 41 - 44, The test
around persistApprovedMemoryProposal only checks the ok flag, so it can miss
cases where the proposal is not actually moved to persisted. Update the
assertions in memory-proposal-service.test.ts to verify the refreshed proposal
state after the first call, including that the status is persisted and
persisted_memory_id is set, and keep the existing audit check plus the
second-call no-op expectation to catch regressions in
persistApprovedMemoryProposal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant