+ Experience, skills, languages, preferences, targets, and work
+ eligibility are not yet available in CareerOS.
+
+
)
}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 6109850..7b7e44e 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -93,8 +93,9 @@ revision makes that review stale after a relevant create, update, or delete. Sec
empty, loading, success, validation-error, server-error, and deletion-confirmation states.
**React Router is now justified** because these are real bookmarkable pages with browser-history
-behavior, not conditional panels. This is a recorded decision, not an installed dependency in this
-planning PR. **TanStack Query is not added yet**: simple typed repositories and explicit reloads
+behavior, not conditional panels. The first Phase 1A slice installs the current stable
+`react-router` package and uses `createBrowserRouter`/`RouterProvider`; tests use
+`createMemoryRouter`. **TanStack Query is not added yet**: simple typed repositories and explicit reloads
remain enough until shared cached queries across routes, repetitive mutation invalidation,
background refresh, optimistic updates, or more complex server-state coordination provides a
concrete need.
diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md
index c489727..5d24847 100644
--- a/docs/DATA_MODEL.md
+++ b/docs/DATA_MODEL.md
@@ -81,9 +81,25 @@ erDiagram
## Profile domain (Phase 1A proposal; user-owned unless marked shared)
-**Implementation status**: only the deliberately minimal Phase 0 `profiles` and
-`education_entries` migration exists. Everything in this section is a Phase 1A logical target,
-not implemented SQL. Phase 1B resume and suggestion records are expressly excluded.
+**Implementation status**: the first Phase 1A vertical slice is implemented: refined
+`profiles`, refined `education_entries`, and section freshness/review rows for `basic_profile`
+and `education`. The remaining profile-domain tables below remain logical targets, not
+implemented SQL. Phase 1B resume and suggestion records are expressly excluded.
+
+### Implemented profile-core migration note
+
+The Phase 0 `profiles.degree_program`, `profiles.degree_year`, and
+`profiles.profile_complete` columns are retained temporarily as deprecated compatibility fields.
+They are not authoritative and the browser does not write them. A forward migration copies a
+legacy degree year only when its profile has exactly one existing education row and that target
+field is null; it never chooses a primary row, infers a status, creates an institution, or maps
+the ambiguous legacy degree-program text into `degree` or `field`. Existing profiles begin with
+`basic_profile` and `education` revisions at zero and no reviews. New profile creation initializes
+those rows transactionally.
+
+`last_confirmed_at` is nullable for migrated profile and education rows: `NULL` means the legacy
+row has not yet been manually confirmed in the current profile model. New manual creation defaults
+to the current timestamp, and later manual content edits refresh it.
**Ownership rule (binding, see [AGENTS.md](../AGENTS.md))**: every user-owned table has direct
`user_id`, including association tables. The root `profiles.user_id` references `auth.users(id)`;
diff --git a/docs/DEVELOPMENT_ROADMAP.md b/docs/DEVELOPMENT_ROADMAP.md
index 217d1a9..2608cdc 100644
--- a/docs/DEVELOPMENT_ROADMAP.md
+++ b/docs/DEVELOPMENT_ROADMAP.md
@@ -25,16 +25,17 @@ process.
save it (RLS-permitted), sign out — verified both by automated tests and by hand in a real
browser against the real local stack. No opportunity data yet.
-## Next implementation PR — Profile core and primary education vertical slice
+## First Phase 1A PR — Profile core and primary education vertical slice — **implemented**
-This remains deliberately small. It will implement only the forward migration of existing Phase 0
+This deliberately small slice implements only the forward migration of existing Phase 0
education values, refined `profiles` and `education_entries`, `profile_section_reviews` for
`basic_profile` and `education`, exact grants/RLS/pgTAP/API tests, React Router foundation, profile
overview, education CRUD, and completeness checks limited to those implemented inputs. It must not
claim full completeness while other Phase 1A domains do not exist.
-The forward migration must preserve `profiles.degree_program` and `profiles.degree_year` by copying
-them into the selected or created primary education row before they stop being authoritative.
+The migration retains the legacy fields as deprecated compatibility data. It safely copies a legacy
+degree year only to one existing education row when unambiguous; it does not select a primary,
+infer a status, or map the ambiguous degree-program text.
## Phase 1A — Structured manual career profile
diff --git a/docs/OPEN_QUESTIONS.md b/docs/OPEN_QUESTIONS.md
index 138987c..5cc30fa 100644
--- a/docs/OPEN_QUESTIONS.md
+++ b/docs/OPEN_QUESTIONS.md
@@ -36,10 +36,10 @@ phase that needs it.
where rules clearly fail is the likely direction, but this needs a dedicated design pass before
Phase 1 of [DEVELOPMENT_ROADMAP.md](DEVELOPMENT_ROADMAP.md), including a cost check (LLM resume
parsing would be the first real LLM cost, ahead of the Phase 7 explanation feature).
-- **Primary-education migration selection**: the next implementation PR must define the
- deterministic fallback when Phase 0 values exist but no education entry is an obvious primary
- candidate (for example, create a clearly marked incomplete primary row for user review rather
- than silently selecting among multiple entries).
+- ~~**Primary-education migration selection**~~ — resolved in the first profile-core slice: no
+ legacy row is selected as primary or inferred current. A degree year is copied only to exactly
+ one existing row when empty; ambiguous program text remains a deprecated read-only notice for
+ manual review.
## Compliance
diff --git a/docs/PROFILE_COMPLETENESS_SPEC.md b/docs/PROFILE_COMPLETENESS_SPEC.md
index 2048b75..b3f33ea 100644
--- a/docs/PROFILE_COMPLETENESS_SPEC.md
+++ b/docs/PROFILE_COMPLETENESS_SPEC.md
@@ -71,6 +71,8 @@ the section revision; “stale” means it does not.
| `all-technical-skills-evidenced` | Two technical claims, each with one valid typed evidence row. | `technical_skill` and `skill_evidence` are `present`; lacking count 0. |
| `eligibility-stale` | Work-eligibility row exists, but its confirmation or section review predates a change. | `eligibility_reviewed = unconfirmed`. |
-The next implementation vertical slice evaluates only checks whose inputs exist in that slice; all
-other checks are reported as **not yet implemented by the product**, not as user omissions. The
-full v2 evaluation begins only after its remaining Phase 1A tables exist.
+The implemented first vertical slice exposes `profile-completeness/v2-slice-education`. It evaluates
+only `basic_profile_review`, `primary_education`, `degree_year`, `graduation_timing`, and
+`education_review`, using `present`, `missing`, and `unconfirmed`. All remaining v2 inputs are a
+UI-only `not_implemented` availability state, never a user omission or a percentage. The full v2
+evaluation begins only after its remaining Phase 1A tables exist.
diff --git a/docs/RLS_POLICY_MATRIX.md b/docs/RLS_POLICY_MATRIX.md
index 0a5ab94..471dfb6 100644
--- a/docs/RLS_POLICY_MATRIX.md
+++ b/docs/RLS_POLICY_MATRIX.md
@@ -57,8 +57,9 @@ both this file and its corresponding test.
## Profile domain (user-owned)
-**Status**: `profiles` and `education_entries` are implemented and pgTAP-tested as of Phase 0.
-All other rows in this profile-domain section are proposed Phase 1A policy, not schema. Phase 1B
+**Status**: `profiles`, `education_entries`, `profile_section_revisions`, and
+`profile_section_reviews` are implemented and pgTAP-tested in the first Phase 1A slice. All other
+rows in this profile-domain section remain proposed Phase 1A policy, not schema. Phase 1B
resume/suggestion tables are intentionally not designed here.
Every table's owner column is literally named `user_id` (see [AGENTS.md](../AGENTS.md)) — child
@@ -71,7 +72,7 @@ once per statement rather than once per row — the currently-recommended, non-d
|---|---|---|---|---|---|---|---|---|---|---|
| `profiles` | User-owned root row; direct `user_id` references Auth user | Own only | Own only | Own only | **No direct browser DELETE** | Trusted service/administrative code may have broader maintenance, export, repair, and coordinated-account-deletion access. Root-profile deletion is permitted only in an explicit trusted workflow that coordinates the Auth user, database records, Storage objects, confirmation, and any export requirement — never as a normal browser or routine service-role path. | `(select auth.uid()) = user_id` for SELECT/INSERT/UPDATE; no browser DELETE grant/policy | No | Owner SELECT/INSERT/UPDATE; DELETE fails even for owner; cross-user and forged-owner denial; service deletion is exercised only through the trusted workflow | High |
| `education_entries`, `work_experience`, `projects`, `profile_links`, `user_skills`, `user_languages`, `preferences`, `target_companies` | User-owned; direct `user_id` references `profiles(user_id)` | Own only | Own only | Own only | Own only | Full for maintenance/export/account deletion | `(select auth.uid()) = user_id` for `USING` and `WITH CHECK` | No | Owner CRUD; cross-user SELECT/UPDATE/DELETE denial; forged `user_id` insert; ownership-rewrite denial | High (`profile_links`/targets Medium) |
-| `profile_section_reviews` | User-owned deliberate review; composite key `(user_id, section_key)` | Own only | Own only | Own only | Own only | Full | Direct owner predicate; controlled section key and current revision must be recorded | No | Owner CRUD; forged-owner and cross-user denial; cannot write a review for an unknown key or stale/future revision | High |
+| `profile_section_reviews` | User-owned deliberate review; composite key `(user_id, section_key)` | Own only | **No direct browser write**; restricted RPC only | **No direct browser write**; restricted RPC only | **No direct browser write** | Full | Direct owner SELECT; `review_profile_section` derives `auth.uid()` and locks the current revision | No | User cannot forge an owner/key/revision; RPC writes only the locked current revision | High |
| `profile_section_revisions` | User-owned, database-maintained freshness metadata | Own only | No direct browser write | No direct browser write | No direct browser write | Trusted migration/mutation mechanism only | Direct owner SELECT; no browser mutation grant | No | User can read only own revision; browser cannot forge/increment/rewrite revision; relevant insert/update/delete increments it atomically | Medium |
| `preference_locations`, `user_target_engineering_areas`, `user_target_role_types` | User-owned relation; direct `user_id` | Own only | Own only | Own only where applicable | Own only | Full | Direct owner predicate plus composite parent `(user_id, parent_id)` validation | No | Owner CRUD; all cross-user denials; forged owner; cannot attach a valid user A row to B's preferences/profile | Medium |
| `project_skills` | User-owned relation; direct `user_id` | Own only | Own only | N/A or own only | Own only | Full | Direct owner predicate plus `(user_id, project_id)` composite FK | No | User A cannot attach B's project to A's skill or vice versa, even when UUID is known; owner CRUD | High |
diff --git a/docs/SECURITY_AND_PRIVACY.md b/docs/SECURITY_AND_PRIVACY.md
index d084f1a..fe21500 100644
--- a/docs/SECURITY_AND_PRIVACY.md
+++ b/docs/SECURITY_AND_PRIVACY.md
@@ -46,6 +46,13 @@ SELECT/INSERT/UPDATE/DELETE permissions, the expected RLS predicate, anonymous-a
the isolation test each row requires. This section states the *principle*; that document is the
*checklist* a migration must satisfy.
+**Implemented Phase 1A profile-core hardening**: section revisions and reviews are browser
+read-only. The only review mutation is a narrowly granted `SECURITY DEFINER` RPC that derives the
+caller from `auth.uid()`, locks that user's current revision, and records that exact value. Primary
+education selection likewise uses a narrowly granted ownership-checking RPC; browser clients do
+not have column privilege to set `is_primary` directly. Every privileged function fixes its search
+path, revokes default `PUBLIC` execution, and grants only the browser RPCs to `authenticated`.
+
**Row-level data isolation for future public users**: because the schema is already `user_id`-
scoped and RLS-enforced, enabling additional user accounts later is a matter of allowing sign-up —
the isolation mechanism doesn't change. This is the concrete mechanism behind the "private-first,
diff --git a/docs/TESTING_STRATEGY.md b/docs/TESTING_STRATEGY.md
index 6892956..7a65d32 100644
--- a/docs/TESTING_STRATEGY.md
+++ b/docs/TESTING_STRATEGY.md
@@ -72,6 +72,31 @@ each rule gets explicit positive and negative test cases.
## Phase 1A profile implementation tests (future)
+**Implemented in the first profile-core/education slice**: pgTAP verifies refined profile and
+education schema/grants, browser denial of root-profile deletion and direct revision/review/primary
+writes, review freshness, primary switching/idempotence, and cascade safety. It also catalog-tests
+all eight Phase 1A privileged functions for `SECURITY DEFINER`, an empty hardened search path, and
+the exact callable surface: browser clients may execute only the constrained review and
+primary-selection RPCs. The real Auth/JWT/PostgREST suite covers column restrictions, education
+CRUD, primary switching, review staleness, cross-user denial, and concurrent mutation revision
+advancement; later Phase 1A domains remain future work.
+
+The corrective API coverage also explicitly verifies that neither an owner nor another browser
+user can delete a root profile, and that browser clients cannot directly insert revision or review
+metadata.
+
+The CI database job also runs `supabase/scripts/migration-compatibility-test.sh`. It uses the
+installed CLI's `supabase db reset --version` and `supabase migration up --local` commands to
+reset through Phase 0, insert synthetic legacy records, apply the Phase 1A migration, and verify
+row counts, long/blank legacy-text preservation, safe degree-year backfill, no inferred primary or
+status, neutral revisions, no reviews, migration provenance, and nullable migrated confirmation
+timestamps.
+
+The frontend component suite covers the user-observable loading, ready, missing, error, retry,
+zero-row, review-refresh, and pending-operation states for the basic-profile and education pages.
+Those pages never substitute a failed section-state request with “Not reviewed”; their visible
+status always comes from the returned revision/review state.
+
- **Migration structure tests**: exact tables, direct `user_id` ownership, required foreign keys,
ownership-safe composite parent references, primary-education partial unique constraint,
typed-skill-evidence exactly-one-source check, bounded note, section-review/revision constraints,
diff --git a/docs/USER_WORKFLOWS.md b/docs/USER_WORKFLOWS.md
index 04c705f..a3c5715 100644
--- a/docs/USER_WORKFLOWS.md
+++ b/docs/USER_WORKFLOWS.md
@@ -5,6 +5,11 @@ architecture and data-model decisions grounded in real usage rather than abstrac
## Workflow 1 — Phase 1A manual profile setup
+**Implemented first slice**: the routed profile workspace currently supports basic-profile editing
+and education drafts/CRUD. A user explicitly selects one current education as primary; no other
+entry is promoted automatically. The overview shows only the implemented review/education checks
+and labels all other Phase 1A sections as not yet available.
+
1. User signs in and sees a profile overview with named completeness checks and concrete next
actions, never just a mysterious percentage.
2. User edits education, experience/research, projects/links, skills/evidence, languages,
diff --git a/supabase/fixtures/migration/assert_phase1_migration.sql b/supabase/fixtures/migration/assert_phase1_migration.sql
new file mode 100644
index 0000000..8642df0
--- /dev/null
+++ b/supabase/fixtures/migration/assert_phase1_migration.sql
@@ -0,0 +1,21 @@
+do $$
+begin
+ if (select headline from public.profiles where user_id = '50000000-0000-0000-0000-000000000001') <> 'Preserved headline' then raise exception 'headline was not preserved'; end if;
+ if (select headline from public.profiles where user_id = '50000000-0000-0000-0000-000000000002') <> repeat('x', 200) then raise exception 'overlength legacy headline was not preserved'; end if;
+ if (select degree_program from public.profiles where user_id = '50000000-0000-0000-0000-000000000001') <> ' ' then raise exception 'blank legacy degree-program text was normalized'; end if;
+ if (select count(*) from public.profiles) <> 4 then raise exception 'profile row count changed'; end if;
+ if (select count(*) from public.education_entries) <> 3 then raise exception 'education row count changed'; end if;
+ if (select count(*) from public.education_entries where user_id = '50000000-0000-0000-0000-000000000001') <> 0 then raise exception 'migration invented education'; end if;
+ if (select degree_year from public.education_entries where id = '60000000-0000-0000-0000-000000000001') <> 3 then raise exception 'lone degree year was not safely copied'; end if;
+ if (select degree_year from public.education_entries where id = '60000000-0000-0000-0000-000000000002') is not null then raise exception 'null legacy degree year changed'; end if;
+ if (select institution from public.education_entries where id = '60000000-0000-0000-0000-000000000003') <> 'Second Institution' then raise exception 'legacy institution changed'; end if;
+ if (select field from public.education_entries where id = '60000000-0000-0000-0000-000000000002') <> 'First field' then raise exception 'legacy field changed'; end if;
+ if exists (select 1 from public.education_entries where user_id = '50000000-0000-0000-0000-000000000003' and degree_year is not null) then raise exception 'multiple education rows received a legacy degree year'; end if;
+ if exists (select 1 from public.education_entries where is_primary) then raise exception 'migration selected a primary'; end if;
+ if exists (select 1 from public.education_entries where education_status <> 'unknown') then raise exception 'migration inferred a status'; end if;
+ if exists (select 1 from public.profiles where created_via <> 'migration' or last_confirmed_at is not null) then raise exception 'migrated profiles lack expected provenance or confirmation state'; end if;
+ if exists (select 1 from public.education_entries where created_via <> 'migration' or last_confirmed_at is not null) then raise exception 'migrated education lacks expected provenance or confirmation state'; end if;
+ if exists (select 1 from public.profile_section_reviews) then raise exception 'migration created reviews'; end if;
+ if exists (select 1 from public.profile_section_revisions where content_revision <> 0) then raise exception 'migration revisions are not neutral'; end if;
+end;
+$$;
diff --git a/supabase/fixtures/migration/phase0_fixture.sql b/supabase/fixtures/migration/phase0_fixture.sql
new file mode 100644
index 0000000..e06b755
--- /dev/null
+++ b/supabase/fixtures/migration/phase0_fixture.sql
@@ -0,0 +1,21 @@
+-- Synthetic Phase 0 data applied after resetting through the Phase 0 schema.
+do $$
+begin
+insert into auth.users (id, aud, role, email) values
+ ('50000000-0000-0000-0000-000000000001', 'authenticated', 'authenticated', 'legacy-none@example.test'),
+ ('50000000-0000-0000-0000-000000000002', 'authenticated', 'authenticated', 'legacy-one@example.test'),
+ ('50000000-0000-0000-0000-000000000003', 'authenticated', 'authenticated', 'legacy-many@example.test'),
+ ('50000000-0000-0000-0000-000000000004', 'authenticated', 'authenticated', 'legacy-null@example.test');
+
+insert into public.profiles (user_id, headline, degree_program, degree_year) values
+ ('50000000-0000-0000-0000-000000000001', 'Preserved headline', ' ', 2),
+ ('50000000-0000-0000-0000-000000000002', repeat('x', 200), 'Unmapped legacy program', 3),
+ ('50000000-0000-0000-0000-000000000003', 'Multiple education rows', 'Multiple program', 4),
+ ('50000000-0000-0000-0000-000000000004', null, null, null);
+
+insert into public.education_entries (id, user_id, institution, degree, field) values
+ ('60000000-0000-0000-0000-000000000001', '50000000-0000-0000-0000-000000000002', 'Existing Institution', 'Existing degree', 'Existing field'),
+ ('60000000-0000-0000-0000-000000000002', '50000000-0000-0000-0000-000000000003', 'First Institution', 'First degree', 'First field'),
+ ('60000000-0000-0000-0000-000000000003', '50000000-0000-0000-0000-000000000003', 'Second Institution', null, null);
+end;
+$$;
diff --git a/supabase/migrations/20260801213000_profile_core_education.sql b/supabase/migrations/20260801213000_profile_core_education.sql
new file mode 100644
index 0000000..12468ed
--- /dev/null
+++ b/supabase/migrations/20260801213000_profile_core_education.sql
@@ -0,0 +1,449 @@
+-- Phase 1A, first product slice: profile core and primary education.
+--
+-- This is intentionally forward-only. The Phase 0 profile fields remain in
+-- place as deprecated compatibility data until a later, audited cleanup
+-- migration can prove they have been manually resolved.
+
+-- ---------------------------------------------------------------------------
+-- Schema evolution and safe legacy backfill
+-- ---------------------------------------------------------------------------
+
+alter table public.profiles
+ add column preferred_name text,
+ add column created_via text not null default 'manual',
+ add column updated_at timestamptz not null default now(),
+ add column last_confirmed_at timestamptz default now(),
+ add constraint profiles_created_via_check check (created_via in ('manual', 'migration'));
+
+alter table public.education_entries
+ add column degree_year smallint,
+ add column expected_graduation_month smallint,
+ add column expected_graduation_year smallint,
+ add column education_status text not null default 'unknown',
+ add column is_primary boolean not null default false,
+ add column created_via text not null default 'manual',
+ add column updated_at timestamptz not null default now(),
+ add column last_confirmed_at timestamptz default now(),
+ add constraint education_entries_degree_year_check check (degree_year is null or degree_year between 1 and 10),
+ add constraint education_entries_graduation_month_check check (expected_graduation_month is null or expected_graduation_month between 1 and 12),
+ add constraint education_entries_graduation_year_check check (expected_graduation_year is null or expected_graduation_year between 2000 and 2100),
+ add constraint education_entries_graduation_pair_check check (
+ (expected_graduation_month is null) = (expected_graduation_year is null)
+ ),
+ add constraint education_entries_status_check check (education_status in ('current', 'completed', 'paused', 'withdrawn', 'unknown')),
+ add constraint education_entries_current_end_date_check check (education_status <> 'current' or end_date is null),
+ add constraint education_entries_completed_end_date_check check (education_status <> 'completed' or end_date is not null),
+ add constraint education_entries_graduation_current_check check (
+ expected_graduation_year is null or education_status = 'current'
+ ),
+ add constraint education_entries_graduation_after_start_check check (
+ start_date is null
+ or expected_graduation_year is null
+ or make_date(expected_graduation_year, expected_graduation_month, 1) >= date_trunc('month', start_date)::date
+ ),
+ add constraint education_entries_primary_current_check check (not is_primary or education_status = 'current'),
+ add constraint education_entries_created_via_check check (created_via in ('manual', 'migration'));
+
+-- The Phase 0 migration allowed blank/overlong text. Do not use NOT VALID
+-- text CHECKs: PostgreSQL would then reject unrelated edits to a preserved
+-- legacy row. The validation triggers below instead validate every new value
+-- and every changed text value, while leaving unchanged legacy data editable.
+update public.profiles
+set
+ created_via = 'migration',
+ updated_at = created_at,
+ last_confirmed_at = null;
+
+update public.education_entries
+set
+ created_via = 'migration',
+ updated_at = created_at,
+ last_confirmed_at = null;
+
+-- Only the degree-year mapping has identical semantics in the target model.
+-- A lone legacy education row is not made primary and is not assigned a status.
+with profiles_with_one_education as (
+ select user_id, (array_agg(id order by id))[1] as education_id
+ from public.education_entries
+ group by user_id
+ having count(*) = 1
+)
+update public.education_entries as education
+set degree_year = profile.degree_year
+from public.profiles as profile
+join profiles_with_one_education as one_education on one_education.user_id = profile.user_id
+where education.id = one_education.education_id
+ and profile.degree_year is not null
+ and education.degree_year is null;
+
+comment on column public.profiles.degree_program is
+ 'Deprecated Phase 0 compatibility field. Not authoritative; do not write from the browser or use for completeness.';
+comment on column public.profiles.degree_year is
+ 'Deprecated Phase 0 compatibility field. Safely copied only to a lone existing education row; not authoritative.';
+comment on column public.profiles.profile_complete is
+ 'Deprecated Phase 0 compatibility field. Completeness is derived; this value is not authoritative.';
+comment on column public.profiles.created_via is
+ 'How this current-format profile row entered the system: manual or migration.';
+comment on column public.profiles.last_confirmed_at is
+ 'Last explicit manual profile-content creation or edit; NULL means a migrated row has not yet been manually confirmed in the current profile model.';
+comment on column public.education_entries.last_confirmed_at is
+ 'Last explicit manual education-content creation or edit; NULL means a migrated row has not yet been manually confirmed in the current profile model.';
+
+create unique index education_entries_one_primary_per_user_idx
+ on public.education_entries (user_id)
+ where is_primary;
+
+-- ---------------------------------------------------------------------------
+-- Section state. Existing data starts at a neutral baseline with no review.
+-- ---------------------------------------------------------------------------
+
+create table public.profile_section_revisions (
+ user_id uuid not null references public.profiles (user_id) on delete cascade,
+ section_key text not null check (section_key in ('basic_profile', 'education')),
+ content_revision bigint not null default 0 check (content_revision >= 0),
+ updated_at timestamptz not null default now(),
+ primary key (user_id, section_key)
+);
+
+create table public.profile_section_reviews (
+ user_id uuid not null references public.profiles (user_id) on delete cascade,
+ section_key text not null check (section_key in ('basic_profile', 'education')),
+ reviewed_content_revision bigint not null check (reviewed_content_revision >= 0),
+ reviewed_at timestamptz not null default now(),
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ primary key (user_id, section_key),
+ foreign key (user_id, section_key)
+ references public.profile_section_revisions (user_id, section_key)
+ on delete cascade
+);
+
+insert into public.profile_section_revisions (user_id, section_key, content_revision)
+select profiles.user_id, section_keys.section_key, 0
+from public.profiles
+cross join (values ('basic_profile'::text), ('education'::text)) as section_keys(section_key);
+
+comment on table public.profile_section_revisions is
+ 'Database-maintained, deletion-safe section content mutation counters.';
+comment on table public.profile_section_reviews is
+ 'Deliberate review recorded against an exact profile-section content revision.';
+
+-- ---------------------------------------------------------------------------
+-- Hardened database functions and triggers
+-- ---------------------------------------------------------------------------
+
+create function public.raise_profile_content_validation(
+ value text,
+ field_name text,
+ maximum_length integer
+)
+returns void
+language plpgsql
+security definer
+set search_path = ''
+as $$
+begin
+ if value is not null and (btrim(value) = '' or char_length(value) > maximum_length) then
+ raise exception using
+ errcode = '23514',
+ message = 'invalid profile content',
+ detail = field_name,
+ constraint = 'profile_content_text_check';
+ end if;
+end;
+$$;
+
+create function public.profiles_before_write()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+begin
+ if tg_op = 'INSERT' then
+ perform public.raise_profile_content_validation(new.preferred_name, 'preferred_name', 100);
+ perform public.raise_profile_content_validation(new.headline, 'headline', 160);
+ return new;
+ end if;
+
+ if new.preferred_name is distinct from old.preferred_name then
+ perform public.raise_profile_content_validation(new.preferred_name, 'preferred_name', 100);
+ end if;
+ if new.headline is distinct from old.headline then
+ perform public.raise_profile_content_validation(new.headline, 'headline', 160);
+ end if;
+
+ if new.preferred_name is distinct from old.preferred_name
+ or new.headline is distinct from old.headline then
+ new.updated_at := now();
+ new.last_confirmed_at := now();
+ else
+ new.created_at := old.created_at;
+ new.created_via := old.created_via;
+ new.updated_at := old.updated_at;
+ new.last_confirmed_at := old.last_confirmed_at;
+ end if;
+ return new;
+end;
+$$;
+
+create function public.education_entries_before_write()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ content_changed boolean;
+begin
+ if tg_op = 'INSERT' then
+ perform public.raise_profile_content_validation(new.institution, 'institution', 200);
+ perform public.raise_profile_content_validation(new.degree, 'degree', 160);
+ perform public.raise_profile_content_validation(new.field, 'field', 160);
+ return new;
+ end if;
+
+ if new.institution is distinct from old.institution then
+ perform public.raise_profile_content_validation(new.institution, 'institution', 200);
+ end if;
+ if new.degree is distinct from old.degree then
+ perform public.raise_profile_content_validation(new.degree, 'degree', 160);
+ end if;
+ if new.field is distinct from old.field then
+ perform public.raise_profile_content_validation(new.field, 'field', 160);
+ end if;
+
+ content_changed := new.institution is distinct from old.institution
+ or new.degree is distinct from old.degree
+ or new.field is distinct from old.field
+ or new.degree_year is distinct from old.degree_year
+ or new.expected_graduation_month is distinct from old.expected_graduation_month
+ or new.expected_graduation_year is distinct from old.expected_graduation_year
+ or new.education_status is distinct from old.education_status
+ or new.is_primary is distinct from old.is_primary
+ or new.start_date is distinct from old.start_date
+ or new.end_date is distinct from old.end_date;
+
+ if content_changed then
+ new.updated_at := now();
+ new.last_confirmed_at := now();
+ else
+ new.user_id := old.user_id;
+ new.created_at := old.created_at;
+ new.created_via := old.created_via;
+ new.updated_at := old.updated_at;
+ new.last_confirmed_at := old.last_confirmed_at;
+ end if;
+ return new;
+end;
+$$;
+
+create function public.increment_profile_section_revision(target_user_id uuid, target_section_key text)
+returns void
+language plpgsql
+security definer
+set search_path = ''
+as $$
+begin
+ -- The profile-existence guard makes a cascading account deletion harmless:
+ -- after the parent disappears there is no state left to recreate.
+ insert into public.profile_section_revisions (user_id, section_key, content_revision, updated_at)
+ select target_user_id, target_section_key, 1, now()
+ where exists (select 1 from public.profiles where user_id = target_user_id)
+ on conflict (user_id, section_key) do update
+ set content_revision = public.profile_section_revisions.content_revision + 1,
+ updated_at = excluded.updated_at;
+end;
+$$;
+
+create function public.profiles_after_content_change()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+begin
+ if tg_op = 'INSERT' then
+ insert into public.profile_section_revisions (user_id, section_key, content_revision)
+ values (new.user_id, 'basic_profile', 0), (new.user_id, 'education', 0)
+ on conflict (user_id, section_key) do nothing;
+ perform public.increment_profile_section_revision(new.user_id, 'basic_profile');
+ elsif new.preferred_name is distinct from old.preferred_name
+ or new.headline is distinct from old.headline then
+ perform public.increment_profile_section_revision(new.user_id, 'basic_profile');
+ end if;
+ return null;
+end;
+$$;
+
+create function public.education_entries_after_content_change()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ owner_id uuid;
+begin
+ owner_id := case when tg_op = 'DELETE' then old.user_id else new.user_id end;
+ if tg_op <> 'UPDATE'
+ or new.institution is distinct from old.institution
+ or new.degree is distinct from old.degree
+ or new.field is distinct from old.field
+ or new.degree_year is distinct from old.degree_year
+ or new.expected_graduation_month is distinct from old.expected_graduation_month
+ or new.expected_graduation_year is distinct from old.expected_graduation_year
+ or new.education_status is distinct from old.education_status
+ or new.is_primary is distinct from old.is_primary
+ or new.start_date is distinct from old.start_date
+ or new.end_date is distinct from old.end_date then
+ perform public.increment_profile_section_revision(owner_id, 'education');
+ end if;
+ return null;
+end;
+$$;
+
+create trigger profiles_before_write
+before insert or update on public.profiles
+for each row execute function public.profiles_before_write();
+
+create trigger profiles_after_content_change
+after insert or update on public.profiles
+for each row execute function public.profiles_after_content_change();
+
+create trigger education_entries_before_write
+before insert or update on public.education_entries
+for each row execute function public.education_entries_before_write();
+
+create trigger education_entries_after_content_change
+after insert or update or delete on public.education_entries
+for each row execute function public.education_entries_after_content_change();
+
+create function public.review_profile_section(requested_section_key text)
+returns bigint
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ caller_id uuid := auth.uid();
+ current_revision bigint;
+begin
+ if caller_id is null then
+ raise exception using errcode = '28000', message = 'authentication required';
+ end if;
+ if requested_section_key not in ('basic_profile', 'education') then
+ raise exception using errcode = '22023', message = 'unknown profile section';
+ end if;
+
+ select content_revision into current_revision
+ from public.profile_section_revisions
+ where user_id = caller_id and section_key = requested_section_key
+ for update;
+ if not found then
+ raise exception using errcode = 'P0002', message = 'profile section does not exist';
+ end if;
+
+ insert into public.profile_section_reviews (
+ user_id, section_key, reviewed_content_revision, reviewed_at, created_at, updated_at
+ ) values (caller_id, requested_section_key, current_revision, now(), now(), now())
+ on conflict (user_id, section_key) do update
+ set reviewed_content_revision = excluded.reviewed_content_revision,
+ reviewed_at = excluded.reviewed_at,
+ updated_at = excluded.updated_at;
+ return current_revision;
+end;
+$$;
+
+create function public.set_primary_education(education_id uuid)
+returns void
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ caller_id uuid := auth.uid();
+ selected_status text;
+begin
+ if caller_id is null then
+ raise exception using errcode = '28000', message = 'authentication required';
+ end if;
+
+ -- Deterministic locking serializes simultaneous primary-selection requests.
+ perform 1
+ from public.education_entries
+ where user_id = caller_id
+ order by id
+ for update;
+
+ select education_status into selected_status
+ from public.education_entries
+ where id = education_id and user_id = caller_id;
+ if not found then
+ raise exception using errcode = 'P0002', message = 'education entry not found';
+ end if;
+ if selected_status <> 'current' then
+ raise exception using errcode = '23514', message = 'primary education must be current';
+ end if;
+
+ update public.education_entries
+ set is_primary = false
+ where user_id = caller_id and is_primary and id <> education_id;
+
+ update public.education_entries
+ set is_primary = true
+ where user_id = caller_id and id = education_id and not is_primary;
+end;
+$$;
+
+-- No function starts browser-callable merely because it is created in public.
+revoke all on function public.raise_profile_content_validation(text, text, integer) from public, anon, authenticated;
+revoke all on function public.profiles_before_write() from public, anon, authenticated;
+revoke all on function public.education_entries_before_write() from public, anon, authenticated;
+revoke all on function public.increment_profile_section_revision(uuid, text) from public, anon, authenticated;
+revoke all on function public.profiles_after_content_change() from public, anon, authenticated;
+revoke all on function public.education_entries_after_content_change() from public, anon, authenticated;
+revoke all on function public.review_profile_section(text) from public, anon;
+revoke all on function public.set_primary_education(uuid) from public, anon;
+grant execute on function public.review_profile_section(text) to authenticated;
+grant execute on function public.set_primary_education(uuid) to authenticated;
+
+-- ---------------------------------------------------------------------------
+-- Explicit grants and RLS
+-- ---------------------------------------------------------------------------
+
+drop policy if exists profiles_delete_own on public.profiles;
+revoke all on public.profiles from anon, authenticated, service_role;
+grant select on public.profiles to authenticated;
+grant insert (user_id, preferred_name, headline) on public.profiles to authenticated;
+grant update (preferred_name, headline) on public.profiles to authenticated;
+grant select, insert, update, delete on public.profiles to service_role;
+
+revoke all on public.education_entries from anon, authenticated, service_role;
+grant select on public.education_entries to authenticated;
+grant insert (
+ user_id, institution, degree, field, degree_year,
+ expected_graduation_month, expected_graduation_year, education_status,
+ start_date, end_date
+) on public.education_entries to authenticated;
+grant update (
+ institution, degree, field, degree_year,
+ expected_graduation_month, expected_graduation_year, education_status,
+ start_date, end_date
+) on public.education_entries to authenticated;
+grant delete on public.education_entries to authenticated;
+grant select, insert, update, delete on public.education_entries to service_role;
+
+alter table public.profile_section_revisions enable row level security;
+alter table public.profile_section_reviews enable row level security;
+revoke all on public.profile_section_revisions from anon, authenticated, service_role;
+revoke all on public.profile_section_reviews from anon, authenticated, service_role;
+grant select on public.profile_section_revisions to authenticated;
+grant select on public.profile_section_reviews to authenticated;
+grant select, insert, update, delete on public.profile_section_revisions to service_role;
+grant select, insert, update, delete on public.profile_section_reviews to service_role;
+
+create policy profile_section_revisions_select_own on public.profile_section_revisions
+ for select to authenticated using ((select auth.uid()) = user_id);
+create policy profile_section_reviews_select_own on public.profile_section_reviews
+ for select to authenticated using ((select auth.uid()) = user_id);
diff --git a/supabase/scripts/api-integration-test.mjs b/supabase/scripts/api-integration-test.mjs
index 0c5e982..9cbde0b 100644
--- a/supabase/scripts/api-integration-test.mjs
+++ b/supabase/scripts/api-integration-test.mjs
@@ -80,6 +80,14 @@ async function adminGetProfile(userId) {
return res.json();
}
+async function adminGetRows(table, userId) {
+ const res = await fetch(`${SUPABASE_URL}/rest/v1/${table}?user_id=eq.${userId}`, {
+ headers: { apikey: SECRET_KEY, Authorization: `Bearer ${SECRET_KEY}` },
+ });
+ if (!res.ok) throw new Error(`admin ${table} lookup failed: HTTP ${res.status}`);
+ return res.json();
+}
+
async function verifyUserFullyRemoved(id) {
const problems = [];
const userRes = await adminGetUser(id);
@@ -90,6 +98,10 @@ async function verifyUserFullyRemoved(id) {
if (profileRows.length !== 0) {
problems.push(`profile row for ${id} still present after user deletion (expected cascade delete)`);
}
+ for (const table of ['education_entries', 'profile_section_revisions', 'profile_section_reviews']) {
+ const rows = await adminGetRows(table, id);
+ if (rows.length !== 0) problems.push(`${table} rows for ${id} still present after user deletion (expected cascade delete)`);
+ }
return problems;
}
@@ -178,6 +190,103 @@ async function main() {
});
check('user A can update their own profile', updateOwnA.ok && updateOwnA.data[0].headline === 'Updated by A');
+ const forgeProfileMetadata = await rest(tokenA, `/profiles?user_id=eq.${userA.id}`, {
+ method: 'PATCH',
+ body: { created_via: 'migration' },
+ prefer: 'return=representation',
+ });
+ check('user A cannot forge profile provenance through the REST API', !forgeProfileMetadata.ok && forgeProfileMetadata.status === 403);
+
+ const deleteOwnProfile = await rest(tokenA, `/profiles?user_id=eq.${userA.id}`, {
+ method: 'DELETE', prefer: 'return=representation',
+ });
+ check('user A cannot delete their own root profile through the browser role', !deleteOwnProfile.ok && deleteOwnProfile.status === 403);
+
+ // ---- education CRUD, primary RPC, and review freshness -----------------
+ const createEducationA = await rest(tokenA, '/education_entries', {
+ method: 'POST',
+ body: {
+ user_id: userA.id,
+ institution: 'Synthetic Integration University',
+ degree: 'BSc',
+ degree_year: 2,
+ education_status: 'current',
+ expected_graduation_month: 7,
+ expected_graduation_year: 2028,
+ },
+ prefer: 'return=representation',
+ });
+ const educationA = createEducationA.data?.[0];
+ check('user A can create current education via the REST API', createEducationA.ok && educationA?.institution === 'Synthetic Integration University');
+
+ const directPrimary = await rest(tokenA, `/education_entries?id=eq.${educationA.id}`, {
+ method: 'PATCH', body: { is_primary: true }, prefer: 'return=representation',
+ });
+ check('user A cannot directly set primary education', !directPrimary.ok && directPrimary.status === 403);
+
+ const selectPrimary = await rest(tokenA, '/rpc/set_primary_education', {
+ method: 'POST', body: { education_id: educationA.id },
+ });
+ check('user A can select their current education through the primary RPC', selectPrimary.ok);
+
+ const revisionsBeforeReview = await rest(tokenA, '/profile_section_revisions?select=section_key,content_revision');
+ check('user A can read their own section revisions', revisionsBeforeReview.ok && revisionsBeforeReview.data.length === 2);
+ const reviewEducation = await rest(tokenA, '/rpc/review_profile_section', {
+ method: 'POST', body: { requested_section_key: 'education' },
+ });
+ const reviewBasic = await rest(tokenA, '/rpc/review_profile_section', {
+ method: 'POST', body: { requested_section_key: 'basic_profile' },
+ });
+ check('user A can review both implemented sections through constrained RPCs', reviewEducation.ok && reviewBasic.ok);
+
+ const mutateProfileAfterReview = await rest(tokenA, `/profiles?user_id=eq.${userA.id}`, {
+ method: 'PATCH', body: { headline: 'Changed after review' }, prefer: 'return=representation',
+ });
+ const basicStateAfterMutation = await rest(tokenA, '/profile_section_revisions?section_key=eq.basic_profile&select=content_revision');
+ const basicReviewAfterMutation = await rest(tokenA, '/profile_section_reviews?section_key=eq.basic_profile&select=reviewed_content_revision');
+ check('a profile mutation makes the basic-profile review stale', mutateProfileAfterReview.ok && basicStateAfterMutation.ok && basicReviewAfterMutation.ok && basicStateAfterMutation.data[0].content_revision !== basicReviewAfterMutation.data[0].reviewed_content_revision);
+
+ const updateEducationA = await rest(tokenA, `/education_entries?id=eq.${educationA.id}`, {
+ method: 'PATCH', body: { field: 'Electrical Engineering' }, prefer: 'return=representation',
+ });
+ check('user A can update their education via the REST API', updateEducationA.ok && updateEducationA.data[0].field === 'Electrical Engineering');
+ const staleReview = await rest(tokenA, '/profile_section_reviews?select=section_key,reviewed_content_revision');
+ const afterMutationRevision = await rest(tokenA, '/profile_section_revisions?section_key=eq.education&select=content_revision');
+ check('an education mutation makes its recorded review stale', staleReview.ok && afterMutationRevision.ok && staleReview.data.find((row) => row.section_key === 'education').reviewed_content_revision !== afterMutationRevision.data[0].content_revision);
+
+ const createSecondEducationA = await rest(tokenA, '/education_entries', {
+ method: 'POST', body: { user_id: userA.id, institution: 'Second Current University', education_status: 'current' }, prefer: 'return=representation',
+ });
+ const secondEducationA = createSecondEducationA.data?.[0];
+ check('user A can create a second current education entry', createSecondEducationA.ok && secondEducationA?.education_status === 'current');
+ const switchPrimary = await rest(tokenA, '/rpc/set_primary_education', {
+ method: 'POST', body: { education_id: secondEducationA.id },
+ });
+ const primaryRows = await rest(tokenA, '/education_entries?select=id,is_primary');
+ check('switching primary leaves exactly the selected second entry primary', switchPrimary.ok && primaryRows.ok && primaryRows.data.filter((row) => row.is_primary).length === 1 && primaryRows.data.find((row) => row.is_primary)?.id === secondEducationA.id);
+ const revisionBeforeReselect = await rest(tokenA, '/profile_section_revisions?section_key=eq.education&select=content_revision');
+ const reselectPrimary = await rest(tokenA, '/rpc/set_primary_education', { method: 'POST', body: { education_id: secondEducationA.id } });
+ const revisionAfterReselect = await rest(tokenA, '/profile_section_revisions?section_key=eq.education&select=content_revision');
+ check('reselecting the current primary is idempotent', reselectPrimary.ok && revisionBeforeReselect.data[0].content_revision === revisionAfterReselect.data[0].content_revision);
+ const createCompletedEducationA = await rest(tokenA, '/education_entries', {
+ method: 'POST', body: { user_id: userA.id, institution: 'Completed University', education_status: 'completed', end_date: '2025-01-01' }, prefer: 'return=representation',
+ });
+ const nonCurrentPrimary = await rest(tokenA, '/rpc/set_primary_education', { method: 'POST', body: { education_id: createCompletedEducationA.data?.[0]?.id } });
+ check('a non-current education entry cannot become primary', !nonCurrentPrimary.ok && nonCurrentPrimary.status >= 400);
+ const deletePrimary = await rest(tokenA, `/education_entries?id=eq.${secondEducationA.id}`, { method: 'DELETE', prefer: 'return=representation' });
+ const primaryAfterDelete = await rest(tokenA, '/education_entries?select=id,is_primary');
+ check('deleting the selected primary does not automatically promote another entry', deletePrimary.ok && primaryAfterDelete.ok && primaryAfterDelete.data.filter((row) => row.is_primary).length === 0);
+ const reviewEducationAgain = await rest(tokenA, '/rpc/review_profile_section', { method: 'POST', body: { requested_section_key: 'education' } });
+ const revisionBeforeConcurrentUpdates = await rest(tokenA, '/profile_section_revisions?section_key=eq.education&select=content_revision');
+ const concurrentUpdates = await Promise.all([
+ rest(tokenA, `/education_entries?id=eq.${educationA.id}`, { method: 'PATCH', body: { degree: 'BSc revised once' }, prefer: 'return=representation' }),
+ rest(tokenA, `/education_entries?id=eq.${educationA.id}`, { method: 'PATCH', body: { field: 'Electrical Engineering revised' }, prefer: 'return=representation' }),
+ ]);
+ const revisionAfterConcurrentUpdates = await rest(tokenA, '/profile_section_revisions?section_key=eq.education&select=content_revision');
+ const educationReviewAfterConcurrentUpdates = await rest(tokenA, '/profile_section_reviews?section_key=eq.education&select=reviewed_content_revision');
+ check('concurrent education mutations advance revision monotonically without losing increments', reviewEducationAgain.ok && concurrentUpdates.every((result) => result.ok) && revisionAfterConcurrentUpdates.data[0].content_revision >= revisionBeforeConcurrentUpdates.data[0].content_revision + 2);
+ check('education review is stale after a later education mutation', educationReviewAfterConcurrentUpdates.ok && educationReviewAfterConcurrentUpdates.data[0].reviewed_content_revision !== revisionAfterConcurrentUpdates.data[0].content_revision);
+
// ---- isolation: A cannot read or update B's data ----------------------
const readBAsA = await rest(tokenA, `/profiles?user_id=eq.${userB.id}`, {});
check('user A cannot read user B\'s profile (empty result, not an error)', readBAsA.ok && readBAsA.data.length === 0);
@@ -196,6 +305,33 @@ async function main() {
});
check('user A cannot insert an education entry owned by user B', !forgedInsert.ok && forgedInsert.status === 403);
+ const createEducationB = await rest(tokenB, '/education_entries', {
+ method: 'POST',
+ body: { user_id: userB.id, institution: 'Synthetic B University', education_status: 'current' },
+ prefer: 'return=representation',
+ });
+ const foreignPrimary = await rest(tokenA, '/rpc/set_primary_education', {
+ method: 'POST', body: { education_id: createEducationB.data?.[0]?.id },
+ });
+ check('user A cannot select user B\'s education as primary', !foreignPrimary.ok && foreignPrimary.status >= 400);
+
+ const revisionsAAsB = await rest(tokenB, `/profile_section_revisions?user_id=eq.${userA.id}`);
+ const reviewsAAsB = await rest(tokenB, `/profile_section_reviews?user_id=eq.${userA.id}`);
+ check('user B cannot read user A\'s revisions or reviews', revisionsAAsB.ok && reviewsAAsB.ok && revisionsAAsB.data.length === 0 && reviewsAAsB.data.length === 0);
+ const reviewB = await rest(tokenB, '/rpc/review_profile_section', { method: 'POST', body: { requested_section_key: 'basic_profile' } });
+ const reviewsAAfterBReview = await rest(tokenA, '/profile_section_reviews?section_key=eq.basic_profile&select=reviewed_content_revision');
+ check('user B\'s review RPC changes only user B\'s state', reviewB.ok && reviewsAAfterBReview.ok && reviewsAAfterBReview.data[0].reviewed_content_revision !== null);
+
+ const directRevisionWrite = await rest(tokenA, '/profile_section_revisions', {
+ method: 'POST', body: { user_id: userA.id, section_key: 'education', content_revision: 99 },
+ });
+ check('user A cannot directly forge section revisions', !directRevisionWrite.ok && directRevisionWrite.status === 403);
+
+ const directReviewWrite = await rest(tokenA, '/profile_section_reviews', {
+ method: 'POST', body: { user_id: userA.id, section_key: 'education', reviewed_content_revision: 999 },
+ });
+ check('user A cannot directly forge section reviews', !directReviewWrite.ok && directReviewWrite.status === 403);
+
// ---- isolation: B cannot access A's data ------------------------------
const readAAsB = await rest(tokenB, `/profiles?user_id=eq.${userA.id}`, {});
check('user B cannot read user A\'s profile', readAAsB.ok && readAAsB.data.length === 0);
@@ -204,7 +340,7 @@ async function main() {
method: 'DELETE',
prefer: 'return=representation',
});
- check('user B cannot delete user A\'s profile (0 rows affected)', deleteAAsB.ok && deleteAAsB.data.length === 0);
+ check('user B cannot delete user A\'s profile (browser DELETE is not granted)', !deleteAAsB.ok && deleteAAsB.status === 403);
console.log(`\nAll ${checkCount} checks passed.`);
} finally {
diff --git a/supabase/scripts/migration-compatibility-test.sh b/supabase/scripts/migration-compatibility-test.sh
new file mode 100755
index 0000000..b7b11e8
--- /dev/null
+++ b/supabase/scripts/migration-compatibility-test.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Exercise the actual supported CLI path: reset through Phase 0, insert
+# synthetic Phase 0 data, then apply pending local migrations.
+supabase db reset --local --version 20260726145144 --no-seed
+supabase db query --local --file supabase/fixtures/migration/phase0_fixture.sql
+supabase migration up --local
+supabase db query --local --file supabase/fixtures/migration/assert_phase1_migration.sql
+echo 'Phase 0-to-Phase 1A migration compatibility test passed.'
diff --git a/supabase/tests/database/000_structure.test.sql b/supabase/tests/database/000_structure.test.sql
index 2b79c71..b357470 100644
--- a/supabase/tests/database/000_structure.test.sql
+++ b/supabase/tests/database/000_structure.test.sql
@@ -2,17 +2,21 @@
-- policy existence, and grant existence (including the absence of any
-- grant to anon). No fixture data needed; this only inspects catalogs.
begin;
-select plan(29);
+select plan(58);
-- profiles ------------------------------------------------------------------
select has_table('public', 'profiles', 'profiles table exists');
select has_column('public', 'profiles', 'user_id', 'profiles.user_id exists');
select has_column('public', 'profiles', 'headline', 'profiles.headline exists');
+select has_column('public', 'profiles', 'preferred_name', 'profiles.preferred_name exists');
select has_column('public', 'profiles', 'degree_program', 'profiles.degree_program exists');
select has_column('public', 'profiles', 'degree_year', 'profiles.degree_year exists');
select has_column('public', 'profiles', 'profile_complete', 'profiles.profile_complete exists');
select has_column('public', 'profiles', 'created_at', 'profiles.created_at exists');
+select has_column('public', 'profiles', 'created_via', 'profiles.created_via exists');
+select has_column('public', 'profiles', 'updated_at', 'profiles.updated_at exists');
+select has_column('public', 'profiles', 'last_confirmed_at', 'profiles.last_confirmed_at exists');
select col_is_pk('public', 'profiles', 'user_id', 'profiles.user_id is the primary key');
select fk_ok('public', 'profiles', 'user_id', 'auth', 'users', 'id', 'profiles.user_id references auth.users.id');
select has_check('public', 'profiles', 'profiles has a check constraint (degree_year range)');
@@ -24,15 +28,20 @@ select is(
);
select policies_are(
'public', 'profiles',
- array['profiles_select_own', 'profiles_insert_own', 'profiles_update_own', 'profiles_delete_own'],
- 'profiles has exactly the four expected policies'
+ array['profiles_select_own', 'profiles_insert_own', 'profiles_update_own'],
+ 'profiles has exactly the three expected policies'
);
select policy_cmd_is('public', 'profiles', 'profiles_select_own', 'SELECT', 'profiles_select_own applies to SELECT');
select policy_cmd_is('public', 'profiles', 'profiles_insert_own', 'INSERT', 'profiles_insert_own applies to INSERT');
select policy_cmd_is('public', 'profiles', 'profiles_update_own', 'UPDATE', 'profiles_update_own applies to UPDATE');
-select policy_cmd_is('public', 'profiles', 'profiles_delete_own', 'DELETE', 'profiles_delete_own applies to DELETE');
-
-select table_privs_are('public', 'profiles', 'authenticated', array['SELECT', 'INSERT', 'UPDATE', 'DELETE'], 'authenticated has exactly SELECT/INSERT/UPDATE/DELETE on profiles');
+select is(
+ has_table_privilege('authenticated', 'public.profiles', 'SELECT')
+ and has_column_privilege('authenticated', 'public.profiles', 'user_id', 'INSERT')
+ and has_column_privilege('authenticated', 'public.profiles', 'headline', 'UPDATE')
+ and not has_table_privilege('authenticated', 'public.profiles', 'DELETE'),
+ true,
+ 'authenticated has the intended profiles table and column privileges'
+);
select table_privs_are('public', 'profiles', 'service_role', array['SELECT', 'INSERT', 'UPDATE', 'DELETE'], 'service_role has exactly SELECT/INSERT/UPDATE/DELETE on profiles');
select table_privs_are('public', 'profiles', 'anon', array[]::text[], 'anon has no grants at all on profiles');
@@ -40,6 +49,14 @@ select table_privs_are('public', 'profiles', 'anon', array[]::text[], 'anon has
select has_table('public', 'education_entries', 'education_entries table exists');
select has_column('public', 'education_entries', 'user_id', 'education_entries.user_id exists (not profile_id)');
+select has_column('public', 'education_entries', 'degree_year', 'education_entries.degree_year exists');
+select has_column('public', 'education_entries', 'expected_graduation_month', 'education_entries.expected_graduation_month exists');
+select has_column('public', 'education_entries', 'expected_graduation_year', 'education_entries.expected_graduation_year exists');
+select has_column('public', 'education_entries', 'education_status', 'education_entries.education_status exists');
+select has_column('public', 'education_entries', 'is_primary', 'education_entries.is_primary exists');
+select has_column('public', 'education_entries', 'created_via', 'education_entries.created_via exists');
+select has_column('public', 'education_entries', 'updated_at', 'education_entries.updated_at exists');
+select has_column('public', 'education_entries', 'last_confirmed_at', 'education_entries.last_confirmed_at exists');
select col_is_pk('public', 'education_entries', 'id', 'education_entries.id is the primary key');
select fk_ok('public', 'education_entries', 'user_id', 'public', 'profiles', 'user_id', 'education_entries.user_id references profiles.user_id');
select has_index('public', 'education_entries', 'education_entries_user_id_idx', 'index on education_entries.user_id exists');
@@ -56,8 +73,35 @@ select policies_are(
'education_entries has exactly the four expected policies'
);
-select table_privs_are('public', 'education_entries', 'authenticated', array['SELECT', 'INSERT', 'UPDATE', 'DELETE'], 'authenticated has exactly SELECT/INSERT/UPDATE/DELETE on education_entries');
+select is(
+ has_table_privilege('authenticated', 'public.education_entries', 'SELECT, DELETE')
+ and has_column_privilege('authenticated', 'public.education_entries', 'institution', 'INSERT, UPDATE')
+ and not has_column_privilege('authenticated', 'public.education_entries', 'is_primary', 'UPDATE'),
+ true,
+ 'authenticated has intended editable education columns but cannot change is_primary directly'
+);
select table_privs_are('public', 'education_entries', 'anon', array[]::text[], 'anon has no grants at all on education_entries');
+-- section freshness state ----------------------------------------------------
+
+select has_table('public', 'profile_section_revisions', 'profile_section_revisions table exists');
+select has_table('public', 'profile_section_reviews', 'profile_section_reviews table exists');
+select has_column('public', 'profile_section_revisions', 'user_id', 'revisions own directly via user_id');
+select has_column('public', 'profile_section_revisions', 'content_revision', 'revisions counter exists');
+select has_column('public', 'profile_section_reviews', 'reviewed_content_revision', 'reviews record observed revision');
+select col_is_pk('public', 'profile_section_revisions', array['user_id', 'section_key'], 'revisions have composite identity');
+select col_is_pk('public', 'profile_section_reviews', array['user_id', 'section_key'], 'reviews have composite identity');
+select is((select relrowsecurity from pg_class where oid = 'public.profile_section_revisions'::regclass), true, 'RLS is enabled on revisions');
+select is((select relrowsecurity from pg_class where oid = 'public.profile_section_reviews'::regclass), true, 'RLS is enabled on reviews');
+select policies_are('public', 'profile_section_revisions', array['profile_section_revisions_select_own'], 'revisions have only the select-own policy');
+select policies_are('public', 'profile_section_reviews', array['profile_section_reviews_select_own'], 'reviews have only the select-own policy');
+select table_privs_are('public', 'profile_section_revisions', 'authenticated', array['SELECT'], 'authenticated can only select revisions');
+select table_privs_are('public', 'profile_section_reviews', 'authenticated', array['SELECT'], 'authenticated can only select reviews');
+select table_privs_are('public', 'profile_section_revisions', 'anon', array[]::text[], 'anon has no revisions grants');
+select table_privs_are('public', 'profile_section_reviews', 'anon', array[]::text[], 'anon has no reviews grants');
+select has_index('public', 'education_entries', 'education_entries_one_primary_per_user_idx', 'partial primary education index exists');
+select has_function('public', 'review_profile_section', array['text'], 'review RPC exists');
+select has_function('public', 'set_primary_education', array['uuid'], 'primary selection RPC exists');
+
select * from finish();
rollback;
diff --git a/supabase/tests/database/010_profiles_rls.test.sql b/supabase/tests/database/010_profiles_rls.test.sql
index e769f5f..970148c 100644
--- a/supabase/tests/database/010_profiles_rls.test.sql
+++ b/supabase/tests/database/010_profiles_rls.test.sql
@@ -69,14 +69,11 @@ select is(
'user A cannot update user B''s profile (0 rows affected)'
);
-with del as (
- delete from public.profiles where user_id = '22222222-2222-2222-2222-222222222222'
- returning 1
-)
-select is(
- (select count(*)::int from del),
- 0,
- 'user A cannot delete user B''s profile (0 rows affected)'
+select throws_ok(
+ $$ delete from public.profiles where user_id = '22222222-2222-2222-2222-222222222222' $$,
+ '42501'::char(5),
+ null,
+ 'user A has no profile DELETE privilege'
);
select throws_ok(
@@ -114,14 +111,16 @@ select is(
'service_role can read both users'' profiles (deliberate RLS bypass, not a leak)'
);
--- ---- Back to user A: owner delete (run last; mutates state other tests rely on) --
+-- ---- Back to user A: root profile delete is deliberately unavailable -------
reset role;
select set_config('request.jwt.claims', json_build_object('sub', '11111111-1111-1111-1111-111111111111', 'role', 'authenticated')::text, true);
set role authenticated;
-select lives_ok(
+select throws_ok(
$$ delete from public.profiles where user_id = '11111111-1111-1111-1111-111111111111' $$,
- 'user A can delete their own profile'
+ '42501'::char(5),
+ null,
+ 'user A cannot delete their own root profile'
);
reset role;
diff --git a/supabase/tests/database/020_education_entries_rls.test.sql b/supabase/tests/database/020_education_entries_rls.test.sql
index 936119b..f585a64 100644
--- a/supabase/tests/database/020_education_entries_rls.test.sql
+++ b/supabase/tests/database/020_education_entries_rls.test.sql
@@ -23,26 +23,26 @@ select set_config('request.jwt.claims', json_build_object('sub', '11111111-1111-
set role authenticated;
select lives_ok(
- $$ insert into public.education_entries (id, user_id, institution) values ('aaaaaaaa-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'University of A') $$,
+ $$ insert into public.education_entries (user_id, institution) values ('11111111-1111-1111-1111-111111111111', 'University of A') $$,
'user A can insert their own education entry'
);
select lives_ok(
- $$ insert into public.education_entries (id, user_id, institution) values ('aaaaaaaa-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', 'Second entry, deleted later') $$,
+ $$ insert into public.education_entries (user_id, institution) values ('11111111-1111-1111-1111-111111111111', 'Second entry, deleted later') $$,
'user A can insert a second education entry'
);
select is(
- (select institution from public.education_entries where id = 'aaaaaaaa-0000-0000-0000-000000000001'),
+ (select institution from public.education_entries where institution = 'University of A'),
'University of A',
'user A can select their own education entry'
);
select lives_ok(
- $$ update public.education_entries set institution = 'University of A (renamed)' where id = 'aaaaaaaa-0000-0000-0000-000000000001' $$,
+ $$ update public.education_entries set institution = 'University of A (renamed)' where institution = 'University of A' $$,
'user A can update their own education entry'
);
select is(
- (select institution from public.education_entries where id = 'aaaaaaaa-0000-0000-0000-000000000001'),
+ (select institution from public.education_entries where institution = 'University of A (renamed)'),
'University of A (renamed)',
'user A''s update to their own education entry was applied'
);
@@ -53,7 +53,7 @@ select set_config('request.jwt.claims', json_build_object('sub', '22222222-2222-
set role authenticated;
select lives_ok(
- $$ insert into public.education_entries (id, user_id, institution) values ('bbbbbbbb-0000-0000-0000-000000000001', '22222222-2222-2222-2222-222222222222', 'University of B') $$,
+ $$ insert into public.education_entries (user_id, institution) values ('22222222-2222-2222-2222-222222222222', 'University of B') $$,
'user B can insert their own education entry'
);
@@ -91,7 +91,7 @@ select is(
-- names B as the owner, even though the FK target (profiles.user_id = B)
-- legitimately exists.
select throws_ok(
- $$ insert into public.education_entries (id, user_id, institution) values ('cccccccc-0000-0000-0000-000000000001', '22222222-2222-2222-2222-222222222222', 'forged') $$,
+ $$ insert into public.education_entries (user_id, institution) values ('22222222-2222-2222-2222-222222222222', 'forged') $$,
'42501'::char(5),
null,
'user A cannot insert an education entry with user_id = user B (foreign-key-bypass attempt)'
@@ -99,14 +99,11 @@ select throws_ok(
-- The ownership-rewrite test: A must not be able to "claim" B's existing
-- entry by rewriting its user_id to A's own id.
-with upd as (
- update public.education_entries set user_id = '11111111-1111-1111-1111-111111111111' where id = 'bbbbbbbb-0000-0000-0000-000000000001'
- returning 1
-)
-select is(
- (select count(*)::int from upd),
- 0,
- 'user A cannot take ownership of user B''s education entry by rewriting its user_id'
+select throws_ok(
+ $$ update public.education_entries set user_id = '11111111-1111-1111-1111-111111111111' where institution = 'University of B' $$,
+ '42501'::char(5),
+ null,
+ 'user A cannot take ownership of an education entry by rewriting user_id'
);
-- ---- Anonymous: insufficient privilege, not merely empty rows -------------
@@ -121,7 +118,7 @@ select throws_ok(
'anonymous SELECT on education_entries is rejected with insufficient privilege'
);
select throws_ok(
- $$ insert into public.education_entries (id, user_id, institution) values ('dddddddd-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'anon') $$,
+ $$ insert into public.education_entries (user_id, institution) values ('11111111-1111-1111-1111-111111111111', 'anon') $$,
'42501'::char(5),
null,
'anonymous INSERT on education_entries is rejected with insufficient privilege'
@@ -143,7 +140,7 @@ select set_config('request.jwt.claims', json_build_object('sub', '11111111-1111-
set role authenticated;
select lives_ok(
- $$ delete from public.education_entries where id = 'aaaaaaaa-0000-0000-0000-000000000002' $$,
+ $$ delete from public.education_entries where institution = 'Second entry, deleted later' $$,
'user A can delete their own education entry'
);
diff --git a/supabase/tests/database/030_profile_section_state.test.sql b/supabase/tests/database/030_profile_section_state.test.sql
new file mode 100644
index 0000000..e2ad019
--- /dev/null
+++ b/supabase/tests/database/030_profile_section_state.test.sql
@@ -0,0 +1,74 @@
+-- Revision counters, privileged RPCs, direct-write denial, and cascade safety.
+begin;
+select plan(21);
+
+insert into auth.users (id, aud, role, email)
+values
+ ('33333333-3333-3333-3333-333333333333', 'authenticated', 'authenticated', 'section-a@example.test'),
+ ('44444444-4444-4444-4444-444444444444', 'authenticated', 'authenticated', 'section-b@example.test');
+
+reset role;
+select set_config('request.jwt.claims', json_build_object('sub', '33333333-3333-3333-3333-333333333333', 'role', 'authenticated')::text, true);
+set role authenticated;
+
+select lives_ok(
+ $$ insert into public.profiles (user_id, headline) values ('33333333-3333-3333-3333-333333333333', 'Profile A') $$,
+ 'profile insert succeeds and initializes section state'
+);
+select is((select content_revision from public.profile_section_revisions where section_key = 'basic_profile'), 1::bigint, 'profile creation advances basic-profile revision');
+select is((select content_revision from public.profile_section_revisions where section_key = 'education'), 0::bigint, 'education begins at revision zero');
+
+select lives_ok($$ update public.profiles set headline = 'Profile A updated' $$, 'meaningful profile update succeeds');
+select is((select content_revision from public.profile_section_revisions where section_key = 'basic_profile'), 2::bigint, 'meaningful profile update advances its revision');
+
+select lives_ok(
+ $$ insert into public.education_entries (user_id, institution, education_status) values ('33333333-3333-3333-3333-333333333333', 'Synthetic University', 'current') $$,
+ 'owner can create current education'
+);
+select is((select content_revision from public.profile_section_revisions where section_key = 'education'), 1::bigint, 'education insert advances its revision');
+
+select lives_ok($$ select public.review_profile_section('education') $$, 'owner can record a current education review through RPC');
+select is(
+ (select reviewed_content_revision from public.profile_section_reviews where section_key = 'education'),
+ 1::bigint,
+ 'review stores the locked current revision'
+);
+select lives_ok($$ update public.education_entries set degree = 'BSc' where institution = 'Synthetic University' $$, 'education update succeeds');
+select is((select content_revision from public.profile_section_revisions where section_key = 'education'), 2::bigint, 'education update advances its revision');
+select isnt(
+ (select reviewed_content_revision from public.profile_section_reviews where section_key = 'education'),
+ (select content_revision from public.profile_section_revisions where section_key = 'education'),
+ 'content mutation makes the prior review stale'
+);
+
+select throws_ok(
+ $$ insert into public.profile_section_revisions (user_id, section_key, content_revision) values ('33333333-3333-3333-3333-333333333333', 'education', 99) $$,
+ '42501'::char(5), null, 'browser cannot directly forge revisions'
+);
+select throws_ok(
+ $$ update public.education_entries set is_primary = true where institution = 'Synthetic University' $$,
+ '42501'::char(5), null, 'browser cannot directly set primary education'
+);
+select lives_ok(
+ $$ select public.set_primary_education((select id from public.education_entries where institution = 'Synthetic University')) $$,
+ 'primary selection RPC succeeds for the owner'
+);
+select is((select is_primary from public.education_entries where institution = 'Synthetic University'), true, 'selected row is primary');
+select is((select content_revision from public.profile_section_revisions where section_key = 'education'), 3::bigint, 'primary selection advances revision');
+select lives_ok(
+ $$ select public.set_primary_education((select id from public.education_entries where institution = 'Synthetic University')) $$,
+ 'reselecting the current primary is idempotent'
+);
+select is((select content_revision from public.profile_section_revisions where section_key = 'education'), 3::bigint, 'idempotent selection does not change revision');
+select throws_ok($$ select public.review_profile_section('projects') $$, '22023'::char(5), null, 'unknown review key is rejected');
+
+reset role;
+set role service_role;
+select lives_ok(
+ $$ delete from public.profiles where user_id = '33333333-3333-3333-3333-333333333333' $$,
+ 'trusted profile deletion with education rows does not break revision triggers'
+);
+
+reset role;
+select * from finish();
+rollback;
diff --git a/supabase/tests/database/040_privileged_function_security.test.sql b/supabase/tests/database/040_privileged_function_security.test.sql
new file mode 100644
index 0000000..3e71ae5
--- /dev/null
+++ b/supabase/tests/database/040_privileged_function_security.test.sql
@@ -0,0 +1,90 @@
+-- Every Phase 1A function is deliberately SECURITY DEFINER with an empty
+-- search_path. Browser roles may execute only the two constrained RPCs.
+begin;
+select plan(40);
+
+with functions(signature, label, browser_callable) as (
+ values
+ ('public.raise_profile_content_validation(text,text,integer)', 'raise_profile_content_validation', false),
+ ('public.profiles_before_write()', 'profiles_before_write', false),
+ ('public.education_entries_before_write()', 'education_entries_before_write', false),
+ ('public.increment_profile_section_revision(uuid,text)', 'increment_profile_section_revision', false),
+ ('public.profiles_after_content_change()', 'profiles_after_content_change', false),
+ ('public.education_entries_after_content_change()', 'education_entries_after_content_change', false),
+ ('public.review_profile_section(text)', 'review_profile_section', true),
+ ('public.set_primary_education(uuid)', 'set_primary_education', true)
+)
+select ok(to_regprocedure(signature) is not null, label || ' exists') from functions;
+
+with functions(signature, label) as (
+ values
+ ('public.raise_profile_content_validation(text,text,integer)', 'raise_profile_content_validation'),
+ ('public.profiles_before_write()', 'profiles_before_write'),
+ ('public.education_entries_before_write()', 'education_entries_before_write'),
+ ('public.increment_profile_section_revision(uuid,text)', 'increment_profile_section_revision'),
+ ('public.profiles_after_content_change()', 'profiles_after_content_change'),
+ ('public.education_entries_after_content_change()', 'education_entries_after_content_change'),
+ ('public.review_profile_section(text)', 'review_profile_section'),
+ ('public.set_primary_education(uuid)', 'set_primary_education')
+)
+select ok(
+ (select prosecdef from pg_proc where oid = to_regprocedure(signature)),
+ label || ' is SECURITY DEFINER'
+) from functions;
+
+with functions(signature, label) as (
+ values
+ ('public.raise_profile_content_validation(text,text,integer)', 'raise_profile_content_validation'),
+ ('public.profiles_before_write()', 'profiles_before_write'),
+ ('public.education_entries_before_write()', 'education_entries_before_write'),
+ ('public.increment_profile_section_revision(uuid,text)', 'increment_profile_section_revision'),
+ ('public.profiles_after_content_change()', 'profiles_after_content_change'),
+ ('public.education_entries_after_content_change()', 'education_entries_after_content_change'),
+ ('public.review_profile_section(text)', 'review_profile_section'),
+ ('public.set_primary_education(uuid)', 'set_primary_education')
+)
+select ok(
+ exists (
+ select 1 from pg_proc
+ where oid = to_regprocedure(signature)
+ and coalesce(proconfig, array[]::text[]) @> array['search_path=""']
+ ),
+ label || ' has an empty hardened search_path'
+) from functions;
+
+with functions(signature, label) as (
+ values
+ ('public.raise_profile_content_validation(text,text,integer)', 'raise_profile_content_validation'),
+ ('public.profiles_before_write()', 'profiles_before_write'),
+ ('public.education_entries_before_write()', 'education_entries_before_write'),
+ ('public.increment_profile_section_revision(uuid,text)', 'increment_profile_section_revision'),
+ ('public.profiles_after_content_change()', 'profiles_after_content_change'),
+ ('public.education_entries_after_content_change()', 'education_entries_after_content_change'),
+ ('public.review_profile_section(text)', 'review_profile_section'),
+ ('public.set_primary_education(uuid)', 'set_primary_education')
+)
+select ok(
+ not has_function_privilege('public', signature, 'EXECUTE')
+ and not has_function_privilege('anon', signature, 'EXECUTE'),
+ label || ' is not executable by PUBLIC or anon'
+) from functions;
+
+with functions(signature, label, browser_callable) as (
+ values
+ ('public.raise_profile_content_validation(text,text,integer)', 'raise_profile_content_validation', false),
+ ('public.profiles_before_write()', 'profiles_before_write', false),
+ ('public.education_entries_before_write()', 'education_entries_before_write', false),
+ ('public.increment_profile_section_revision(uuid,text)', 'increment_profile_section_revision', false),
+ ('public.profiles_after_content_change()', 'profiles_after_content_change', false),
+ ('public.education_entries_after_content_change()', 'education_entries_after_content_change', false),
+ ('public.review_profile_section(text)', 'review_profile_section', true),
+ ('public.set_primary_education(uuid)', 'set_primary_education', true)
+)
+select ok(
+ has_function_privilege('authenticated', signature, 'EXECUTE') = browser_callable
+ and not has_function_privilege('service_role', signature, 'EXECUTE'),
+ label || ' has the documented authenticated and service-role execute privileges'
+) from functions;
+
+select * from finish();
+rollback;
diff --git a/supabase/tests/database/050_primary_review_behavior.test.sql b/supabase/tests/database/050_primary_review_behavior.test.sql
new file mode 100644
index 0000000..36977bd
--- /dev/null
+++ b/supabase/tests/database/050_primary_review_behavior.test.sql
@@ -0,0 +1,47 @@
+-- Focused browser-role behavior for review freshness, primary selection, and
+-- cascade safety. All fixture identities are synthetic and rolled back.
+begin;
+select plan(20);
+
+insert into auth.users (id, aud, role, email)
+values
+ ('55555555-5555-5555-5555-555555555555', 'authenticated', 'authenticated', 'behavior-a@example.test'),
+ ('66666666-6666-6666-6666-666666666666', 'authenticated', 'authenticated', 'behavior-b@example.test');
+insert into public.profiles (user_id, headline)
+values
+ ('55555555-5555-5555-5555-555555555555', 'A'),
+ ('66666666-6666-6666-6666-666666666666', 'B');
+
+reset role;
+set role anon;
+select throws_ok($$ select public.review_profile_section('education') $$, '42501'::char(5), null, 'anonymous callers cannot execute the review RPC');
+select throws_ok($$ select public.set_primary_education('00000000-0000-0000-0000-000000000000') $$, '42501'::char(5), null, 'anonymous callers cannot execute the primary RPC');
+
+reset role;
+select set_config('request.jwt.claims', json_build_object('sub', '55555555-5555-5555-5555-555555555555', 'role', 'authenticated')::text, true);
+set role authenticated;
+select lives_ok($$ insert into public.education_entries (user_id, institution, education_status, end_date) values ('55555555-5555-5555-5555-555555555555', 'First', 'current', null), ('55555555-5555-5555-5555-555555555555', 'Second', 'current', null), ('55555555-5555-5555-5555-555555555555', 'Finished', 'completed', '2025-01-01') $$, 'owner can create two current and one completed entry');
+select lives_ok($$ select public.set_primary_education((select id from public.education_entries where institution = 'First')) $$, 'owner can select the first current entry');
+select is((select count(*)::int from public.education_entries where is_primary), 1, 'exactly one entry is primary after first selection');
+select is((select institution from public.education_entries where is_primary), 'First', 'the first selected row is primary');
+select lives_ok($$ select public.set_primary_education((select id from public.education_entries where institution = 'Second')) $$, 'owner can switch to the second current entry');
+select is((select count(*)::int from public.education_entries where is_primary), 1, 'switching still leaves exactly one primary');
+select is((select institution from public.education_entries where is_primary), 'Second', 'the second selected row becomes primary');
+select lives_ok($$ select public.set_primary_education((select id from public.education_entries where institution = 'Second')) $$, 'reselecting the current primary is idempotent');
+select throws_ok($$ select public.set_primary_education((select id from public.education_entries where institution = 'Finished')) $$, '23514'::char(5), null, 'a non-current entry cannot become primary');
+select lives_ok($$ select public.review_profile_section('education') $$, 'owner can review education');
+select lives_ok($$ delete from public.education_entries where institution = 'Second' $$, 'owner can delete the selected primary');
+select is((select count(*)::int from public.education_entries where is_primary), 0, 'deleting a primary does not promote another row');
+select isnt((select reviewed_content_revision from public.profile_section_reviews where section_key = 'education'), (select content_revision from public.profile_section_revisions where section_key = 'education'), 'education review becomes stale after deletion');
+select throws_ok($$ insert into public.profile_section_reviews (user_id, section_key, reviewed_content_revision) values ('55555555-5555-5555-5555-555555555555', 'education', 99) $$, '42501'::char(5), null, 'browser cannot directly write review rows');
+select throws_ok($$ delete from public.profiles where user_id = '55555555-5555-5555-5555-555555555555' $$, '42501'::char(5), null, 'profile owner DELETE remains denied');
+
+reset role;
+set role service_role;
+select lives_ok($$ delete from public.profiles where user_id = '55555555-5555-5555-5555-555555555555' $$, 'trusted deletion cascades safely');
+select is((select count(*)::int from public.profile_section_revisions where user_id = '55555555-5555-5555-5555-555555555555'), 0, 'cascade deletion removes revision rows');
+select is((select count(*)::int from public.profile_section_reviews where user_id = '55555555-5555-5555-5555-555555555555'), 0, 'cascade deletion removes review rows');
+
+reset role;
+select * from finish();
+rollback;