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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions mcp/agentSessionArchiveMigration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";

const migrationUrl = new URL(
"../supabase/migrations/20260717083000_add_agent_session_archives.sql",
import.meta.url
);

test("archive migration keeps compacted content service-only and append-only", async () => {
const sql = await readFile(migrationUrl, "utf8");

assert.match(sql, /create table public\.agent_session_archives/);
assert.match(sql, /create table public\.agent_session_archive_heads/);
assert.match(sql, /foreign key \(session_id, user_id\)[\s\S]*?on delete cascade/g);
assert.match(sql, /alter table public\.agent_session_archives enable row level security/);
assert.match(sql, /alter table public\.agent_session_archive_heads enable row level security/);
assert.match(
sql,
/revoke all on table public\.agent_session_archives[\s\S]*?from public, anon, authenticated/
);
assert.match(sql, /grant select on table public\.agent_session_archives to service_role/);
assert.match(sql, /grant select on table public\.agent_session_archive_heads to service_role/);
assert.doesNotMatch(sql, /grant (?:insert|update|delete)[\s\S]*?to service_role/);
});

test("archive append serializes one user session and advances its manifest atomically", async () => {
const sql = await readFile(migrationUrl, "utf8");

assert.match(sql, /create or replace function public\.append_agent_session_archive/);
assert.match(sql, /security definer/);
assert.match(sql, /set search_path = pg_catalog, pg_temp/);
assert.match(
sql,
/from public\.agent_sessions[\s\S]*?id = p_session_id[\s\S]*?user_id = p_user_id[\s\S]*?for update/
);
assert.match(
sql,
/from public\.agent_session_archive_heads[\s\S]*?session_id = p_session_id[\s\S]*?user_id = p_user_id[\s\S]*?for update/
);
assert.match(
sql,
/insert into public\.agent_session_archives[\s\S]*?update public\.agent_session_archive_heads/
);
assert.match(sql, /jsonb_array_elements\(v_manifest -> 'archives'\)/);
assert.match(sql, /using errcode = '23505'/);
assert.match(
sql,
/revoke all on function public\.append_agent_session_archive[\s\S]*?from public, anon, authenticated/
);
assert.match(
sql,
/grant execute on function public\.append_agent_session_archive[\s\S]*?to service_role/
);
});

test("archive rows preserve the Heddle manifest and content invariants", async () => {
const sql = await readFile(migrationUrl, "utf8");

assert.match(sql, /archive_record ->> 'id' = archive_id/);
assert.match(sql, /jsonb_typeof\(messages\) = 'array'/);
assert.match(sql, /manifest -> 'version' = '1'::jsonb/);
assert.match(sql, /manifest ->> 'sessionId' = session_id/);
assert.match(sql, /jsonb_typeof\(manifest -> 'archives'\) = 'array'/);
assert.match(sql, /'currentSummaryPath', p_archive_record ->> 'summaryPath'/);
});
21 changes: 17 additions & 4 deletions supabase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ Supabase only owns the remote data required by the current product:
- `presentations`: one user's editable MotionDoc source, including protected
starter rows identified by `kind = 'template'`.
- `agent_sessions`: Heddle conversation metadata owned by one user and presentation.
- `agent_session_records` and `agent_session_messages`: service-only Heddle
session state and completed user-visible turns.
- `agent_session_archives` and `agent_session_archive_heads`: service-only,
append-only compaction content and its current Heddle manifest.
- `slide_comments`: comments attached to a zero-based slide index, including resolved state.
- `presentation-images`: private Storage bucket for uploaded images.

Expand Down Expand Up @@ -80,10 +84,19 @@ code `40001` and `source_revision_conflict`. Direct authenticated updates to
source and title are revoked; editor template changes use the document CAS.

`agent_sessions.id` is the Heddle conversation ID. Each row belongs to one user
and one presentation and stores only session metadata: title, message count,
and timestamps. Heddle message content/state is not duplicated into Supabase.
The Agent backend should forward the signed-in user's JWT so CAS and session RLS
run as that user; the browser never receives a service-role key.
and one presentation and exposes only browser-safe metadata: title, message
count, and timestamps. The complete Heddle session record, completed
user-visible turns, and compaction archives are separate service-only tables.
The browser never receives their contents or the service-role key.

`append_agent_session_archive` locks the exact `(session_id, user_id)` parent,
inserts immutable archived messages and summary content, and advances the
manifest head in one transaction. Archive rows cannot be updated or deleted by
the service role; parent-session deletion still cascades through the foreign
key. The Agent backend must bind both session and archive repositories to the
same verified user ID and Supabase project. It should use the caller's JWT for
presentation CAS, while only trusted backend code uses the service role for
the server-only Heddle tables.

## Image paths

Expand Down
234 changes: 234 additions & 0 deletions supabase/migrations/20260717083000_add_agent_session_archives.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
-- Persist Heddle's completed compaction archives in the same trusted Supabase
-- scope as the session record. The browser never reads or writes these rows.

create table public.agent_session_archives (
session_id text not null,
user_id uuid not null,
archive_id text not null
check (char_length(trim(archive_id)) between 1 and 200),
archive_record jsonb not null
check (jsonb_typeof(archive_record) = 'object'),
messages jsonb not null
check (jsonb_typeof(messages) = 'array'),
summary text not null
check (char_length(trim(summary)) >= 1),
created_at timestamptz not null,

constraint agent_session_archives_pk
primary key (session_id, user_id, archive_id),

constraint agent_session_archives_parent_fk
foreign key (session_id, user_id)
references public.agent_sessions (id, user_id)
on delete cascade,

constraint agent_session_archives_record_check
check (
archive_record ? 'id'
and jsonb_typeof(archive_record -> 'id') = 'string'
and archive_record ->> 'id' = archive_id
and archive_record ? 'path'
and jsonb_typeof(archive_record -> 'path') = 'string'
and char_length(archive_record ->> 'path') >= 1
and archive_record ? 'summaryPath'
and jsonb_typeof(archive_record -> 'summaryPath') = 'string'
and char_length(archive_record ->> 'summaryPath') >= 1
and archive_record ? 'messageCount'
and jsonb_typeof(archive_record -> 'messageCount') = 'number'
and (archive_record ->> 'messageCount')::numeric >= 0
and archive_record ? 'createdAt'
and jsonb_typeof(archive_record -> 'createdAt') = 'string'
and char_length(archive_record ->> 'createdAt') >= 1
and (
not (archive_record ? 'shortDescription')
or jsonb_typeof(archive_record -> 'shortDescription') = 'string'
)
and (
not (archive_record ? 'summaryModel')
or jsonb_typeof(archive_record -> 'summaryModel') = 'string'
)
)
);

create table public.agent_session_archive_heads (
session_id text not null,
user_id uuid not null,
manifest jsonb not null
check (jsonb_typeof(manifest) = 'object'),
updated_at timestamptz not null default now(),

constraint agent_session_archive_heads_pk
primary key (session_id, user_id),

constraint agent_session_archive_heads_parent_fk
foreign key (session_id, user_id)
references public.agent_sessions (id, user_id)
on delete cascade,

constraint agent_session_archive_heads_manifest_shape_check
check (
manifest -> 'version' = '1'::jsonb
and manifest ? 'sessionId'
and jsonb_typeof(manifest -> 'sessionId') = 'string'
and manifest ->> 'sessionId' = session_id
and manifest ? 'archives'
and jsonb_typeof(manifest -> 'archives') = 'array'
and (
not (manifest ? 'currentSummaryPath')
or (
jsonb_typeof(manifest -> 'currentSummaryPath') = 'string'
and char_length(manifest ->> 'currentSummaryPath') >= 1
)
)
)
);

-- One transaction locks the parent session, inserts immutable content, and
-- advances the manifest. A failed append can never expose a partial archive.
create or replace function public.append_agent_session_archive(
p_session_id text,
p_user_id uuid,
p_archive_id text,
p_archive_record jsonb,
p_messages jsonb,
p_summary text,
p_created_at timestamptz
)
returns jsonb
language plpgsql
security definer
set search_path = pg_catalog, pg_temp
as $$
declare
v_manifest jsonb;
v_next_manifest jsonb;
begin
if p_archive_id is null
or char_length(trim(p_archive_id)) not between 1 and 200
or jsonb_typeof(p_archive_record) is distinct from 'object'
or p_archive_record ->> 'id' is distinct from p_archive_id
or jsonb_typeof(p_messages) is distinct from 'array'
or p_summary is null
or char_length(trim(p_summary)) < 1
or p_created_at is null then
raise exception 'invalid agent session archive'
using errcode = '22023';
end if;

-- This parent lock serializes all appends, including creation of the first
-- manifest head, for one exact user/session pair.
perform 1
from public.agent_sessions
where id = p_session_id
and user_id = p_user_id
for update;

if not found then
raise exception 'agent session not found'
using errcode = 'P0002';
end if;

insert into public.agent_session_archive_heads (
session_id,
user_id,
manifest
) values (
p_session_id,
p_user_id,
jsonb_build_object(
'version', 1,
'sessionId', p_session_id,
'archives', jsonb_build_array()
)
)
on conflict (session_id, user_id) do nothing;

select manifest
into v_manifest
from public.agent_session_archive_heads
where session_id = p_session_id
and user_id = p_user_id
for update;

if not found then
raise exception 'agent session archive manifest head was not created'
using errcode = 'P0002';
end if;

if jsonb_typeof(v_manifest) is distinct from 'object'
or v_manifest -> 'version' is distinct from '1'::jsonb
or v_manifest ->> 'sessionId' is distinct from p_session_id
or jsonb_typeof(v_manifest -> 'archives') is distinct from 'array' then
raise exception 'invalid agent session archive manifest'
using errcode = '22023';
end if;

if exists (
select 1
from jsonb_array_elements(v_manifest -> 'archives') as archive(value)
where archive.value ->> 'id' = p_archive_id
) then
raise exception 'agent session archive already exists'
using errcode = '23505';
end if;

insert into public.agent_session_archives (
session_id,
user_id,
archive_id,
archive_record,
messages,
summary,
created_at
) values (
p_session_id,
p_user_id,
p_archive_id,
p_archive_record,
p_messages,
p_summary,
p_created_at
);

v_next_manifest := jsonb_build_object(
'version', 1,
'sessionId', p_session_id,
'currentSummaryPath', p_archive_record ->> 'summaryPath',
'archives', (v_manifest -> 'archives') || jsonb_build_array(p_archive_record)
);

update public.agent_session_archive_heads
set manifest = v_next_manifest,
updated_at = clock_timestamp()
where session_id = p_session_id
and user_id = p_user_id;

if not found then
raise exception 'agent session archive manifest head disappeared'
using errcode = 'P0002';
end if;

return v_next_manifest;
end;
$$;

alter table public.agent_session_archives enable row level security;
alter table public.agent_session_archive_heads enable row level security;

revoke all on table public.agent_session_archives
from public, anon, authenticated;
revoke all on table public.agent_session_archive_heads
from public, anon, authenticated;

-- Trusted backends may read persisted state, but only the narrowly granted
-- security-definer RPC can insert content or advance the mutable head.
grant select on table public.agent_session_archives to service_role;
grant select on table public.agent_session_archive_heads to service_role;

revoke all on function public.append_agent_session_archive(
text, uuid, text, jsonb, jsonb, text, timestamptz
) from public, anon, authenticated;

grant execute on function public.append_agent_session_archive(
text, uuid, text, jsonb, jsonb, text, timestamptz
) to service_role;
Loading