diff --git a/docs/disaster-recovery.md b/docs/disaster-recovery.md index 37d5c3f..012820a 100644 --- a/docs/disaster-recovery.md +++ b/docs/disaster-recovery.md @@ -87,9 +87,12 @@ producing a silently partial artifact. | `repo_sync` | operational | yes | phase markers; self-healing hints | | `user_sync` | operational | yes | stale `syncing`/`locked` rows are taken over by cron's stale-lock logic | | `ownership_scopes` | core | yes | ownership scope map — for retained/disconnected repos GitHub may no longer serve the data; irreplaceable | +| `ownership_scope_versions` | core | yes | per-version scope membership — required to interpret active ownership state (exclusion + which scopes belong to active_version) | | `ownership_contributors` | core | yes | repo-scoped contributor identity rows — same irreplaceability | | `ownership_snapshots` | core | yes | ownership aggregates — real retained user history, not always reconstructable | | `ownership_sync_state` | operational | yes | ownership cursors/version markers; losing it means a re-ingest | +| `ownership_history_work` | ephemeral | **no** | crash-safe build counters — pseudonymous work keys; loss means the building version is abandoned and restarted, never active user data | +| `ownership_contributor_build_work` | ephemeral | **no** | build-level identity staging — exists only while a build runs; same discard-and-rebuild recovery | | `auth_sessions` | ephemeral | **no** | recovery must force re-auth, never resurrect live sids | | `webhook_deliveries` | ephemeral | **no** | dedup history; post-restore deliveries reprocess idempotently | | `security_events` | ephemeral | **no** | 30-day operational telemetry | diff --git a/migrations/013_ownership_checkpointing.sql b/migrations/013_ownership_checkpointing.sql new file mode 100644 index 0000000..34c6c5b --- /dev/null +++ b/migrations/013_ownership_checkpointing.sql @@ -0,0 +1,1105 @@ +-- 013 — ownership checkpointing database foundation (additive; forward-only; +-- idempotent). Implements the database substrate of +-- docs/ownership-checkpoint-architecture.md (contract amendment recorded in +-- docs/ownership-data-privacy-spec.md §27): +-- +-- * RETAINED: ownership_scope_versions splits immutable base-scope identity +-- from per-version membership — exclusion state is versioned, dashboard +-- reads join membership at active_version, and every retained child -> +-- base-scope relation is ON DELETE NO ACTION (§5.9/§5.9a). +-- * EPHEMERAL: ownership_history_work + ownership_contributor_build_work +-- hold crash-safe multi-invocation build state; backup-excluded, +-- service-role only, zero browser policies, cascade on teardown. +-- * DB-enforced membership integrity: snapshots gain a composite FK +-- (scope_id, repository_id, user_id, calculation_version) -> +-- ownership_scope_versions ON DELETE NO ACTION — a snapshot cannot exist +-- without matching same-repo/same-tenant/same-version membership. +-- * Database primitives: own_checkpoint_page (single-transaction page CAS), +-- own_restart_scope (committed-V-provenance staging cleanup), +-- own_promote_build (exact-set promotion + atomic version flip), +-- own_abandon_build (the ONE canonical build cleanup), +-- own_build_preflight + own_build_init (§5.6c retired-version +-- lifecycle gate and the gated initialization behind it), +-- own_sweep_stale_work (abandon-routed stale/orphan sweep). +-- +-- 012 is never edited: the deployed ownership_snapshots_scope_repo +-- constraint is dropped and re-created with ON DELETE NO ACTION — same +-- name, same keys, only the delete action changes. Existing 012-era rows +-- are made consistent by a membership backfill BEFORE the composite FK is +-- added, so the FK validates on real deployments. + +-- ── 1. ownership_scope_versions — RETAINED / CORE ─────────────────────── +-- Grain (scope_id, calculation_version): one row means the scope belongs +-- to that calculation version. excluded=true is permitted only for +-- 'cardinality_cap' — never a generic path-inventory marker. + +create table if not exists public.ownership_scope_versions ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null, + repository_id uuid not null, + scope_id uuid not null, + calculation_version int not null, + excluded boolean not null default false, + exclusion_reason text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + + -- tenant pair: the repo must belong to the claimed user (teardown cascade) + constraint ownership_scope_versions_repo_tenant + foreign key (repository_id, user_id) + references public.repositories (id, user_id) + on delete cascade, + + -- retained child -> base scope: NO ACTION so a referenced scope cannot be + -- deleted while membership exists (§5.9a). Cross-repository membership is + -- impossible — repository_id must match the scope's own repository. + constraint ownership_scope_versions_scope_repo + foreign key (scope_id, repository_id) + references public.ownership_scopes (id, repository_id) + on delete no action, + + constraint ownership_scope_versions_version + check (calculation_version > 0), + + -- same contract as ownership_scopes_exclusion, versioned + constraint ownership_scope_versions_exclusion + check ( + (not excluded and exclusion_reason is null) + or (excluded and exclusion_reason = 'cardinality_cap') + ), + + -- membership uniqueness: a scope belongs to a version at most once + unique (scope_id, calculation_version), + -- supporting unique for the snapshot composite FK: proves same-scope + + -- same-repo + same-tenant + same-version, not merely same-scope + unique (scope_id, repository_id, user_id, calculation_version) +); + +-- repo-scoped cleanup/cascade lookups (uniques are scope_id-first) +create index if not exists ownership_scope_versions_repo + on public.ownership_scope_versions (repository_id); + +-- ── 2. membership backfill for pre-existing 012-era rows ──────────────── +-- Production holds zero ownership rows, but forward-only migrations must +-- keep any existing data consistent before the composite FK validates: +-- (a) every snapshot's (scope, repo, user, version) implies membership — +-- required for the new FK; +-- (b) 012 served every base scope of a repo while active_version was set — +-- preserving that output means every base scope also belongs to the +-- repo's active version (and its building version, if any). +-- Exclusion state is copied from the base scope's own (legacy) columns. +insert into public.ownership_scope_versions + (user_id, repository_id, scope_id, calculation_version, excluded, exclusion_reason) +select distinct s.user_id, s.repository_id, s.scope_id, s.calculation_version, + sc.excluded, sc.exclusion_reason +from public.ownership_snapshots s +join public.ownership_scopes sc + on sc.id = s.scope_id and sc.repository_id = s.repository_id +on conflict do nothing; + +insert into public.ownership_scope_versions + (user_id, repository_id, scope_id, calculation_version, excluded, exclusion_reason) +select sc.user_id, sc.repository_id, sc.id, v.ver, sc.excluded, sc.exclusion_reason +from public.ownership_scopes sc +join public.ownership_sync_state st + on st.repository_id = sc.repository_id and st.user_id = sc.user_id +cross join lateral ( + select st.active_version union all select st.building_version +) as v(ver) +where v.ver is not null +on conflict do nothing; + +-- ── 3. ownership_snapshots — retained child FK actions → NO ACTION ────── +-- §5.9a: the 012 CASCADE lets an erroneous direct scope delete destroy +-- active snapshots (and, transitively, membership). The constraint is +-- re-created under the same name/keys; only the delete action changes. +alter table public.ownership_snapshots + drop constraint ownership_snapshots_scope_repo, + add constraint ownership_snapshots_scope_repo + foreign key (scope_id, repository_id) + references public.ownership_scopes (id, repository_id) + on delete no action; + +-- The hard membership invariant (§5.9): a snapshot can exist only for a +-- scope belonging to the snapshot's own version, repository, and tenant. +-- NO ACTION — never CASCADE — so deleting membership can never destroy +-- snapshots; the legal cleanup order is snapshots first, membership second. +alter table public.ownership_snapshots + add constraint ownership_snapshots_scope_version + foreign key (scope_id, repository_id, user_id, calculation_version) + references public.ownership_scope_versions + (scope_id, repository_id, user_id, calculation_version) + on delete no action; + +-- ── 4. ownership_history_work — EPHEMERAL counter state ───────────────── +-- Per (scope × version × month × work_key) accumulation counters. Only +-- counters/timestamps live here — no plaintext contributor identity, no +-- paths, no provider payloads. work_key is the pseudonymous contributor +-- key: literal 'unlinked' or a 64-hex key — structurally disjoint classes. +create table if not exists public.ownership_history_work ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null, + repository_id uuid not null, + scope_id uuid not null, + calculation_version int not null, + period_start timestamptz not null, + work_key text not null, + is_bot boolean not null default false, + touch_commits int not null default 0, + first_seen_at timestamptz, + last_seen_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + + constraint ownership_history_work_repo_tenant + foreign key (repository_id, user_id) + references public.repositories (id, user_id) + on delete cascade, + + -- EPHEMERAL rows are not retained children — cascade is correct here + -- (the §5.9a NO ACTION rule covers the two retained child->scope FKs). + constraint ownership_history_work_scope_repo + foreign key (scope_id, repository_id) + references public.ownership_scopes (id, repository_id) + on delete cascade, + + constraint ownership_history_work_version + check (calculation_version > 0), + + constraint ownership_history_work_key + check (work_key = 'unlinked' or work_key ~ '^[0-9a-f]{64}$'), + + -- month grain mirrors ownership_snapshots_period (UTC wall clock) + constraint ownership_history_work_period + check ( + period_start = + (date_trunc('month', period_start at time zone 'UTC') + at time zone 'UTC') + ), + + constraint ownership_history_work_counts + check (touch_commits >= 0), + + constraint ownership_history_work_seen + check (first_seen_at is null or last_seen_at is null + or first_seen_at <= last_seen_at), + + unique (scope_id, calculation_version, period_start, work_key) +); + +create index if not exists ownership_history_work_repo + on public.ownership_history_work (repository_id); + +-- ── 5. ownership_contributor_build_work — EPHEMERAL identity staging ──── +-- Build-level staging, one row per REPRESENTED key per repository build. +-- Identity fields (github_node_id + display_login) may exist only here and +-- in retained tables — and only for already-represented keys. Deliberately +-- no scope provenance column (committed-V provenance drives cleanup, §5.3) +-- and never a synthetic bucket key. +create table if not exists public.ownership_contributor_build_work ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null, + repository_id uuid not null, + calculation_version int not null, + work_key text not null, + github_node_id text not null, + display_login text not null, + is_bot boolean not null default false, + first_seen_at timestamptz, + last_seen_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + + constraint ownership_contributor_build_work_repo_tenant + foreign key (repository_id, user_id) + references public.repositories (id, user_id) + on delete cascade, + + constraint ownership_contributor_build_work_version + check (calculation_version > 0), + + -- synthetic buckets ('unlinked'/'other') carry no identity and must + -- never be staged — only real contributor keys appear here. + constraint ownership_contributor_build_work_key + check (work_key ~ '^[0-9a-f]{64}$'), + + constraint ownership_contributor_build_work_seen + check (first_seen_at is null or last_seen_at is null + or first_seen_at <= last_seen_at), + + unique (repository_id, calculation_version, work_key) +); + +-- (repository_id, ...) is the unique's leading column — no extra index. + +-- ── 6. RLS + grants — same model as 012 ───────────────────────────────── +-- scope_versions is RETAINED (CORE backup class); both work relations are +-- EPHEMERAL. All three: RLS enabled, zero browser/user policies, +-- service_role DML only. + +alter table public.ownership_scope_versions enable row level security; +alter table public.ownership_history_work enable row level security; +alter table public.ownership_contributor_build_work enable row level security; + +revoke all on public.ownership_scope_versions from public, anon, authenticated; +revoke all on public.ownership_history_work from public, anon, authenticated; +revoke all on public.ownership_contributor_build_work from public, anon, authenticated; + +grant select, insert, update, delete on public.ownership_scope_versions to service_role; +grant select, insert, update, delete on public.ownership_history_work to service_role; +grant select, insert, update, delete on public.ownership_contributor_build_work to service_role; + +-- ── 7. own_checkpoint_page — single-transaction page CAS (§6.1) ───────── +-- The crash-safe exactly-once primitive: page aggregate deltas + durable +-- cursor advance commit together under compare-and-swap on the sync-state +-- row. Any state mismatch → 'stale' with ZERO writes, so a retry after a +-- lost response can never double-apply. SECURITY INVOKER; EXECUTE is +-- service_role-only (same model as the dash_* RPCs). +-- +-- history_cursor.current = {scope_id, pass, accumulate_done?, cursor} — +-- 'stale' whenever building_version/phase/scope/pass/cursor disagree. +create or replace function public.own_checkpoint_page( + p_user uuid, + p_repository uuid, + p_version int, + p_scope uuid, + p_pass text, + p_expected_cursor text, + p_next_cursor text, + p_deltas jsonb default '[]'::jsonb, + p_identities jsonb default '[]'::jsonb, + p_last_page boolean default false +) +returns text +language plpgsql +security invoker +set search_path = public +as $$ +declare + st public.ownership_sync_state%rowtype; + v_cur jsonb; + v_keys int; + v_rows int; + v_represented text[]; +begin + -- 1. serialize all ownership work for the repo + select * into st + from public.ownership_sync_state + where user_id = p_user and repository_id = p_repository + for update; + + -- argument shape — invalid calls are caller bugs (fail closed), not + -- stale replays (which return without writing) + if p_pass not in ('accumulate', 'resolve') then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + if p_deltas is not null and jsonb_typeof(p_deltas) <> 'array' then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + if p_identities is not null and jsonb_typeof(p_identities) <> 'array' then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + if p_pass = 'accumulate' + and jsonb_array_length(coalesce(p_identities, '[]'::jsonb)) > 0 then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + if p_pass = 'resolve' + and jsonb_array_length(coalesce(p_deltas, '[]'::jsonb)) > 0 then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + -- one GraphQL page → at most 100 delta/identity rows + if jsonb_array_length(coalesce(p_deltas, '[]'::jsonb)) > 100 + or jsonb_array_length(coalesce(p_identities, '[]'::jsonb)) > 100 then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + + -- 2. CAS envelope — ANY mismatch: 'stale', no writes + if not found + or st.building_version is distinct from p_version + or st.phase <> 'history' + or st.history_cursor is null + or (st.history_cursor #>> '{current,scope_id}')::uuid is distinct from p_scope + or st.history_cursor #>> '{current,pass}' is distinct from p_pass + or st.history_cursor #>> '{current,cursor}' is distinct from p_expected_cursor + then + return 'stale'; + end if; + + -- the current scope must be a member of the building version + if not exists ( + select 1 from public.ownership_scope_versions sv + where sv.scope_id = p_scope and sv.repository_id = p_repository + and sv.calculation_version = p_version + ) then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + + -- 3. accumulate: additive counter upsert (CAS ⇒ batch applies ≤ once) + if p_pass = 'accumulate' + and jsonb_array_length(coalesce(p_deltas, '[]'::jsonb)) > 0 then + -- bot classification is attribution state — drift inside the batch, + -- against earlier build counters, or against staged frozen values + -- fails closed rather than silently reclassifying + if exists ( + select 1 from jsonb_array_elements(p_deltas) d + group by d->>'work_key' + having count(distinct (d->>'is_bot')::boolean) > 1 + ) then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + if exists ( + select 1 + from jsonb_array_elements(p_deltas) d + join public.ownership_history_work w + on w.repository_id = p_repository and w.calculation_version = p_version + and w.work_key = d->>'work_key' + where w.is_bot <> (d->>'is_bot')::boolean + ) then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + if exists ( + select 1 + from jsonb_array_elements(p_deltas) d + join public.ownership_contributor_build_work b + on b.repository_id = p_repository and b.calculation_version = p_version + and b.work_key = d->>'work_key' + where b.is_bot <> (d->>'is_bot')::boolean + ) then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + + insert into public.ownership_history_work + (user_id, repository_id, scope_id, calculation_version, period_start, + work_key, is_bot, touch_commits, first_seen_at, last_seen_at) + select p_user, p_repository, p_scope, p_version, + (d->>'period_start')::timestamptz, + d->>'work_key', + (d->>'is_bot')::boolean, + (d->>'touches')::int, + (d->>'first_seen_at')::timestamptz, + (d->>'last_seen_at')::timestamptz + from jsonb_array_elements(p_deltas) d + on conflict (scope_id, calculation_version, period_start, work_key) + do update set + touch_commits = public.ownership_history_work.touch_commits + + excluded.touch_commits, + first_seen_at = least(public.ownership_history_work.first_seen_at, + excluded.first_seen_at), + last_seen_at = greatest(public.ownership_history_work.last_seen_at, + excluded.last_seen_at), + updated_at = now(); + + -- §7 bounds, enforced inside the same transaction + select count(distinct work_key), count(*) into v_keys, v_rows + from public.ownership_history_work + where scope_id = p_scope and calculation_version = p_version; + if v_keys > 10000 or v_rows > 100000 then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + end if; + + -- 4. resolve: stage represented-key identities — frozen is_bot only. + -- Identity persistence is authorized ONLY by deterministic + -- represented-set membership (§6.4 durable-identity minimization): + -- pass-1 counter existence is NOT sufficient. The set is the canonical + -- reducer's rule (lib/ownership/history-reducer.mjs, + -- TOP_LINKED_PER_PERIOD = 25) made durable: per period, rank linked + -- work_keys by touch_commits DESC, work_key ASC ('unlinked' never + -- ranks); a key is represented iff it lands in the Top-25 of at least + -- one period. Overflow-only contributors fold into synthetic 'other' + -- and their identity fields may never reach persisted state, so a + -- non-represented submission is ignored BEFORE any pass-2 check or + -- write — its attribution metrics are already frozen by pass 1. + if p_pass = 'resolve' + and jsonb_array_length(coalesce(p_identities, '[]'::jsonb)) > 0 then + select array_agg(work_key) into v_represented + from ( + select distinct work_key + from ( + select work_key, + row_number() over ( + partition by period_start + order by touch_commits desc, work_key asc + ) as rnk + from public.ownership_history_work + where scope_id = p_scope and calculation_version = p_version + and work_key <> 'unlinked' + ) ranked + where rnk <= 25 + ) rep; + + -- a conflicting stored node id for the same REPRESENTED work_key is + -- impossible by construction and fails closed if ever seen (within + -- the batch or against stored staging) + if exists ( + select 1 from jsonb_array_elements(p_identities) i + where i->>'work_key' = any(v_represented) + group by i->>'work_key' + having count(distinct i->>'github_node_id') > 1 + ) then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + if exists ( + select 1 + from jsonb_array_elements(p_identities) i + join public.ownership_contributor_build_work b + on b.repository_id = p_repository and b.calculation_version = p_version + and b.work_key = i->>'work_key' + where i->>'work_key' = any(v_represented) + and b.github_node_id <> i->>'github_node_id' + ) then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + -- observed_is_bot must equal the frozen build value: the staged row + -- when the key was already represented by a committed scope, else the + -- current scope's pass-1 counters — and the two must already agree. + -- checks apply only to represented keys; anything else is ignored. + if exists ( + select 1 + from jsonb_array_elements(p_identities) i + join public.ownership_contributor_build_work b + on b.repository_id = p_repository and b.calculation_version = p_version + and b.work_key = i->>'work_key' + join public.ownership_history_work w + on w.scope_id = p_scope and w.calculation_version = p_version + and w.work_key = i->>'work_key' + where i->>'work_key' = any(v_represented) + and w.is_bot <> b.is_bot + ) then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + if exists ( + select 1 + from jsonb_array_elements(p_identities) i + left join public.ownership_contributor_build_work b + on b.repository_id = p_repository and b.calculation_version = p_version + and b.work_key = i->>'work_key' + left join ( + select distinct work_key, is_bot + from public.ownership_history_work + where scope_id = p_scope and calculation_version = p_version + ) f on f.work_key = i->>'work_key' + where i->>'work_key' = any(v_represented) + and (i->>'observed_is_bot')::boolean + is distinct from coalesce(b.is_bot, f.is_bot) + ) then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + + -- staging is the represented set ONLY: represented ⊆ this scope's + -- pass-1 counters, so coalesce(b.is_bot, f.is_bot) is never null for + -- a member — overflow-only and pass-2-only keys never stage. + insert into public.ownership_contributor_build_work + (user_id, repository_id, calculation_version, work_key, + github_node_id, display_login, is_bot) + select p_user, p_repository, p_version, + i->>'work_key', i->>'github_node_id', i->>'display_login', + coalesce(b.is_bot, f.is_bot) + from jsonb_array_elements(p_identities) i + left join public.ownership_contributor_build_work b + on b.repository_id = p_repository and b.calculation_version = p_version + and b.work_key = i->>'work_key' + left join ( + select distinct work_key, is_bot + from public.ownership_history_work + where scope_id = p_scope and calculation_version = p_version + ) f on f.work_key = i->>'work_key' + where i->>'work_key' = any(v_represented) + on conflict (repository_id, calculation_version, work_key) + do update set + display_login = excluded.display_login, + updated_at = now(); + + select count(*) into v_rows + from public.ownership_contributor_build_work + where repository_id = p_repository and calculation_version = p_version; + if v_rows > 10000 then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + end if; + + -- 5. cursor advance — same transaction as the writes above + -- (to_jsonb(NULL) is SQL NULL, which would null the whole jsonb_set + -- result — coalesce to JSON null for a clean durable cursor state) + v_cur := jsonb_set(st.history_cursor -> 'current', '{cursor}', + coalesce(to_jsonb(p_next_cursor), 'null'::jsonb), true); + if p_last_page then + if p_pass = 'accumulate' then + -- frozen pass done: flip to resolve at a fresh cursor + v_cur := v_cur + || '{"pass":"resolve","accumulate_done":true,"cursor":null}'::jsonb; + else + v_cur := v_cur || '{"resolve_done":true}'::jsonb; + end if; + end if; + + update public.ownership_sync_state + set history_cursor = jsonb_set(st.history_cursor, '{current}', v_cur), + updated_at = now() + where user_id = p_user and repository_id = p_repository; + + return 'applied'; +end +$$; + +-- ── 8. own_restart_scope — committed-V-provenance staging cleanup (§5.3) ─ +-- Build-level staging has no scope provenance, so a scope restarting at a +-- fresh ACCUMULATE cleans by COMMITTED-V provenance: staged keys whose +-- contributor is referenced by at least one committed V snapshot are +-- preserved; everything else (keys staged only by the failed attempt — +-- brand-new contributors and existing active-N contributors alike) is +-- removed. The retained contributor row itself is never deleted here. +create or replace function public.own_restart_scope( + p_user uuid, + p_repository uuid, + p_version int, + p_scope uuid +) +returns text +language plpgsql +security invoker +set search_path = public +as $$ +declare + st public.ownership_sync_state%rowtype; + v_cur jsonb; +begin + select * into st + from public.ownership_sync_state + where user_id = p_user and repository_id = p_repository + for update; + + if not found + or st.building_version is distinct from p_version + or st.phase <> 'history' + or st.history_cursor is null + or (st.history_cursor #>> '{current,scope_id}')::uuid is distinct from p_scope + then + return 'stale'; + end if; + + -- 1. reset the current scope's counter state + delete from public.ownership_history_work + where scope_id = p_scope and calculation_version = p_version; + + -- 2. staged keys with no committed-V snapshot reference are removed + delete from public.ownership_contributor_build_work bw + where bw.repository_id = p_repository and bw.calculation_version = p_version + and not exists ( + select 1 + from public.ownership_contributors c + join public.ownership_snapshots s + on s.contributor_id = c.id and s.repository_id = c.repository_id + where c.repository_id = p_repository + and c.contributor_key = bw.work_key + and s.calculation_version = p_version + ); + + -- 3. fresh ACCUMULATE cursor for the current scope + v_cur := jsonb_build_object( + 'scope_id', p_scope::text, + 'pass', 'accumulate', + 'cursor', null); + update public.ownership_sync_state + set history_cursor = jsonb_set(st.history_cursor, '{current}', v_cur), + updated_at = now() + where user_id = p_user and repository_id = p_repository; + + return 'restarted'; +end +$$; + +-- ── 9. own_promote_build — exact-set promotion + atomic flip (§5.7) ───── +-- Validates the promotion invariant BEFORE any shared metadata write: +-- the staged linked key set must equal the set of distinct linked +-- contributor keys represented by building-V snapshots — synthetic +-- 'unlinked'/'other' buckets are outside both sides (they cannot enter +-- staging and are not linked identity rows). Any asymmetry or bound +-- breach fails closed; nothing is applied. +create or replace function public.own_promote_build( + p_user uuid, + p_repository uuid, + p_version int +) +returns text +language plpgsql +security invoker +set search_path = public +as $$ +declare + st public.ownership_sync_state%rowtype; +begin + select * into st + from public.ownership_sync_state + where user_id = p_user and repository_id = p_repository + for update; + + if not found or st.building_version is distinct from p_version then + return 'stale'; + end if; + + -- pending work must be drained and accumulation state consumed + if st.pending_scopes is not null and st.pending_scopes <> '[]'::jsonb then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + if exists ( + select 1 from public.ownership_history_work + where repository_id = p_repository and calculation_version = p_version + ) then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + + -- cardinality bounds: ≤500 directory memberships per version, ≤10,000 + -- staged rows per build + if ( + select count(*) + from public.ownership_scope_versions sv + join public.ownership_scopes sc + on sc.id = sv.scope_id and sc.repository_id = sv.repository_id + where sv.repository_id = p_repository + and sv.calculation_version = p_version + and sc.scope_type = 'dir' + ) > 500 then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + if ( + select count(*) from public.ownership_contributor_build_work + where repository_id = p_repository and calculation_version = p_version + ) > 10000 then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + + -- exact-set invariant, both directions: + -- extra: a staged key no committed V snapshot represents + if exists ( + select 1 from public.ownership_contributor_build_work bw + where bw.repository_id = p_repository and bw.calculation_version = p_version + and not exists ( + select 1 + from public.ownership_contributors c + join public.ownership_snapshots s + on s.contributor_id = c.id and s.repository_id = c.repository_id + where c.repository_id = p_repository + and c.contributor_key = bw.work_key + and s.calculation_version = p_version + ) + ) then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + -- missing: a linked contributor key represented in V snapshots with + -- no staged build-work row + if exists ( + select 1 + from public.ownership_contributors c + join public.ownership_snapshots s + on s.contributor_id = c.id and s.repository_id = c.repository_id + where c.repository_id = p_repository + and c.identity_kind = 'linked' + and s.calculation_version = p_version + and not exists ( + select 1 from public.ownership_contributor_build_work bw + where bw.repository_id = p_repository + and bw.calculation_version = p_version + and bw.work_key = c.contributor_key + ) + ) then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + + -- same work_key must carry the same node id — mismatch fails closed + if exists ( + select 1 + from public.ownership_contributor_build_work bw + join public.ownership_contributors c + on c.repository_id = bw.repository_id + and c.contributor_key = bw.work_key + where bw.repository_id = p_repository + and bw.calculation_version = p_version + and c.github_node_id is distinct from bw.github_node_id + ) then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + + -- apply staged metadata to retained rows — exactly the validated set + update public.ownership_contributors c + set github_node_id = bw.github_node_id, + display_login = bw.display_login, + is_bot = bw.is_bot, + first_seen_at = bw.first_seen_at, + last_seen_at = bw.last_seen_at, + updated_at = now() + from public.ownership_contributor_build_work bw + where bw.repository_id = p_repository + and bw.calculation_version = p_version + and c.repository_id = bw.repository_id + and c.contributor_key = bw.work_key; + + -- the metadata application and the version flip commit together + update public.ownership_sync_state + set active_version = p_version, + building_version = null, + phase = 'done', + error = null, + history_cursor = null, + pending_scopes = null, + updated_at = now() + where user_id = p_user and repository_id = p_repository; + + delete from public.ownership_contributor_build_work + where repository_id = p_repository and calculation_version = p_version; + + return 'promoted'; +end +$$; + +-- ── 10. own_abandon_build — THE canonical build cleanup (§5.6) ────────── +-- The ONE semantic abandon operation: stale sweep, restore reconciliation, +-- operator abort, and invalid-state recovery all route here. Deletes the +-- building version's snapshots FIRST, then membership, then ephemeral +-- state, then prunes contributors/scopes referenced by nothing retained — +-- the ordering is required by the NO ACTION foreign keys. +create or replace function public.own_abandon_build( + p_user uuid, + p_repository uuid, + p_version int +) +returns text +language plpgsql +security invoker +set search_path = public +as $$ +declare + st public.ownership_sync_state%rowtype; +begin + select * into st + from public.ownership_sync_state + where user_id = p_user and repository_id = p_repository + for update; + + if not found or st.building_version is distinct from p_version then + return 'stale'; + end if; + + -- legal order under the NO ACTION FKs: snapshots -> membership -> work + delete from public.ownership_snapshots + where repository_id = p_repository and calculation_version = p_version; + delete from public.ownership_scope_versions + where repository_id = p_repository and calculation_version = p_version; + delete from public.ownership_history_work + where repository_id = p_repository and calculation_version = p_version; + delete from public.ownership_contributor_build_work + where repository_id = p_repository and calculation_version = p_version; + + -- anti-join prune: contributors referenced by zero snapshots across ALL + -- versions — N-referenced and synthetic rows always survive + delete from public.ownership_contributors c + where c.repository_id = p_repository + and not exists ( + select 1 from public.ownership_snapshots s + where s.contributor_id = c.id and s.repository_id = c.repository_id + ); + + -- base scopes referenced by no membership AND no snapshot — the NO + -- ACTION FKs make this delete impossible while either still references + delete from public.ownership_scopes sc + where sc.repository_id = p_repository + and not exists ( + select 1 from public.ownership_scope_versions sv + where sv.scope_id = sc.id and sv.repository_id = sc.repository_id + ) + and not exists ( + select 1 from public.ownership_snapshots s + where s.scope_id = sc.id and s.repository_id = sc.repository_id + ); + + -- clear build state; active_version is never touched + update public.ownership_sync_state + set building_version = null, + history_cursor = null, + pending_scopes = null, + phase = case when active_version is not null then 'done' else 'pending' end, + error = null, + updated_at = now() + where user_id = p_user and repository_id = p_repository; + + return 'abandoned'; +end +$$; + +-- ── 11a. own_build_preflight — §5.6c retired-version lifecycle gate ───── +-- Fresh-build preflight: under the sync-row lock, a non-null +-- building_version routes to resume/abandon ('blocked'); otherwise every +-- retired version (snapshots/membership not active_version) is garbage- +-- collected in the legal order — snapshots first, membership second, +-- then the same anti-join prunes as abandon — and the retired set is +-- re-verified empty inside the same transaction. Idempotent: a failed +-- attempt commits nothing, active keeps serving, retry is safe. This is +-- the GC step only — pinning a new revision requires a GitHub probe the +-- caller performs outside this transaction, so initialization is the +-- separate gated primitive below. +create or replace function public.own_build_preflight( + p_user uuid, + p_repository uuid +) +returns text +language plpgsql +security invoker +set search_path = public +as $$ +declare + st public.ownership_sync_state%rowtype; + v_left int; +begin + select * into st + from public.ownership_sync_state + where user_id = p_user and repository_id = p_repository + for update; + + if not found then + return 'stale'; + end if; + if st.building_version is not null then + return 'blocked'; + end if; + + -- retired versions: snapshots first, membership second + delete from public.ownership_snapshots s + where s.repository_id = p_repository + and (st.active_version is null + or s.calculation_version <> st.active_version); + delete from public.ownership_scope_versions sv + where sv.repository_id = p_repository + and (st.active_version is null + or sv.calculation_version <> st.active_version); + + -- then the same anti-join prunes as abandon + delete from public.ownership_contributors c + where c.repository_id = p_repository + and not exists ( + select 1 from public.ownership_snapshots s + where s.contributor_id = c.id and s.repository_id = c.repository_id + ); + delete from public.ownership_scopes sc + where sc.repository_id = p_repository + and not exists ( + select 1 from public.ownership_scope_versions sv + where sv.scope_id = sc.id and sv.repository_id = sc.repository_id + ) + and not exists ( + select 1 from public.ownership_snapshots s + where s.scope_id = sc.id and s.repository_id = sc.repository_id + ); + + -- the gate: no retired-version rows may remain — verified, not assumed + select count(*) into v_left + from ( + select calculation_version from public.ownership_snapshots + where repository_id = p_repository + union + select calculation_version from public.ownership_scope_versions + where repository_id = p_repository + ) v + where st.active_version is null + or v.calculation_version <> st.active_version; + if v_left > 0 then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + + return 'ready'; +end +$$; + +-- ── 11b. own_build_init — gated build initialization (§5.6c) ──────────── +-- A fresh build may initialize ONLY when the lifecycle invariant already +-- holds at request time: building_version null AND zero retired-version +-- rows. When retired rows are present the answer is 'blocked' — +-- building_version stays null; the caller runs own_build_preflight's GC +-- first and retries (the gate re-runs on every build-start attempt). The +-- pin is supplied by the caller's GitHub probe — never fetched here. +create or replace function public.own_build_init( + p_user uuid, + p_repository uuid, + p_new_version int, + p_pinned_oid text +) +returns text +language plpgsql +security invoker +set search_path = public +as $$ +declare + st public.ownership_sync_state%rowtype; + v_retired int; +begin + select * into st + from public.ownership_sync_state + where user_id = p_user and repository_id = p_repository + for update; + + if not found then + return 'stale'; + end if; + if st.building_version is not null then + return 'blocked'; + end if; + + -- gate: any retired-version row means no third version may initialize — + -- building_version stays null; the GC preflight runs first + select count(*) into v_retired + from ( + select calculation_version from public.ownership_snapshots + where repository_id = p_repository + union + select calculation_version from public.ownership_scope_versions + where repository_id = p_repository + ) v + where st.active_version is null + or v.calculation_version <> st.active_version; + if v_retired > 0 then + return 'blocked'; + end if; + + if p_new_version is null or p_new_version <= 0 + or p_new_version = st.active_version then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + if p_pinned_oid is null or p_pinned_oid !~ '^[0-9a-f]{40}$' then + raise exception 'OWNERSHIP_INTERNAL_ERROR'; + end if; + + update public.ownership_sync_state + set building_version = p_new_version, + phase = 'history', + error = null, + history_cursor = jsonb_build_object( + 'pinned_oid', p_pinned_oid, + 'build_started_at', now(), + 'current', null), + pending_scopes = null, + updated_at = now() + where user_id = p_user and repository_id = p_repository; + + return 'initialized'; +end +$$; + +-- ── 12. own_sweep_stale_work — orphan cleanup + abandon routing (§7) ──── +-- Bare-deletes orphan work rows ONLY when no build is live — a build +-- never gets cleaned piecemeal. A genuinely stale live build (no +-- checkpoint activity, no valid lease, sync row idle past the window) is +-- routed through the canonical abandon so V's snapshots/contributors/ +-- membership are cleaned in the same legal order. +create or replace function public.own_sweep_stale_work( + p_user uuid, + p_repository uuid, + p_stale_before timestamptz default now() - interval '14 days' +) +returns text +language plpgsql +security invoker +set search_path = public +as $$ +declare + st public.ownership_sync_state%rowtype; +begin + select * into st + from public.ownership_sync_state + where user_id = p_user and repository_id = p_repository + for update; + + if not found or st.building_version is null then + -- orphan rows only (leftovers of an already-cleared build or of a + -- sync row that no longer exists) — no build semantics to preserve + delete from public.ownership_history_work + where repository_id = p_repository; + delete from public.ownership_contributor_build_work + where repository_id = p_repository; + return 'swept-orphans'; + end if; + + -- stale live build: no activity on the sync row or the work rows inside + -- the window, and no valid lease + if st.updated_at < p_stale_before + and ( + (st.history_cursor #>> '{lease,expires}')::timestamptz is null + or (st.history_cursor #>> '{lease,expires}')::timestamptz < now() + ) + and not exists ( + select 1 from public.ownership_history_work w + where w.repository_id = p_repository and w.updated_at >= p_stale_before + ) + and not exists ( + select 1 from public.ownership_contributor_build_work b + where b.repository_id = p_repository and b.updated_at >= p_stale_before + ) + then + return public.own_abandon_build(p_user, p_repository, st.building_version); + end if; + + return 'active'; +end +$$; + +-- Functions default to EXECUTE for PUBLIC — revoke everywhere, then grant +-- the backend role only (007/012 pattern). All are SECURITY INVOKER. +revoke all on function public.own_checkpoint_page(uuid, uuid, int, uuid, text, text, text, jsonb, jsonb, boolean) from public, anon, authenticated; +revoke all on function public.own_restart_scope(uuid, uuid, int, uuid) from public, anon, authenticated; +revoke all on function public.own_promote_build(uuid, uuid, int) from public, anon, authenticated; +revoke all on function public.own_abandon_build(uuid, uuid, int) from public, anon, authenticated; +revoke all on function public.own_build_preflight(uuid, uuid) from public, anon, authenticated; +revoke all on function public.own_build_init(uuid, uuid, int, text) from public, anon, authenticated; +revoke all on function public.own_sweep_stale_work(uuid, uuid, timestamptz) from public, anon, authenticated; + +grant execute on function public.own_checkpoint_page(uuid, uuid, int, uuid, text, text, text, jsonb, jsonb, boolean) to service_role; +grant execute on function public.own_restart_scope(uuid, uuid, int, uuid) to service_role; +grant execute on function public.own_promote_build(uuid, uuid, int) to service_role; +grant execute on function public.own_abandon_build(uuid, uuid, int) to service_role; +grant execute on function public.own_build_preflight(uuid, uuid) to service_role; +grant execute on function public.own_build_init(uuid, uuid, int, text) to service_role; +grant execute on function public.own_sweep_stale_work(uuid, uuid, timestamptz) to service_role; + +-- ── 13. dash_ownership_scopes — active-version membership (§5.9) ──────── +-- Amended for version isolation: scopes are enumerated through +-- ownership_scope_versions at active_version — N+1-only scopes cannot +-- appear, N exclusion state cannot move, and excluded/exclusion state now +-- comes from the versioned membership row (the base row's columns are +-- non-authoritative legacy after this migration). Same signature, same +-- SECURITY INVOKER model, same grants. +create or replace function public.dash_ownership_scopes(p_user uuid, p_repository uuid) +returns table( + scope_path_hash text, + scope_type text, + display_path text, + depth int, + excluded boolean, + human_contributor_count int, + recent_human_maintainer_count int, + top_human_contributor_share numeric, + total_surviving_lines bigint, + unlinked_surviving_line_share numeric +) +language sql stable security invoker set search_path = public as +$$ + select sc.scope_path_hash, sc.scope_type, sc.display_path, sc.depth, sv.excluded, + t.human_contributor_count, t.recent_human_maintainer_count, + t.top_human_contributor_share, t.total_surviving_lines, + t.unlinked_surviving_line_share + from public.ownership_scopes sc + join public.ownership_sync_state st + on st.repository_id = sc.repository_id and st.user_id = sc.user_id + join public.ownership_scope_versions sv + on sv.scope_id = sc.id and sv.repository_id = sc.repository_id + and sv.user_id = sc.user_id + and sv.calculation_version = st.active_version + left join public.ownership_snapshots t + on t.scope_id = sc.id and t.repository_id = sc.repository_id + and t.contributor_id is null and t.period_granularity = 'head' + and t.calculation_version = st.active_version + where sc.user_id = p_user + and sc.repository_id = p_repository + and st.active_version is not null + order by sc.depth, sc.display_path +$$; + +revoke all on function public.dash_ownership_scopes(uuid, uuid) from public, anon, authenticated; +grant execute on function public.dash_ownership_scopes(uuid, uuid) to service_role; diff --git a/scripts/backup-scope.mjs b/scripts/backup-scope.mjs index 6854038..19afe83 100644 --- a/scripts/backup-scope.mjs +++ b/scripts/backup-scope.mjs @@ -29,6 +29,10 @@ export const CORE_TABLES = [ 'ownership_scopes', // ownership scope map — irreplaceable for // retained/disconnected repos GitHub can no // longer serve + 'ownership_scope_versions', // retained per-version scope membership — + // required to interpret active ownership state + // (exclusion state + which scopes belong to + // active_version); backed up like snapshots 'ownership_contributors',// repo-scoped contributor identity rows 'ownership_snapshots', // ownership aggregates — same irreplaceability ] @@ -54,6 +58,13 @@ export const EPHEMERAL_TABLES = [ // reprocess idempotently — safe to start empty 'security_events', // operational telemetry, 30-day retention anyway 'schema_migrations', // rebuilt by the migration runner, not restored + 'ownership_history_work', // crash-safe build counters — pseudonymous + // work keys; loss = discard the building + // version via canonical abandon, never + // active user data (architecture §9) + 'ownership_contributor_build_work', // build-level identity staging — exists + // only while a building_version exists; + // same discard-and-rebuild recovery ] // The canonical dump allowlist — the ONLY tables pg_dump may ever be asked diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index 2404c21..d8031b2 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -20,7 +20,14 @@ const sql = postgres(url, { onnotice: () => {} }) async function run() { const dir = path.join(root, 'migrations') - const files = (await fs.readdir(dir)).filter((f) => f.endsWith('.sql')).sort() + // MIGRATE_UP_TO bounds the chain at a migration prefix (e.g. '012') — + // used only by the recovery drill's upgrade leg to rehearse an + // old-version -> current-version apply. Unset in normal operation. + const upTo = process.env.MIGRATE_UP_TO + const files = (await fs.readdir(dir)) + .filter((f) => f.endsWith('.sql')) + .sort() + .filter((f) => !upTo || f.slice(0, 3) <= upTo) await sql` create table if not exists schema_migrations ( diff --git a/scripts/ownership-checkpoint-proofs.mjs b/scripts/ownership-checkpoint-proofs.mjs new file mode 100644 index 0000000..7205f5a --- /dev/null +++ b/scripts/ownership-checkpoint-proofs.mjs @@ -0,0 +1,765 @@ +// Ownership checkpointing proofs — real-Postgres integrity tests for the +// migration-013 substrate (docs/ownership-checkpoint-architecture.md). +// +// Invoked by scripts/recovery-drill.mjs on the drill_src database AFTER +// seeding and BEFORE the backup. Every scenario runs on its own throwaway +// tenant and cleans itself up, so the net effect on seeded rows is zero. +// Negative cases assert the SPECIFIC constraint that fires — a malformed +// statement cannot masquerade as a passing invariant proof. +// +// Matrix (architecture §12 + the B1 tranche requirements): +// A–F snapshot<->membership composite-FK integrity +// G–I base-scope NO ACTION enforcement + legal prune order +// J–L single-statement repository/account teardown incl. work tables +// M–O active-version dashboard isolation +// P–Q canonical abandon (initial-build and N->N+1) +// R–T scope-restart staging cleanup (committed-V provenance) +// U–W promotion exact-set invariant +// X–AA retired-version lifecycle gate + idempotent GC +// own_checkpoint_page CAS: apply / replay-stale / stale-cursor / +// stale-version / resolve staging / is_bot drift fail-closed +// own_sweep_stale_work: orphan sweep + stale-build abandon routing + +import crypto from 'node:crypto' +import { reduceHistoryToMonthly } from '../lib/ownership/history-reducer.mjs' +import { contributorKey } from '../lib/ownership/contributors.mjs' + +const HEX = (n) => n.toString(16).padStart(64, '0') +const MONTH = '2026-09-01T00:00:00Z' + +export async function checkpointingProofs(sql) { + const problems = [] + const check = (ok, msg) => { if (!ok) problems.push(msg) } + const mustFail = async (fn, label, expected = null) => { + try { + await fn() + problems.push(`${label}: statement must have been rejected`) + } catch (e) { + if (!expected) return + const names = [].concat(expected.name) + const cname = e.constraint_name || e.constraint || '' + const code = e.code || '' + const msg = String(e.message || '') + const hit = names.some((n) => cname === n || msg.includes(n)) + if (!hit) { + problems.push(`${label}: expected constraint ${names.join('/')} ` + + `(${expected.code || 'any'}), got ${cname || code || msg.slice(0, 80)}`) + } + if (expected.code && code !== expected.code) { + problems.push(`${label}: expected SQLSTATE ${expected.code}, got ${code || 'none'}`) + } + } + } + const mustRaise = async (fn, label) => { + try { + await fn() + problems.push(`${label}: expected OWNERSHIP_INTERNAL_ERROR, got success`) + } catch (e) { + if (!String(e.message || '').includes('OWNERSHIP_INTERNAL_ERROR')) { + problems.push(`${label}: expected OWNERSHIP_INTERNAL_ERROR, got ${String(e.message || '').slice(0, 90)}`) + } + } + } + const FK = (name) => ({ name, code: '23503' }) + const CHK = (name) => ({ name, code: '23514' }) + + let tenantSeq = 0 + async function tenant() { + const uid = crypto.randomUUID() + const repoId = crypto.randomUUID() + const gid = 80000000 + ++tenantSeq + await sql`insert into users (id, github_user_id, github_login) values (${uid}, ${gid}, ${'ckpt-' + tenantSeq})` + await sql`insert into repositories (id, user_id, github_repo_id, owner_login, name, full_name) + values (${repoId}, ${uid}, ${gid}, 'ckpt', ${'r' + tenantSeq}, ${'ckpt/r' + tenantSeq})` + return { uid, repoId } + } + let scopeSeq = 0 + const scope = (t, hash = null, path = 'src', type = 'dir', depth = 1) => { + const id = crypto.randomUUID() + const h = hash ?? `h-${++scopeSeq}` // unique per call — hash is repo-scoped + return sql`insert into ownership_scopes (id, user_id, repository_id, scope_path_hash, display_path, scope_type, depth) + values (${id}, ${t.uid}, ${t.repoId}, ${h}, ${path}, ${type}, ${depth})`.then(() => id) + } + const membership = (t, scopeId, ver, excluded = false, reason = null) => + sql`insert into ownership_scope_versions (user_id, repository_id, scope_id, calculation_version, excluded, exclusion_reason) + values (${t.uid}, ${t.repoId}, ${scopeId}, ${ver}, ${excluded}, ${reason})` + const contributor = (t, key, kind = 'linked', node = 'N', login = 'x', bot = false) => { + const id = crypto.randomUUID() + return sql`insert into ownership_contributors (id, user_id, repository_id, contributor_key, identity_kind, github_node_id, display_login, is_bot) + values (${id}, ${t.uid}, ${t.repoId}, ${key}, ${kind}, ${node}, ${login}, ${bot})`.then(() => id) + } + const snapshot = (t, scopeId, contribId, ver, touches = 1) => + sql`insert into ownership_snapshots (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, touch_commits, calculation_version) + values (${t.uid}, ${t.repoId}, ${scopeId}, ${contribId}, 'month', ${MONTH}, ${touches}, ${ver})` + const sync = (t, phase, active, building, cursor = null, pending = null) => + sql`insert into ownership_sync_state (user_id, repository_id, phase, active_version, building_version, history_cursor, pending_scopes) + values (${t.uid}, ${t.repoId}, ${phase}, ${active}, ${building}, ${cursor}, ${pending})` + const workRow = (t, scopeId, ver, key, touches = 1, bot = false) => + sql`insert into ownership_history_work (user_id, repository_id, scope_id, calculation_version, period_start, work_key, is_bot, touch_commits) + values (${t.uid}, ${t.repoId}, ${scopeId}, ${ver}, ${MONTH}, ${key}, ${bot}, ${touches})` + const stageRow = (t, ver, key, node = 'N', login = 'x', bot = false) => + sql`insert into ownership_contributor_build_work (user_id, repository_id, calculation_version, work_key, github_node_id, display_login, is_bot) + values (${t.uid}, ${t.repoId}, ${ver}, ${key}, ${node}, ${login}, ${bot})` + const count = async (t, table, ver = null) => { + const [r] = ver == null + ? await sql`select count(*)::int c from ${sql(table)} where repository_id = ${t.repoId}` + : await sql`select count(*)::int c from ${sql(table)} where repository_id = ${t.repoId} and calculation_version = ${ver}` + return r.c + } + // calls an own_* primitive; args are bound by position and Postgres + // infers each parameter type from the function signature (strings + // containing JSON land correctly on jsonb params). Failures are tagged + // with the call site so a raise reports WHICH primitive rejected. + const call = (fn, ...args) => + sql.unsafe( + `select ${fn}(${args.map((_, i) => `$${i + 1}`).join(',')}) as r`, + args + ).then(([r]) => r.r, (e) => { + e.message = `${fn}(${args.map((a) => JSON.stringify(a)).join(', ')}): ${e.message}` + throw e + }) + + const mark = (m) => { if (process.env.CKPT_DEBUG) console.log(' [ckpt]', m) } + mark('A-F') + /* ── A–F: snapshot<->membership composite-FK integrity ──────────────── */ + { + const t = await tenant() + const s1 = await scope(t) + const c1 = await contributor(t, HEX(1)) + await membership(t, s1, 1) + // A: snapshot with no matching membership → rejected + const sOrphan = await scope(t) + await mustFail(() => snapshot(t, sOrphan, c1, 1), 'A: snapshot without membership', + FK(['ownership_snapshots_scope_version'])) + // B: membership at V1, snapshot claims V2 → rejected (wrong version) + await mustFail(() => snapshot(t, s1, c1, 2), 'B: snapshot at wrong version', + FK('ownership_snapshots_scope_version')) + // C: snapshot claims another repository's scope → rejected (either + // the scope/repo pair FK or the membership composite fires — both + // encode the same isolation invariant; a valid-shape contributor + // row is used so only the FK can reject) + const t2 = await tenant() + const s2 = await scope(t2) + await membership(t2, s2, 1) + await mustFail(() => sql`insert into ownership_snapshots + (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, touch_commits, calculation_version) + values (${t.uid}, ${t.repoId}, ${s2}, ${c1}, 'month', ${MONTH}, 1, 1)`, + 'C: snapshot using another repo scope', FK(['ownership_snapshots_scope_repo', 'ownership_snapshots_scope_version'])) + // D: cross-tenant — repoA's user/repo pairing violated under user B + await mustFail(() => sql`insert into ownership_snapshots + (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, touch_commits, calculation_version) + values (${t2.uid}, ${t.repoId}, ${s1}, ${c1}, 'month', ${MONTH}, 1, 1)`, + 'D: snapshot user mismatched to repo tenant', FK(['ownership_snapshots_repo_tenant', 'ownership_snapshots_scope_version'])) + // E: delete membership while a snapshot references it → rejected + await snapshot(t, s1, c1, 1) + await mustFail( + () => sql`delete from ownership_scope_versions where scope_id = ${s1} and calculation_version = 1`, + 'E: membership delete with referencing snapshot', FK('ownership_snapshots_scope_version')) + // F: legal order — snapshots first, membership second → succeeds + await sql`delete from ownership_snapshots where repository_id = ${t.repoId} and calculation_version = 1` + await sql`delete from ownership_scope_versions where repository_id = ${t.repoId} and calculation_version = 1` + check(await count(t, 'ownership_scope_versions') === 0, 'F: legal delete order failed') + + /* ── G–I: base-scope NO ACTION + legal prune order ───────────────── */ + // G: direct delete of scope referenced by membership → rejected + await membership(t, s1, 1) // re-add membership on s1 + await mustFail(() => sql`delete from ownership_scopes where id = ${s1}`, + 'G: scope delete referenced by membership', FK('ownership_scope_versions_scope_repo')) + // H: direct delete of scope referenced by snapshot → rejected. A + // snapshot implies same-version membership, so the membership FK + // may legitimately fire first — either NO ACTION constraint + // rejecting the delete proves the protection (the upgrade leg + // separately asserts confdeltype='a' on all three FKs) + await snapshot(t, s1, c1, 1) + await mustFail(() => sql`delete from ownership_scopes where id = ${s1}`, + 'H: scope delete referenced by snapshot', + FK(['ownership_snapshots_scope_repo', 'ownership_scope_versions_scope_repo'])) + // I: snapshots → membership → scope → succeeds + await sql`delete from ownership_snapshots where repository_id = ${t.repoId}` + await sql`delete from ownership_scope_versions where repository_id = ${t.repoId}` + await sql`delete from ownership_scopes where id = ${s1}` + check((await count(t, 'ownership_scopes')) === 1, 'I: legal scope prune order failed') + + /* ── J–L: single-statement teardown incl. work tables ────────────── */ + const tJ = await tenant() + const sJ = await scope(tJ) + const cJ = await contributor(tJ, HEX(2)) + await membership(tJ, sJ, 1) + await snapshot(tJ, sJ, cJ, 1) + await sync(tJ, 'history', 1, 2) + await workRow(tJ, sJ, 2, HEX(3)) + await stageRow(tJ, 2, HEX(3)) + await sql`delete from repositories where id = ${tJ.repoId}` + for (const table of ['ownership_scopes', 'ownership_scope_versions', 'ownership_snapshots', + 'ownership_contributors', 'ownership_sync_state', 'ownership_history_work', + 'ownership_contributor_build_work']) { + check(await count(tJ, table) === 0, `J/L: ${table} survived single-statement repository delete`) + } + const tK = await tenant() + const sK = await scope(tK) + const cK = await contributor(tK, HEX(4)) + await membership(tK, sK, 1) + await snapshot(tK, sK, cK, 1) + await sync(tK, 'done', 1, null) + await workRow(tK, sK, 1, HEX(5)) + await stageRow(tK, 1, HEX(5)) + await sql`delete from users where id = ${tK.uid}` + for (const table of ['ownership_scopes', 'ownership_scope_versions', 'ownership_snapshots', + 'ownership_contributors', 'ownership_sync_state', 'ownership_history_work', + 'ownership_contributor_build_work']) { + const [r] = await sql`select count(*)::int c from ${sql(table)} where user_id = ${tK.uid}` + check(r.c === 0, `K/L: ${table} survived single-statement user delete`) + } + const [{ c: repoLeft }] = await sql`select count(*)::int c from repositories where id = ${tK.repoId}` + check(repoLeft === 0, 'K: repositories row survived user delete') + + /* ── M–O: active-version dashboard isolation ─────────────────────── */ + const tM = await tenant() + const sM = await scope(tM, 'h-m1') + const sN2 = await scope(tM, 'h-m2', 'lib') + await membership(tM, sM, 1) + await membership(tM, sN2, 2) // building-V2-only scope + await sync(tM, 'done', 1, 2) + const out0 = await sql`select scope_path_hash, excluded from dash_ownership_scopes(${tM.uid}, ${tM.repoId})` + check(out0.length === 1 && out0[0].scope_path_hash === 'h-m1', + `M: building-version scope leaked into active output (${JSON.stringify(out0)})`) + // N: N+1 exclusion change must not move N output + await membership(tM, sM, 2, true, 'cardinality_cap') + const outN = await sql`select scope_path_hash, excluded from dash_ownership_scopes(${tM.uid}, ${tM.repoId})` + check(outN.length === 1 && outN[0].excluded === false, + 'N: building-version exclusion state leaked into active output') + // O: the N+1-only scope stays invisible until promotion + const [outO] = await sql`select count(*)::int c from dash_ownership_scopes(${tM.uid}, ${tM.repoId}) where scope_path_hash = 'h-m2'` + check(outO.c === 0, 'O: N+1-only base scope visible under active N') + await sql`delete from users where id = ${tM.uid}` + await sql`delete from users where id = ${t2.uid}` + await sql`delete from users where id = ${t.uid}` + + /* ── P: initial-build abort → zero V1 artifacts ──────────────────── */ + const tP = await tenant() + const sP = await scope(tP) + const cP = await contributor(tP, HEX(6)) + const cP2 = await contributor(tP, HEX(7)) + await membership(tP, sP, 1) + await snapshot(tP, sP, cP, 1) + await workRow(tP, sP, 1, HEX(8)) + await stageRow(tP, 1, HEX(6)) + await stageRow(tP, 1, HEX(9)) + await sync(tP, 'history', null, 1) + const rP = await call('own_abandon_build', tP.uid, tP.repoId, 1) + check(rP === 'abandoned', `P: abandon returned ${rP}`) + check(await count(tP, 'ownership_snapshots') === 0, 'P: V1 snapshots survived abandon') + check(await count(tP, 'ownership_scope_versions') === 0, 'P: V1 membership survived abandon') + check(await count(tP, 'ownership_history_work') === 0, 'P: history work survived abandon') + check(await count(tP, 'ownership_contributor_build_work') === 0, 'P: staging survived abandon') + // contributors unreferenced by any snapshot across all versions prune + check(await count(tP, 'ownership_contributors') === 0, 'P: V1-only contributors not pruned') + // unreferenced base scope prunes too + check(await count(tP, 'ownership_scopes') === 0, 'P: V1-only base scope not pruned') + const [{ phase: pPhase, active_version: pActive, building_version: pBuild }] = + await sql`select phase, active_version, building_version from ownership_sync_state where repository_id = ${tP.repoId}` + check(pPhase === 'pending' && pActive === null && pBuild === null, + `P: sync state not reset (phase=${pPhase}, active=${pActive}, building=${pBuild})`) + await sql`delete from users where id = ${tP.uid}` + + /* ── Q: N→N+1 abort — N byte-identical, N+1 gone ─────────────────── */ + const tQ = await tenant() + const sQ = await scope(tQ, 'h-q1') + const sQ2 = await scope(tQ, 'h-q2') + const cQ = await contributor(tQ, HEX(10), 'linked', 'Nq', 'carol') + const cQ2 = await contributor(tQ, HEX(11), 'linked', 'Nq2', 'newbie') + await membership(tQ, sQ, 1) + await membership(tQ, sQ, 2) + await membership(tQ, sQ2, 2) // N+1-only scope + await snapshot(tQ, sQ, cQ, 1, 7) // N snapshot + await snapshot(tQ, sQ, cQ, 2, 9) // committed N+1 snapshot + await snapshot(tQ, sQ2, cQ2, 2, 4) // N+1-only contributor snapshot + await workRow(tQ, sQ2, 2, HEX(12)) + await stageRow(tQ, 2, HEX(10)) + await sync(tQ, 'history', 1, 2) + const rQ = await call('own_abandon_build', tQ.uid, tQ.repoId, 2) + check(rQ === 'abandoned', `Q: abandon returned ${rQ}`) + check(await count(tQ, 'ownership_snapshots', 2) === 0, 'Q: N+1 snapshots survived') + check(await count(tQ, 'ownership_scope_versions', 2) === 0, 'Q: N+1 membership survived') + check(await count(tQ, 'ownership_history_work') === 0, 'Q: work rows survived') + check(await count(tQ, 'ownership_contributor_build_work') === 0, 'Q: staging survived') + check(await count(tQ, 'ownership_snapshots', 1) === 1, 'Q: N snapshot lost') + check(await count(tQ, 'ownership_scope_versions', 1) === 1, 'Q: N membership lost') + // N-referenced contributor survives untouched; N+1-only pruned + const qContribs = await sql`select contributor_key, display_login from ownership_contributors where repository_id = ${tQ.repoId}` + check(qContribs.length === 1 && qContribs[0].contributor_key === HEX(10) && qContribs[0].display_login === 'carol', + `Q: contributors wrong after abort (${JSON.stringify(qContribs)})`) + // N-referenced base scope survives; N+1-only base scope pruned + const qScopes = await sql`select scope_path_hash from ownership_scopes where repository_id = ${tQ.repoId}` + check(qScopes.length === 1 && qScopes[0].scope_path_hash === 'h-q1', + `Q: base scopes wrong after abort (${JSON.stringify(qScopes)})`) + const [{ active_version: qActive, building_version: qBuild, phase: qPhase }] = + await sql`select active_version, building_version, phase from ownership_sync_state where repository_id = ${tQ.repoId}` + check(qActive === 1 && qBuild === null && qPhase === 'done', + `Q: sync state wrong after abort (active=${qActive}, building=${qBuild}, phase=${qPhase})`) + await sql`delete from users where id = ${tQ.uid}` + + /* ── R–T: scope-restart staging cleanup ──────────────────────────── */ + const tR = await tenant() + const sR = await scope(tR, 'h-r1') + const sR2 = await scope(tR, 'h-r2') + const cR = await contributor(tR, HEX(20), 'linked', 'Nr', 'kee') + const cR2 = await contributor(tR, HEX(21), 'linked', 'Nr2', 'solo') + await membership(tR, sR, 2) + await membership(tR, sR2, 2) + await snapshot(tR, sR, cR, 2) // committed V2 snapshot references cR + // staging: HEX(20) — preserved (committed-V snapshot), HEX(21) — + // existing retained contributor staged ONLY by the failed scope + // (no V2 snapshot) → staging row removed, retained row untouched. + // HEX(22) — brand-new contributor staged by failed attempt → removed. + await stageRow(tR, 2, HEX(20)) + await stageRow(tR, 2, HEX(21), 'Nr2', 'solo') + await stageRow(tR, 2, HEX(22), 'Nnew', 'ghost') + await workRow(tR, sR2, 2, HEX(20)) + await sync(tR, 'history', 1, 2, + sql`jsonb_build_object('current', jsonb_build_object('scope_id', ${sR2}::text, 'pass', 'resolve', 'cursor', 'zz'))`) + const rR = await call('own_restart_scope', tR.uid, tR.repoId, 2, sR2) + check(rR === 'restarted', `R/S/T: restart returned ${rR}`) + check(await count(tR, 'ownership_history_work') === 0, 'R: scope work rows survived restart') + const staged = await sql`select work_key from ownership_contributor_build_work + where repository_id = ${tR.repoId} order by work_key` + check(staged.length === 1 && staged[0].work_key === HEX(20), + `R/S/T: staging wrong after restart (${JSON.stringify(staged)})`) + check((await count(tR, 'ownership_contributors')) === 2, + 'S: retained contributor deleted by restart cleanup') + const [{ pass: rPass, cursor: rCur }] = await sql` + select history_cursor #>> '{current,pass}' as pass, + history_cursor #>> '{current,cursor}' as cursor + from ownership_sync_state where repository_id = ${tR.repoId}` + check(rPass === 'accumulate' && rCur === null, + `R: cursor not reset to fresh ACCUMULATE (pass=${rPass}, cursor=${rCur})`) + await sql`delete from users where id = ${tR.uid}` + + /* ── U–W: promotion exact-set invariant ──────────────────────────── */ + mark('U-W') + const mkPromotable = async () => { + const t = await tenant() + const s = await scope(t) + const c = await contributor(t, HEX(30), 'linked', 'Np', 'promo') + await membership(t, s, 1) + await snapshot(t, s, c, 1) + await stageRow(t, 1, HEX(30), 'Np', 'promo') + await sync(t, 'history', null, 1, null, sql`'[]'::jsonb`) + return { t, s, c } + } + // U: extra staged key → fail closed, nothing applied + { + const { t } = await mkPromotable() + await stageRow(t, 1, HEX(31), 'Nextra', 'stale') + await mustRaise(() => call('own_promote_build', t.uid, t.repoId, 1), 'U: extra staged key') + const [{ building_version }] = await sql`select building_version from ownership_sync_state where repository_id = ${t.repoId}` + check(building_version === 1, 'U: building version lost after failed promotion') + check(await count(t, 'ownership_contributor_build_work') === 2, + 'U: staging mutated by failed promotion') + await sql`delete from users where id = ${t.uid}` + } + // V: missing staged key → fail closed + { + const { t } = await mkPromotable() + await sql`delete from ownership_contributor_build_work where repository_id = ${t.repoId}` + await mustRaise(() => call('own_promote_build', t.uid, t.repoId, 1), 'V: missing staged key') + await sql`delete from users where id = ${t.uid}` + } + // W: exact set → promoted atomically (flip + metadata + staging delete) + { + mark('W') + const { t } = await mkPromotable() + const r = await call('own_promote_build', t.uid, t.repoId, 1) + check(r === 'promoted', `W: promote returned ${r}`) + const [{ active_version, building_version, phase }] = + await sql`select active_version, building_version, phase from ownership_sync_state where repository_id = ${t.repoId}` + check(active_version === 1 && building_version === null && phase === 'done', + `W: flip wrong (active=${active_version}, building=${building_version}, phase=${phase})`) + check(await count(t, 'ownership_contributor_build_work') === 0, 'W: staging not consumed') + const [{ display_login }] = await sql`select display_login from ownership_contributors where repository_id = ${t.repoId}` + check(display_login === 'promo', 'W: staged metadata not applied') + await sql`delete from users where id = ${t.uid}` + } + + /* ── X–AA: retired-version lifecycle gate ────────────────────────── */ + const tX = await tenant() + const sX = await scope(tX) + const cX = await contributor(tX, HEX(40)) + await membership(tX, sX, 1) + await membership(tX, sX, 2) + await snapshot(tX, sX, cX, 2) + // stale retired V1 snapshot too — simulates post-promotion GC-pending + await snapshot(tX, sX, cX, 1) + await sync(tX, 'done', 2, null) + // X: init request while retired V1 rows remain → blocked, stays null + const rX = await call('own_build_init', tX.uid, tX.repoId, 3, 'b'.repeat(40)) + check(rX === 'blocked', `X: init with retired rows returned ${rX}`) + const [{ building_version: xBuild }] = await sql`select building_version from ownership_sync_state where repository_id = ${tX.repoId}` + check(xBuild === null, 'X: building_version set despite retired rows') + // Y: preflight GC removes retired V1 in legal order → init proceeds + const rY = await call('own_build_preflight', tX.uid, tX.repoId) + check(rY === 'ready', `Y: preflight returned ${rY}`) + const yVers = await sql`select distinct calculation_version v from ( + select calculation_version from ownership_snapshots where repository_id = ${tX.repoId} + union select calculation_version from ownership_scope_versions where repository_id = ${tX.repoId}) q` + check(yVers.length === 1 && yVers[0].v === 2, `Y: retired rows survived GC (${JSON.stringify(yVers)})`) + const rY2 = await call('own_build_init', tX.uid, tX.repoId, 3, 'b'.repeat(40)) + check(rY2 === 'initialized', `Y: init after GC returned ${rY2}`) + const [{ building_version: yBuild, phase: yPhase }] = + await sql`select building_version, phase from ownership_sync_state where repository_id = ${tX.repoId}` + check(yBuild === 3 && yPhase === 'history', 'Y: build not initialized after GC') + // AA: clean-state preflight is a no-op retry path — clear the build, + // re-run preflight (nothing retired), init again + await sql`update ownership_sync_state set building_version = null, phase = 'done' where repository_id = ${tX.repoId}` + const rAA = await call('own_build_preflight', tX.uid, tX.repoId) + check(rAA === 'ready', `AA: clean preflight returned ${rAA}`) + check(await count(tX, 'ownership_snapshots') === 1 && await count(tX, 'ownership_scope_versions') === 1, + 'AA: clean preflight deleted active data') + await sql`delete from users where id = ${tX.uid}` + + // Z: forced GC failure mid-preflight — a trigger that raises inside + // the same transaction makes the membership delete fail → the + // whole preflight rolls back: active keeps serving, building stays + // null, retired rows remain, and the next attempt retries the same + // idempotent GC (DDL rolls back with the tx, so the trigger is + // self-cleaning) + { + const tZ = await tenant() + const sZ = await scope(tZ) + const cZ = await contributor(tZ, HEX(41)) + await membership(tZ, sZ, 1) + await membership(tZ, sZ, 2) + await snapshot(tZ, sZ, cZ, 2) + await snapshot(tZ, sZ, cZ, 1) + await sync(tZ, 'done', 2, null) + await sql.begin(async (tx) => { + await tx`create function gc_fail() returns trigger language plpgsql as + $f$ begin raise exception 'forced gc failure'; end $f$` + await tx`create trigger force_gc_fail before delete on ownership_scope_versions + for each row execute function gc_fail()` + let failed = false + try { + await tx`select own_build_preflight(${tZ.uid}, ${tZ.repoId})` + } catch (e) { + failed = String(e.message || '').includes('forced gc failure') + if (!failed) problems.push(`Z: unexpected error ${String(e.message).slice(0, 80)}`) + } + if (!failed) problems.push('Z: GC failure was not triggered') + throw new Error('rollback trigger + failed preflight') + }).catch(() => {}) + const [zStill] = await sql`select count(*)::int c from ownership_scope_versions + where repository_id = ${tZ.repoId} and calculation_version = 1` + const [zStillSnap] = await sql`select count(*)::int c from ownership_snapshots + where repository_id = ${tZ.repoId} and calculation_version = 1` + check(zStill.c === 1 && zStillSnap.c === 1, + `Z: GC effects persisted across forced failure (mem=${zStill.c}, snap=${zStillSnap.c})`) + const [{ active_version: zAct, building_version: zBld }] = + await sql`select active_version, building_version from ownership_sync_state where repository_id = ${tZ.repoId}` + check(zAct === 2 && zBld === null, `Z: active/building wrong after forced abort (${zAct}/${zBld})`) + // retry runs the same idempotent GC — trigger rolled back with the tx + const rZ = await call('own_build_preflight', tZ.uid, tZ.repoId) + check(rZ === 'ready', `Z: preflight retry returned ${rZ}`) + const [zGone] = await sql`select count(*)::int c from ownership_scope_versions + where repository_id = ${tZ.repoId} and calculation_version = 1` + check(zGone.c === 0, 'Z: retired V1 not collected on retry') + await sql`delete from users where id = ${tZ.uid}` + } + } + + /* ── own_checkpoint_page — CAS / crash-window proofs ───────────────── */ + { + const t = await tenant() + const s = await scope(t) + await membership(t, s, 1) + await sync(t, 'history', null, 1, + sql`jsonb_build_object('pinned_oid', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'current', jsonb_build_object('scope_id', ${s}::text, 'pass', 'accumulate', 'cursor', null))`, + sql`jsonb_build_array(${s}::text)`) + + // jsonb params through sql.unsafe must be JS values — the driver + // JSON-encodes them; a pre-stringified payload would land as a jsonb + // STRING and trip the function's array-shape guard + const delta = (key, touches, bot = false) => [{ + period_start: MONTH, work_key: key, is_bot: bot, touches, + first_seen_at: '2026-09-02T00:00:00Z', last_seen_at: '2026-09-05T00:00:00Z', + }] + const ident = (key, node, login, obs) => [{ + work_key: key, github_node_id: node, display_login: login, observed_is_bot: obs, + }] + + // apply page 1 + const r1 = await call('own_checkpoint_page', t.uid, t.repoId, 1, s, 'accumulate', + null, 'cur1', delta(HEX(50), 5)) + check(r1 === 'applied', `checkpoint page1 returned ${r1}`) + // crash window A: response lost after commit — replay sees stale, no + // double-apply + const r2 = await call('own_checkpoint_page', t.uid, t.repoId, 1, s, 'accumulate', + null, 'cur1', delta(HEX(50), 5)) + check(r2 === 'stale', `replay after commit returned ${r2}`) + let [{ c: touch }] = await sql`select touch_commits::int c from ownership_history_work where repository_id = ${t.repoId}` + check(touch === 5, `double-count after lost response (touch=${touch})`) + // stale cursor → rejected, no writes + const r3 = await call('own_checkpoint_page', t.uid, t.repoId, 1, s, 'accumulate', + 'wrong-cursor', 'cur2', delta(HEX(51), 2)) + check(r3 === 'stale', `stale cursor returned ${r3}`) + check(await count(t, 'ownership_history_work') === 1, 'stale cursor wrote rows') + // stale version → rejected + const r4 = await call('own_checkpoint_page', t.uid, t.repoId, 9, s, 'accumulate', + 'cur1', 'cur2', delta(HEX(52), 2)) + check(r4 === 'stale', `stale version returned ${r4}`) + check(await count(t, 'ownership_history_work') === 1, 'stale version wrote rows') + // accumulate page 2 adds to the same bucket + const r5 = await call('own_checkpoint_page', t.uid, t.repoId, 1, s, 'accumulate', + 'cur1', 'cur2', delta(HEX(50), 3)) + check(r5 === 'applied', `checkpoint page2 returned ${r5}`) + ;[{ c: touch }] = await sql`select touch_commits::int c from ownership_history_work where repository_id = ${t.repoId}` + check(touch === 8, `additive upsert wrong (touch=${touch})`) + // bot drift inside the build fails closed and leaves cursor at cur2 + await mustRaise(() => call('own_checkpoint_page', t.uid, t.repoId, 1, s, 'accumulate', + 'cur2', 'cur3', delta(HEX(50), 1, true)), 'accumulate is_bot drift') + const [{ cursor: curNow }] = await sql`select history_cursor #>> '{current,cursor}' as cursor from ownership_sync_state where repository_id = ${t.repoId}` + check(curNow === 'cur2', `cursor moved on failed page (${curNow})`) + // last accumulate page flips to resolve + const r6 = await call('own_checkpoint_page', t.uid, t.repoId, 1, s, 'accumulate', + 'cur2', null, [], [], true) + check(r6 === 'applied', `flip call returned ${r6}`) + const [{ pass: curPass }] = await sql`select history_cursor #>> '{current,pass}' as pass from ownership_sync_state where repository_id = ${t.repoId}` + check(curPass === 'resolve', `pass not flipped (${curPass})`) + // resolve stages identity with FROZEN is_bot + const r7 = await call('own_checkpoint_page', t.uid, t.repoId, 1, s, 'resolve', + null, 'r1', [], ident(HEX(50), 'Nz', 'zed', false)) + check(r7 === 'applied', `resolve stage returned ${r7}`) + const staged = await sql`select github_node_id, display_login, is_bot from ownership_contributor_build_work where repository_id = ${t.repoId}` + check(staged.length === 1 && staged[0].github_node_id === 'Nz' && staged[0].is_bot === false, + `staged row wrong (${JSON.stringify(staged)})`) + // pass-2 bot drift fails closed + await mustRaise(() => call('own_checkpoint_page', t.uid, t.repoId, 1, s, 'resolve', + 'r1', 'r2', [], ident(HEX(50), 'Nz', 'zed', true)), 'resolve is_bot drift') + // key seen only in pass 2 (no pass-1 counters) is ignored + const r8 = await call('own_checkpoint_page', t.uid, t.repoId, 1, s, 'resolve', + 'r1', 'r2', [], ident(HEX(53), 'Nn', 'newguy', false)) + check(r8 === 'applied', `pass2-only key call returned ${r8}`) + check(await count(t, 'ownership_contributor_build_work') === 1, + 'pass-2-only key was staged') + await sql`delete from users where id = ${t.uid}` + } + + /* ── represented-set authorization — durable-identity minimization ─── */ + // §6.4: existence in ownership_history_work is NOT sufficient + // authorization to persist contributor identity — only membership in + // the deterministic per-period Top-25 union (touch_commits DESC, + // work_key ASC; 'unlinked' excluded) is. The SAME synthetic population + // is pushed through the canonical reducer and through the resolve path; + // the staged key set must equal the reducer's individually represented + // linked set EXACTLY — not merely in cardinality. + { + const t = await tenant() + const s = await scope(t) + await membership(t, s, 1) + // reducer-side repository id — only feeds contributorKey derivation + const probeRepo = `R_ckpt_${t.repoId}` + const P1 = '2026-01-01T00:00:00Z' + const P2 = '2026-02-01T00:00:00Z' + const isBot = (login) => /\[bot\]$/i.test(login) + const mk = (node, login, touches) => ({ + node, login, touches, key: contributorKey(probeRepo, node), + }) + + // P1: a01..a24 ranks 1..24 (touches 47..24); tie pair both at 20 → + // ranks 25/26 decided by work_key ASC; oExtra 15 → rank 27; + // dKey 12 → rank 28. + const aKeys = Array.from({ length: 24 }, (_, i) => + mk(`U_a_${String(i + 1).padStart(2, '0')}`, + i === 0 ? 'a-01[bot]' : `a-${i + 1}`, { [P1]: 47 - i })) + const tieA = mk('U_tie_a', 'tie-a', { [P1]: 20 }) + const tieB = mk('U_tie_b', 'tie-b', { [P1]: 20 }) + const [tieWin, tieLose] = tieA.key < tieB.key ? [tieA, tieB] : [tieB, tieA] + const oExtra = mk('U_overflow', 'always-overflow', { [P1]: 15, [P2]: 2 }) + const dKey = mk('U_union', 'union-peek', { [P1]: 12, [P2]: 30 }) + // P2: dKey rank 1 (30); c01..c24 ranks 2..25 (touches 27..4); + // oExtra rank 26 (2) → overflow in EVERY month. + const cKeys = Array.from({ length: 24 }, (_, i) => + mk(`U_c_${String(i + 1).padStart(2, '0')}`, + i === 0 ? 'c-01[bot]' : `c-${i + 1}`, { [P2]: 27 - i })) + const fKey = mk('U_pass2only', 'pass2-only', {}) // no counters at all + const all = [...aKeys, tieA, tieB, oExtra, dKey, ...cKeys] + + // pass-1 counters exactly as ACCUMULATE would have written them + for (const c of all) { + for (const [period, n] of Object.entries(c.touches)) { + await sql`insert into ownership_history_work + (user_id, repository_id, scope_id, calculation_version, + period_start, work_key, is_bot, touch_commits) + values (${t.uid}, ${t.repoId}, ${s}, 1, ${period}, ${c.key}, + ${isBot(c.login)}, ${n})` + } + } + // unlinked bucket rows — never participate in linked ranking + await sql`insert into ownership_history_work + (user_id, repository_id, scope_id, calculation_version, + period_start, work_key, is_bot, touch_commits) + values (${t.uid}, ${t.repoId}, ${s}, 1, ${P1}, 'unlinked', false, 7), + (${t.uid}, ${t.repoId}, ${s}, 1, ${P2}, 'unlinked', false, 3)` + + // the same population through the canonical reducer + const nodes = [] + for (const c of all) { + for (const [period, n] of Object.entries(c.touches)) { + for (let i = 0; i < n; i++) { + nodes.push({ committedDate: `${period.slice(0, 7)}-15T00:00:00Z`, + author: { user: { id: c.node, login: c.login } } }) + } + } + } + for (const [period, n] of [[P1, 7], [P2, 3]]) { + for (let i = 0; i < n; i++) { + nodes.push({ committedDate: `${period.slice(0, 7)}-15T00:00:00Z`, + author: { user: null } }) + } + } + const reduced = reduceHistoryToMonthly({ + repositoryId: probeRepo, + scope: { scope_path_hash: 'h', display_path: 'src' }, + nodes, + }) + const expected = new Map() + for (const c of reduced.contributor_candidates) { + if (c.identity_kind === 'linked') expected.set(c.contributor_key, c) + } + check(expected.has(tieWin.key) && !expected.has(tieLose.key) + && expected.has(dKey.key) && !expected.has(oExtra.key), + 'test fixture: reducer represented set does not match intended ranks') + + // RESOLVE with identities for every counter-backed key + the + // pass-2-only key — a caller that filters NOTHING. + await sync(t, 'history', null, 1, + sql`jsonb_build_object('current', jsonb_build_object( + 'scope_id', ${s}::text, 'pass', 'resolve', + 'accumulate_done', true, 'cursor', null))`, + sql`jsonb_build_array(${s}::text)`) + const identities = [...all, fKey].map((c) => ({ + work_key: c.key, github_node_id: c.node, display_login: c.login, + observed_is_bot: isBot(c.login), + })) + const r = await call('own_checkpoint_page', t.uid, t.repoId, 1, s, + 'resolve', null, 'r-done', [], identities) + check(r === 'applied', `represented-set resolve returned ${r}`) + + const stagedRows = await sql`select work_key, github_node_id, + display_login, is_bot from ownership_contributor_build_work + where repository_id = ${t.repoId}` + const stagedKeys = new Set(stagedRows.map((x) => x.work_key)) + // differential: DB staged set == reducer individually represented set + check(stagedKeys.size === expected.size + && [...stagedKeys].every((k) => expected.has(k)), + `staged set != reducer represented set (staged=${stagedKeys.size}, expected=${expected.size})`) + for (const row of stagedRows) { + const c = expected.get(row.work_key) + if (!c || c.github_node_id !== row.github_node_id + || c.display_login !== row.display_login || c.is_bot !== row.is_bot) { + problems.push(`staged identity fields wrong for ${row.work_key.slice(0, 12)} (${JSON.stringify(row)})`) + } + } + // A/C: rank-25 = the tie winner chosen by work_key ASC → staged + check(stagedKeys.has(tieWin.key), 'A/C: rank-25 tie winner not staged') + // B/C: rank-26 = the tie loser → NEVER persisted anywhere + check(!stagedKeys.has(tieLose.key), 'B/C: rank-26 tie loser staged') + const [{ c: bWork }] = await sql`select count(*)::int c + from ownership_contributor_build_work + where repository_id = ${t.repoId} + and (work_key = ${tieLose.key} or github_node_id = ${tieLose.node} + or display_login = ${tieLose.login})` + check(bWork === 0, 'B: rank-26 identity persisted in build staging') + const [{ c: bDurable }] = await sql`select count(*)::int c + from ownership_contributors + where repository_id = ${t.repoId} + and (contributor_key = ${tieLose.key} or github_node_id = ${tieLose.node} + or display_login = ${tieLose.login})` + check(bDurable === 0, 'B: rank-26 identity persisted in retained contributors') + // D: outside Top-25 in P1 but inside in P2 → union member → staged + check(stagedKeys.has(dKey.key), 'D: multi-month union key not staged') + // E: outside Top-25 in EVERY month → never stageable despite counters + check(!stagedKeys.has(oExtra.key), 'E: overflow-every-month key staged') + // F: pass-2-only key → ignored + check(!stagedKeys.has(fKey.key), 'F: pass-2-only key staged') + // 'unlinked' bucket is never staged as a linked identity either + check(!stagedKeys.has('unlinked'), 'unlinked bucket staged as identity') + // normal checkpoint semantics preserved — cursor advanced + const [{ cursor: repCur }] = await sql`select history_cursor #>> '{current,cursor}' as cursor + from ownership_sync_state where repository_id = ${t.repoId}` + check(repCur === 'r-done', `resolve cursor did not advance (${repCur})`) + await sql`delete from users where id = ${t.uid}` + } + + /* ── own_sweep_stale_work — orphans vs stale-build routing ─────────── */ + { + // orphans with building_version null → bare delete + const t = await tenant() + const s = await scope(t) + await membership(t, s, 1) + await workRow(t, s, 1, HEX(60)) + await stageRow(t, 1, HEX(60)) + await sync(t, 'done', 1, null) + const rO = await call('own_sweep_stale_work', t.uid, t.repoId) + check(rO === 'swept-orphans', `orphan sweep returned ${rO}`) + check(await count(t, 'ownership_history_work') === 0 && await count(t, 'ownership_contributor_build_work') === 0, + 'orphan rows survived sweep') + // stale live build → routed through canonical abandon + const s2 = await scope(t, 'h-sw2', 'lib') + const c2 = await contributor(t, HEX(61)) + await membership(t, s2, 2) + await snapshot(t, s2, c2, 2) + await workRow(t, s2, 2, HEX(61)) + // stale means NO activity inside the window — backdate the sync row + // AND the work/staging updated_at, or the sweep correctly refuses + await sql`update ownership_history_work set updated_at = now() - interval '20 days' + where repository_id = ${t.repoId}` + await sql`update ownership_contributor_build_work set updated_at = now() - interval '20 days' + where repository_id = ${t.repoId}` + await sql`update ownership_sync_state set building_version = 2, phase = 'history', + updated_at = now() - interval '20 days' + where repository_id = ${t.repoId}` + const rS = await call('own_sweep_stale_work', t.uid, t.repoId) + check(rS === 'abandoned', `stale-build sweep returned ${rS}`) + check(await count(t, 'ownership_snapshots', 2) === 0, 'swept build snapshots survived') + const [{ building_version: swB, active_version: swA }] = + await sql`select building_version, active_version from ownership_sync_state where repository_id = ${t.repoId}` + check(swA === 1 && swB === null, `sweep touched active state (${swA}/${swB})`) + await sql`delete from users where id = ${t.uid}` + + // active (non-stale) build → not swept + const tA = await tenant() + const sA = await scope(tA) + await membership(tA, sA, 1) + await workRow(tA, sA, 1, HEX(62)) + await sync(tA, 'history', null, 1) + const rA = await call('own_sweep_stale_work', tA.uid, tA.repoId) + check(rA === 'active', `fresh build sweep returned ${rA}`) + check(await count(tA, 'ownership_history_work') === 1, 'active build work swept') + await sql`delete from users where id = ${tA.uid}` + } + + /* ── work_key / privacy shape guards ───────────────────────────────── */ + { + const t = await tenant() + const s = await scope(t) + await membership(t, s, 1) + await mustFail(() => workRow(t, s, 1, 'not-hex'), 'work_key plaintext', + CHK('ownership_history_work_key')) + await mustFail(() => workRow(t, s, 1, 'other'), 'work_key aggregate bucket', + CHK('ownership_history_work_key')) + await mustFail(() => stageRow(t, 1, 'unlinked'), 'staging synthetic unlinked', + CHK('ownership_contributor_build_work_key')) + await mustFail(() => stageRow(t, 1, 'other'), 'staging synthetic other', + CHK('ownership_contributor_build_work_key')) + await mustFail(() => workRow(t, s, 0, HEX(70)), 'work version 0', + CHK('ownership_history_work_version')) + await mustFail(() => sql`insert into ownership_scope_versions + (user_id, repository_id, scope_id, calculation_version, excluded, exclusion_reason) + values (${t.uid}, ${t.repoId}, ${s}, 1, true, 'vendor')`, + 'membership vendor exclusion', CHK('ownership_scope_versions_exclusion')) + await sql`delete from users where id = ${t.uid}` + } + + return problems +} diff --git a/scripts/recovery-drill.mjs b/scripts/recovery-drill.mjs index eaf2dd5..c4f8b4d 100644 --- a/scripts/recovery-drill.mjs +++ b/scripts/recovery-drill.mjs @@ -22,6 +22,7 @@ import postgres from 'postgres' import { loadOperatorEnv, pgEnv, isLocalHost } from './operator-env.mjs' import { runPgTool } from './pg-tools.mjs' import { BACKUP_TABLES, EPHEMERAL_TABLES } from './backup-scope.mjs' +import { checkpointingProofs } from './ownership-checkpoint-proofs.mjs' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const BACKED_UP = BACKUP_TABLES @@ -102,7 +103,8 @@ async function seed(sql) { // ── ownership rows (synthetic fixtures — NOT production ingestion) ── // Covers every row shape: repo scope, dir scope, linked/unlinked/ // aggregate contributors, monthly + head contributor and scope-total - // snapshots, and sync state at active_version=1. + // snapshots, per-version scope membership, a mid-build V2 (building) + // version, ephemeral work rows, and sync state at active_version=1. const scopeRepo = crypto.randomUUID() const scopeDir = crypto.randomUUID() const cLinked = crypto.randomUUID() @@ -118,6 +120,28 @@ async function seed(sql) { values (${cUnlinked}, ${uid}, ${repoId}, 'unlinked', 'unlinked')` await sql`insert into ownership_contributors (id, user_id, repository_id, contributor_key, identity_kind) values (${cOther}, ${uid}, ${repoId}, 'other', 'aggregate')` + // version membership precedes snapshots — the composite FK requires it + await sql`insert into ownership_scope_versions (user_id, repository_id, scope_id, calculation_version) + values (${uid}, ${repoId}, ${scopeRepo}, 1)` + await sql`insert into ownership_scope_versions (user_id, repository_id, scope_id, calculation_version) + values (${uid}, ${repoId}, ${scopeDir}, 1)` + // building V2: committed scope snapshot + membership + ephemeral state — + // the backup carries the CORE rows only; restore leaves an abandoned- + // candidate building version whose canonical cleanup is proven below + await sql`insert into ownership_scope_versions (user_id, repository_id, scope_id, calculation_version) + values (${uid}, ${repoId}, ${scopeDir}, 2)` + await sql`insert into ownership_snapshots + (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, + touch_commits, touch_share, calculation_version) + values (${uid}, ${repoId}, ${scopeDir}, ${cLinked}, 'month', '2026-10-01T00:00:00Z', 4, 0.4, 2)` + await sql`insert into ownership_history_work + (user_id, repository_id, scope_id, calculation_version, period_start, work_key, is_bot, touch_commits) + values (${uid}, ${repoId}, ${scopeDir}, 2, '2026-10-01T00:00:00Z', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', false, 4)` + await sql`insert into ownership_contributor_build_work + (user_id, repository_id, calculation_version, work_key, github_node_id, display_login, is_bot) + values (${uid}, ${repoId}, 2, + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'MDQ6VXNlcjExMQ==', 'alice', false)` await sql`insert into ownership_snapshots (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, touch_commits, touch_share, calculation_version) @@ -135,8 +159,8 @@ async function seed(sql) { human_contributor_count, recent_human_maintainer_count, top_human_contributor_share, total_surviving_lines, unlinked_surviving_line_share, calculation_version) values (${uid}, ${repoId}, ${scopeDir}, null, 'head', '1970-01-01T00:00:00Z', 4, 2, 0.42, 1613, 0.15, 1)` - await sql`insert into ownership_sync_state (user_id, repository_id, phase, active_version) - values (${uid}, ${repoId}, 'done', 1)` + await sql`insert into ownership_sync_state (user_id, repository_id, phase, active_version, building_version) + values (${uid}, ${repoId}, 'done', 1, 2)` // Sentinel outside the allowlist — proves the dump cannot pick up // unrelated schemas/tables (Supabase internals, unclassified app tables). @@ -364,11 +388,14 @@ async function ownershipProofs(sql, { uid, repoId, scopeDir, cLinked, cOther }) await sql`update ownership_sync_state set error = 'OWNERSHIP_BLAME_FAILED' where user_id = ${uid} and repository_id = ${repoId}` await sql`update ownership_sync_state set error = null where user_id = ${uid} and repository_id = ${repoId}` - // ── version coexistence: v1 and v2 rows may coexist in storage + // ── version coexistence: v1 and v3 rows may coexist in storage + // (v3 used here so the seeded building-V2 state survives to the backup) + await sql`insert into ownership_scope_versions (user_id, repository_id, scope_id, calculation_version) + values (${uid}, ${repoId}, ${scopeDir}, 3)` await sql`insert into ownership_snapshots (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, touch_commits, touch_share, calculation_version) - values (${uid}, ${repoId}, ${scopeDir}, ${cLinked}, 'month', '2026-09-01T00:00:00Z', 99, 0.9, 2)` + values (${uid}, ${repoId}, ${scopeDir}, ${cLinked}, 'month', '2026-09-01T00:00:00Z', 99, 0.9, 3)` // RPC must still serve only active v1 (touch_commits 12, not 99) const tl = await sql`select touch_commits from dash_ownership_timeline(${uid}, ${repoId}, 'h-src-api') where display_login = 'alice'` @@ -382,7 +409,8 @@ async function ownershipProofs(sql, { uid, repoId, scopeDir, cLinked, cOther }) const scopes = await sql`select * from dash_ownership_scopes(${uid}, ${repoId})` check(scopes.length === 2 && scopes.some((s) => s.display_path === 'src/api' && Number(s.total_surviving_lines) === 1613), 'dash_ownership_scopes served wrong rows') - await sql`delete from ownership_snapshots where calculation_version = 2` + await sql`delete from ownership_snapshots where calculation_version = 3` + await sql`delete from ownership_scope_versions where repository_id = ${repoId} and calculation_version = 3` // ── bootstrap + promotion + rebuild lifecycle on a throwaway tenant const uidC = crypto.randomUUID() @@ -396,6 +424,8 @@ async function ownershipProofs(sql, { uid, repoId, scopeDir, cLinked, cOther }) values (${scopeC}, ${uidC}, ${repoC}, 'h-root', 'repo', 0)` await sql`insert into ownership_contributors (id, user_id, repository_id, contributor_key, identity_kind, github_node_id, display_login) values (${cC}, ${uidC}, ${repoC}, 'k-carol', 'linked', 'MDQ6VXNlcjY2Ng==', 'carol')` + await sql`insert into ownership_scope_versions (user_id, repository_id, scope_id, calculation_version) + values (${uidC}, ${repoC}, ${scopeC}, 1)` await sql`insert into ownership_snapshots (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, touch_commits, calculation_version) values (${uidC}, ${repoC}, ${scopeC}, ${cC}, 'month', '2026-09-01T00:00:00Z', 7, 1)` @@ -411,6 +441,8 @@ async function ownershipProofs(sql, { uid, repoId, scopeDir, cLinked, cOther }) check(v1rows.length === 1 && Number(v1rows[0].touch_commits) === 7, 'promoted V1 not visible') // RECALC: V2 rows coexist while V1 stays served await sql`update ownership_sync_state set building_version = 2 where user_id = ${uidC} and repository_id = ${repoC}` + await sql`insert into ownership_scope_versions (user_id, repository_id, scope_id, calculation_version) + values (${uidC}, ${repoC}, ${scopeC}, 2)` await sql`insert into ownership_snapshots (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, touch_commits, calculation_version) values (${uidC}, ${repoC}, ${scopeC}, ${cC}, 'month', '2026-09-01T00:00:00Z', 42, 2)` @@ -421,9 +453,12 @@ async function ownershipProofs(sql, { uid, repoId, scopeDir, cLinked, cOther }) where user_id = ${uidC} and repository_id = ${repoC}` const v2rows = await sql`select touch_commits from dash_ownership_timeline(${uidC}, ${repoC}, 'h-root')` check(v2rows.length === 1 && Number(v2rows[0].touch_commits) === 42, 'version flip did not change served results') - // repo delete cascades every ownership table (D) + // repo delete cascades every ownership table — single statement, all + // seven relations including retained membership and both work tables await sql`delete from repositories where id = ${repoC}` - for (const t of ['ownership_scopes', 'ownership_contributors', 'ownership_snapshots', 'ownership_sync_state']) { + for (const t of ['ownership_scopes', 'ownership_scope_versions', 'ownership_contributors', + 'ownership_snapshots', 'ownership_sync_state', 'ownership_history_work', + 'ownership_contributor_build_work']) { const [{ c }] = await sql`select count(*)::int c from ${sql(t)} where repository_id = ${repoC}` check(c === 0, `${t} rows survived repository delete (repo cascade broken)`) } @@ -444,13 +479,17 @@ async function ownershipProofs(sql, { uid, repoId, scopeDir, cLinked, cOther }) values (${scopeD}, ${uidD}, ${repoD}, 'h-d', 'repo', 0)` await sql`insert into ownership_contributors (id, user_id, repository_id, contributor_key, identity_kind, github_node_id, display_login) values (${cD}, ${uidD}, ${repoD}, 'k-dana', 'linked', 'MDQ6VXNlcjc3Nw==', 'dana')` + await sql`insert into ownership_scope_versions (user_id, repository_id, scope_id, calculation_version) + values (${uidD}, ${repoD}, ${scopeD}, 1)` await sql`insert into ownership_snapshots (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, touch_commits, calculation_version) values (${uidD}, ${repoD}, ${scopeD}, ${cD}, 'month', '2026-09-01T00:00:00Z', 4, 1)` await sql`insert into ownership_sync_state (user_id, repository_id, phase, active_version) values (${uidD}, ${repoD}, 'done', 1)` await sql`delete from users where id = ${uidD}` // no repo delete first - for (const t of ['ownership_scopes', 'ownership_contributors', 'ownership_snapshots', 'ownership_sync_state']) { + for (const t of ['ownership_scopes', 'ownership_scope_versions', 'ownership_contributors', + 'ownership_snapshots', 'ownership_sync_state', 'ownership_history_work', + 'ownership_contributor_build_work']) { const [{ c }] = await sql`select count(*)::int c from ${sql(t)} where user_id = ${uidD}` check(c === 0, `${t} rows survived direct user delete (user cascade broken)`) } @@ -480,6 +519,93 @@ function tocProblems(dumpPath, serverMajor, env) { return problems } +// ── 012→013 upgrade leg ──────────────────────────────────────────────── +// Runs against drill_upg, which has ONLY the chain through 012 applied. +// Seeds rows legal under 012 — crucially a snapshot with NO scope-version +// membership (the relation does not exist yet) — then applies the +// remaining migrations and proves the deployed-state upgrade contract: +// data preserved, membership backfilled, FK actions exact, new +// RLS/grants/functions present, teardown cascades intact. +async function upgradeProofs(upg, upgUrl) { + const problems = [] + const check = (ok, msg) => { if (!ok) problems.push(msg) } + + const uid = crypto.randomUUID() + const repoId = crypto.randomUUID() + const scopeId = crypto.randomUUID() + const contribId = crypto.randomUUID() + await upg`insert into users (id, github_user_id, github_login) values (${uid}, 47474747, 'drill-upgrade')` + await upg`insert into repositories (id, user_id, github_repo_id, owner_login, name, full_name) + values (${repoId}, ${uid}, 353535, 'drill-upgrade', 'repo-u', 'drill-upgrade/repo-u')` + await upg`insert into ownership_scopes (id, user_id, repository_id, scope_path_hash, display_path, scope_type, depth) + values (${scopeId}, ${uid}, ${repoId}, 'h-upg', 'pkg', 'dir', 1)` + await upg`insert into ownership_contributors (id, user_id, repository_id, contributor_key, identity_kind, github_node_id, display_login) + values (${contribId}, ${uid}, ${repoId}, 'k-upg', 'linked', 'MDQ6VXNlcjg4OA==', 'upguser')` + // legal under 012 — no membership row can exist + await upg`insert into ownership_snapshots + (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, + touch_commits, calculation_version) + values (${uid}, ${repoId}, ${scopeId}, ${contribId}, 'month', '2026-09-01T00:00:00Z', 6, 1)` + await upg`insert into ownership_sync_state (user_id, repository_id, phase, active_version) + values (${uid}, ${repoId}, 'done', 1)` + const [{ c: preSnap }] = await upg`select count(*)::int c from ownership_snapshots` + check(preSnap === 1, 'upgrade seed failed to create a 012-era snapshot') + + // apply the remainder of the chain (013+) + run(process.execPath, ['scripts/migrate.mjs'], { DATABASE_URL: upgUrl }) + + const [{ c: migC }] = await upg`select count(*)::int c from schema_migrations` + check(migC === MIGRATION_COUNT, `upgrade applied ${migC} migrations, expected ${MIGRATION_COUNT}`) + const [{ c: postSnap }] = await upg`select count(*)::int c from ownership_snapshots where repository_id = ${repoId}` + check(postSnap === 1, '012-era snapshot lost in upgrade') + const mem = await upg`select calculation_version, excluded from ownership_scope_versions + where scope_id = ${scopeId}` + check(mem.length === 1 && mem[0].calculation_version === 1 && mem[0].excluded === false, + `membership backfill wrong (${JSON.stringify(mem)})`) + + // exact FK actions — 'a' is NO ACTION in pg_constraint.confdeltype + const fks = await upg` + select conname, confdeltype from pg_constraint + where conname in ('ownership_snapshots_scope_repo', + 'ownership_snapshots_scope_version', + 'ownership_scope_versions_scope_repo')` + check(fks.length === 3, `expected 3 NO ACTION FKs, found ${JSON.stringify(fks)}`) + for (const f of fks) check(f.confdeltype === 'a', `${f.conname} delete action is ${f.confdeltype}, expected 'a'`) + + // new relations exist with RLS + service-role-only grants + for (const t of ['ownership_scope_versions', 'ownership_history_work', 'ownership_contributor_build_work']) { + const [{ c: rlsC }] = await upg` + select count(*)::int c from pg_class cl join pg_namespace n on n.oid = cl.relnamespace + where n.nspname = 'public' and cl.relname = ${t} and cl.relrowsecurity` + check(rlsC === 1, `${t} missing or RLS disabled after upgrade`) + } + const newFns = await upg` + select proname from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and proname like 'own\_%'` + const fnSet = new Set(newFns.map((f) => f.proname)) + for (const f of ['own_checkpoint_page', 'own_restart_scope', 'own_promote_build', + 'own_abandon_build', 'own_build_preflight', 'own_build_init', 'own_sweep_stale_work']) { + check(fnSet.has(f), `${f} missing after upgrade`) + } + + // updated RPC compiles and serves active-version membership + const scopes = await upg`select scope_path_hash, excluded from dash_ownership_scopes(${uid}, ${repoId})` + check(scopes.length === 1 && scopes[0].scope_path_hash === 'h-upg', + `upgraded dash_ownership_scopes served ${JSON.stringify(scopes)}`) + + // single-statement teardown still cascades the whole ownership graph + await upg`delete from repositories where id = ${repoId}` + for (const t of ['ownership_scopes', 'ownership_scope_versions', 'ownership_contributors', + 'ownership_snapshots', 'ownership_sync_state', 'ownership_history_work', + 'ownership_contributor_build_work']) { + const [{ c }] = await upg`select count(*)::int c from ${upg(t)} where repository_id = ${repoId}` + check(c === 0, `${t} rows survived repository delete post-upgrade`) + } + await upg`delete from users where id = ${uid}` + + return problems +} + async function verify(src, dst, ids, extraProblems = []) { const problems = [...extraProblems] const check = (ok, msg) => { if (!ok) problems.push(msg) } @@ -496,8 +622,10 @@ async function verify(src, dst, ids, extraProblems = []) { const [b] = await dst`select count(*)::int c from ${dst(t)}` check(a.c === b.c && a.c > 0, `${t}: src ${a.c} rows, dst ${b.c} (expected equal, nonzero)`) } - // ephemeral tables exist but start empty - for (const t of ['auth_sessions', 'webhook_deliveries', 'security_events']) { + // ephemeral tables exist but start empty — includes both ownership + // work relations, which never enter the backup + for (const t of ['auth_sessions', 'webhook_deliveries', 'security_events', + 'ownership_history_work', 'ownership_contributor_build_work']) { const [b] = await dst`select count(*)::int c from ${dst(t)}` check(b.c === 0, `${t}: expected empty after restore, found ${b.c}`) } @@ -549,10 +677,39 @@ async function verify(src, dst, ids, extraProblems = []) { where table_schema = 'public' and table_name = 'ownership_scopes' and grantee = 'service_role'` check(sro.some((g) => g.privilege_type === 'SELECT') && sro.some((g) => g.privilege_type === 'INSERT'), 'service_role missing SELECT/INSERT on ownership_scopes') - // restored ownership RPCs still enforce the serve gate + // restored ownership RPCs still enforce the serve gate — building-V2 + // membership is invisible under active V1 const [orow] = await dst`select count(*)::int c from dash_ownership_scopes(${ids.uid}, ${ids.repoId})` check(orow.c === 2, `restored dash_ownership_scopes returned ${orow.c}, expected 2`) + // The seeded building V2 survives restore as an abandonment candidate + // (its EPHEMERAL work rows do not — the backup omits them). Canonical + // abandon must cleanly remove the V2 CORE rows without touching V1. + { + const [{ building_version }] = + await dst`select building_version from ownership_sync_state + where user_id = ${ids.uid} and repository_id = ${ids.repoId}` + check(building_version === 2, `restored building_version expected 2, got ${building_version}`) + const [r] = await dst`select own_abandon_build(${ids.uid}, ${ids.repoId}, 2) as r` + check(r.r === 'abandoned', `restored-build abandon returned ${r.r}`) + const [{ c: v2snap }] = await dst`select count(*)::int c from ownership_snapshots + where repository_id = ${ids.repoId} and calculation_version = 2` + const [{ c: v2mem }] = await dst`select count(*)::int c from ownership_scope_versions + where repository_id = ${ids.repoId} and calculation_version = 2` + const [{ c: v1snap }] = await dst`select count(*)::int c from ownership_snapshots + where repository_id = ${ids.repoId} and calculation_version = 1` + check(v2snap === 0 && v2mem === 0, 'restored building version not cleaned by abandon') + check(v1snap === 4, `abandon touched active V1 data (${v1snap} snapshots)`) + const [{ active_version, building_version: bAfter, phase }] = + await dst`select active_version, building_version, phase from ownership_sync_state + where user_id = ${ids.uid} and repository_id = ${ids.repoId}` + check(active_version === 1 && bAfter === null && phase === 'done', + `post-abandon sync state wrong (active=${active_version}, building=${bAfter}, phase=${phase})`) + // active dashboards still serve V1 only after the cleanup + const [orow2] = await dst`select count(*)::int c from dash_ownership_scopes(${ids.uid}, ${ids.repoId})` + check(orow2.c === 2, `dash_ownership_scopes changed after abandon (${orow2.c} rows)`) + } + // Behavioral proof, not just grant inspection: switch into each role the // way Supabase's authenticator does and actually read an app table. for (const role of ['anon', 'authenticated']) { @@ -625,7 +782,7 @@ async function main() { const [{ exists: srExists }] = await sqlAdmin`select exists(select from pg_roles where rolname = 'service_role')` if (!srExists) await sqlAdmin.unsafe('create role service_role nologin bypassrls') - for (const db of ['drill_src', 'drill_dst']) { + for (const db of ['drill_src', 'drill_dst', 'drill_upg']) { await sqlAdmin`drop database if exists ${sqlAdmin.unsafe(db)} with (force)` await sqlAdmin`create database ${sqlAdmin.unsafe(db)}` } @@ -635,6 +792,7 @@ async function main() { const srcUrl = dbAt(admin, 'drill_src') const dstUrl = dbAt(admin, 'drill_dst') + const upgUrl = dbAt(admin, 'drill_upg') try { console.log('drill: applying migrations to drill_src') @@ -643,14 +801,21 @@ async function main() { const seeded = await seed(src) Object.assign(ids, seeded) const oProblems = await ownershipProofs(src, seeded) + const cProblems = oProblems.length ? [] : await checkpointingProofs(src) await src.end() - if (oProblems.length) { - for (const p of oProblems) console.error(` FAIL ${p}`) - fail(`${oProblems.length} ownership schema proof(s) failed`) + const schemaProblems = [...oProblems, ...cProblems] + if (schemaProblems.length) { + for (const p of schemaProblems) console.error(` FAIL ${p}`) + fail(`${schemaProblems.length} ownership schema proof(s) failed`) } console.log('drill: seeded synthetic rows (user, repo, commit, PR, sync state, ownership, ephemeral rows)') console.log('drill: ownership proofs passed — tenant FKs, shape checks, closed error taxonomy,') console.log(' identity kinds, version serve-gate (build/promote/coexist/flip), cascades') + console.log('drill: checkpointing proofs passed — membership/snapshot composite FK (A–F), NO ACTION') + console.log(' scope protection (G–I), single-statement teardown (J–L), active-version dashboard') + console.log(' isolation (M–O), canonical abandon (P–Q), restart staging (R–T), exact-set') + console.log(' promotion (U–W), retired-version lifecycle gate (X–AA), checkpoint CAS/replay,') + console.log(' stale/orphan sweep, work-key domain and privacy shape guards') // same backup path the operator uses const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dl-drill-')) @@ -689,12 +854,31 @@ async function main() { console.log('drill: PASS — synthetic backup → migrate → restore round-trip verified') console.log(' core/operational rows preserved, FKs intact, uuids stable') console.log(' RLS enabled, anon/authenticated denied, service_role scoped') - console.log(' auth_sessions/webhook_deliveries/security_events start empty') + console.log(' auth_sessions/webhook_deliveries/security_events + ownership work tables start empty') console.log(' privacy column (pull_requests.title) absent, sequences live') + + // ── upgrade leg: apply the deployed chain up to 012, seed + // 012-era ownership rows (legal without membership), then run the + // remaining migrations — proves the real 012→013 upgrade path + console.log('drill: upgrade leg — apply chain through 012, seed legacy rows, migrate') + run(process.execPath, ['scripts/migrate.mjs'], + { DATABASE_URL: upgUrl, MIGRATE_UP_TO: '012' }) + const upg = postgres(upgUrl, { onnotice: () => {} }) + try { + const uProblems = await upgradeProofs(upg, upgUrl) + if (uProblems.length) { + for (const p of uProblems) console.error(` FAIL ${p}`) + fail(`${uProblems.length} upgrade proof(s) failed`) + } + } finally { + await upg.end() + } + console.log('drill: upgrade leg PASS — 012-era data preserved, membership backfilled,') + console.log(' NO ACTION FK actions applied, new RLS/grants/functions live') } finally { const cleanup = postgres(admin, { onnotice: () => {} }) try { - for (const db of ['drill_src', 'drill_dst']) { + for (const db of ['drill_src', 'drill_dst', 'drill_upg']) { await cleanup`drop database if exists ${cleanup.unsafe(db)} with (force)` } } finally { diff --git a/test/backup-schema.test.mjs b/test/backup-schema.test.mjs index bc301a0..45df8c0 100644 --- a/test/backup-schema.test.mjs +++ b/test/backup-schema.test.mjs @@ -1,10 +1,10 @@ // Schema-aware backup resolution — unit tests for // scripts/backup-scope.mjs resolveBackupTables(). Covers the transition -// window where repository main has migration 012 but a target database has +// window where repository main has migration 013 but a target database has // not applied it yet: the backup must request only the tables the applied // schema actually created, and fail closed on any broken/inconsistent // schema state. The live end-to-end path (real Postgres) is exercised by -// scripts/recovery-drill.mjs, whose target is post-012 and therefore backs +// scripts/recovery-drill.mjs, whose target is post-013 and therefore backs // up all allowlisted tables. import { test } from 'node:test' @@ -41,16 +41,20 @@ const expected = (applied) => { return out } const allCreated = expected(files) -const pre012Files = files.filter((f) => !f.startsWith('012')) +const pre012Files = files.filter((f) => f < '012') const pre012Created = expected(pre012Files) -const OWNERSHIP = ['ownership_scopes', 'ownership_contributors', 'ownership_snapshots', 'ownership_sync_state'] +const OWNERSHIP = ['ownership_scopes', 'ownership_contributors', 'ownership_snapshots', 'ownership_sync_state', 'ownership_scope_versions'] +const WORK = ['ownership_history_work', 'ownership_contributor_build_work'] /* ── mapping sanity ──────────────────────────────────────────────────── */ -test('migrationTables maps 001..012 to their created/dropped tables', () => { - assert.ok(files.length >= 12, `expected >=12 migration files, got ${files.length}`) - assert.deepEqual(files.at(-1), '012_ownership.sql') - for (const t of OWNERSHIP) assert.ok(byMig.get('012_ownership.sql').created.has(t), `012 must create ${t}`) +test('migrationTables maps 001..013 to their created/dropped tables', () => { + assert.ok(files.length >= 13, `expected >=13 migration files, got ${files.length}`) + assert.deepEqual(files.at(-1), '013_ownership_checkpointing.sql') + for (const t of OWNERSHIP.slice(0, 4)) assert.ok(byMig.get('012_ownership.sql').created.has(t), `012 must create ${t}`) + for (const t of [...WORK, 'ownership_scope_versions']) { + assert.ok(byMig.get('013_ownership_checkpointing.sql').created.has(t), `013 must create ${t}`) + } assert.ok(byMig.get('001_initial.sql').created.has('users')) assert.ok(byMig.get('001_initial.sql').created.has('repositories')) // 001-era tables dropped by 002 must not remain expected @@ -66,30 +70,34 @@ test('PRE-012 target: resolves the 10 pre-012 tables, excludes ownership + unkno const existing = new Set([...pre012Created, 'drill_internal', 'pg_stat_statements_info']) const resolved = resolveBackupTables({ applied: pre012Files, existing }) assert.equal(resolved.length, BACKUP_TABLES.length - OWNERSHIP.length) - for (const t of OWNERSHIP) assert.ok(!resolved.includes(t), `${t} must not be requested pre-012`) + for (const t of [...OWNERSHIP, ...WORK]) assert.ok(!resolved.includes(t), `${t} must not be requested pre-012`) assert.ok(resolved.includes('commits') && resolved.includes('users'), 'old core tables must be included') assert.ok(!resolved.includes('drill_internal'), 'unknown table must never be requested') assert.deepEqual(resolved, BACKUP_TABLES.filter((t) => !OWNERSHIP.includes(t)), 'resolution must preserve allowlist ordering') }) -/* ── B. post-012 target ──────────────────────────────────────────────── */ +/* ── B. post-013 target ──────────────────────────────────────────────── */ -test('POST-012 target: all 14 allowlisted tables resolve', () => { +test('POST-013 target: all 15 allowlisted tables resolve, work tables excluded', () => { const resolved = resolveBackupTables({ applied: files, existing: allCreated }) assert.deepEqual(resolved, BACKUP_TABLES) - assert.equal(resolved.length, 14) + assert.equal(resolved.length, 15) + assert.ok(resolved.includes('ownership_scope_versions'), 'CORE membership must resolve') + for (const t of WORK) assert.ok(!resolved.includes(t), `ephemeral ${t} must never be backed up`) }) -/* ── C. broken post-012 target fails closed ──────────────────────────── */ +/* ── C. broken post-013 target fails closed ──────────────────────────── */ -test('BROKEN post-012: recorded 012 + missing ownership table fails closed', () => { - for (const t of OWNERSHIP) { +test('BROKEN post-013: recorded 013 + missing migration-created table fails closed', () => { + // every migration-created table — including the EPHEMERAL work tables, + // which must exist even though they are never backed up + for (const t of [...OWNERSHIP, ...WORK]) { const existing = new Set([...allCreated].filter((x) => x !== t)) assert.throws( () => resolveBackupTables({ applied: files, existing }), new RegExp(t), - `missing ${t} with 012 applied must fail`, + `missing ${t} with 013 applied must fail`, ) } }) @@ -198,10 +206,10 @@ test('the exact production transition: pre-012 then post-012', () => { const pre = resolveBackupTables({ applied: pre012Files, existing: pre012Created }) assert.deepEqual(pre, BACKUP_TABLES.filter((t) => !OWNERSHIP.includes(t))) assert.equal(pre.length, 10) - // after 012 is applied, all 14 resolve + // after 012+013 are applied, all 15 resolve assert.equal(validateAppliedMigrationPrefix(files), true) const post = resolveBackupTables({ applied: files, existing: allCreated }) - assert.equal(post.length, 14) + assert.equal(post.length, 15) }) /* ── integration surface ─────────────────────────────────────────────── */ diff --git a/test/data-minimization.test.mjs b/test/data-minimization.test.mjs index 81babd6..5829b80 100644 --- a/test/data-minimization.test.mjs +++ b/test/data-minimization.test.mjs @@ -365,6 +365,16 @@ test('every user-linked table cascades from users in the migrations', () => { `${tbl} must tenant-cascade through repositories(id, user_id)`, ) } + // 013 checkpointing tables — retained membership + both ephemeral work + // relations — teardown still cascades through the same tenant FK + const checkpointing = src('migrations/013_ownership_checkpointing.sql') + for (const tbl of ['ownership_scope_versions', 'ownership_history_work', 'ownership_contributor_build_work']) { + assert.match( + checkpointing, + new RegExp(`create table if not exists public\\.${tbl}[\\s\\S]*?foreign key \\(repository_id, user_id\\)[\\s\\S]*?references public\\.repositories \\(id, user_id\\)[\\s\\S]*?on delete cascade`, 'i'), + `${tbl} must tenant-cascade through repositories(id, user_id)`, + ) + } }) /* ── WS7: backup scope stays allowlist-only ─────────────────────────────── */ diff --git a/test/ownership-checkpoint-schema.test.mjs b/test/ownership-checkpoint-schema.test.mjs new file mode 100644 index 0000000..b3c589f --- /dev/null +++ b/test/ownership-checkpoint-schema.test.mjs @@ -0,0 +1,292 @@ +// Phase-1C-B1 ownership checkpointing — structural guards for +// migrations/013_ownership_checkpointing.sql. Real-Postgres behavioral +// proofs (A–AA integrity matrix, checkpoint CAS, lifecycle gate, upgrade +// leg) live in scripts/ownership-checkpoint-proofs.mjs, invoked by +// scripts/recovery-drill.mjs under `npm run db:drill`. + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync, readdirSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const src = (rel) => readFileSync(path.join(ROOT, rel), 'utf8') +const MIG = src('migrations/013_ownership_checkpointing.sql') + +const NEW_TABLES = ['ownership_scope_versions', 'ownership_history_work', 'ownership_contributor_build_work'] + +function tableSql(name) { + const i = MIG.indexOf(`create table if not exists public.${name}`) + assert.notEqual(i, -1, `${name} is not created in 013`) + const end = MIG.indexOf('\n);', i) + return MIG.slice(i, end) +} + +/* ── forward-only, additive relative to 012 ──────────────────────────── */ + +test('013 is the latest migration; 012 untouched by the file', () => { + const files = readdirSync(path.join(ROOT, 'migrations')).filter((f) => f.endsWith('.sql')).sort() + assert.equal(files[files.length - 1], '013_ownership_checkpointing.sql') + assert.ok(files.includes('012_ownership.sql')) + // no drops/truncates — only the sanctioned constraint replacement on + // ownership_snapshots (drop constraint + re-add, same name/keys) + assert.doesNotMatch(MIG, /drop\s+table|truncate|drop\s+column|alter\s+.*\brename\b/i) + const drops = [...MIG.matchAll(/drop\s+constraint\s+(\w+)/gi)].map((m) => m[1]) + assert.deepEqual(drops, ['ownership_snapshots_scope_repo'], + `only the scope_repo FK may be dropped/re-added, got ${drops}`) +}) + +/* ── ownership_scope_versions — RETAINED membership ──────────────────── */ + +test('scope_versions grain, tenant pair, and NO ACTION scope FK', () => { + const t = tableSql('ownership_scope_versions') + for (const col of ['scope_id', 'user_id', 'repository_id', 'calculation_version', + 'excluded', 'exclusion_reason', 'created_at', 'updated_at']) { + assert.match(t, new RegExp(`\\b${col}`), `missing ${col}`) + } + assert.match(t, /unique \(scope_id, calculation_version\)/i, 'missing (scope,version) membership grain') + assert.match(t, /unique \(scope_id, repository_id, user_id, calculation_version\)/i, + 'missing supporting composite unique for the snapshot FK') + assert.match(t, /foreign key \(repository_id, user_id\)[\s\S]*?references public\.repositories \(id, user_id\)[\s\S]*?on delete cascade/i, + 'tenant pair must cascade (teardown path)') + assert.match(t, /foreign key \(scope_id, repository_id\)[\s\S]*?references public\.ownership_scopes \(id, repository_id\)[\s\S]*?on delete no action/i, + 'retained child->scope FK must be exactly NO ACTION') + assert.match(t, /calculation_version > 0/i) + // the only exclusion_reason allowed is the cardinality cap + assert.match(t, /exclusion_reason = 'cardinality_cap'/i) + assert.doesNotMatch(t, /display_path|scope_path|path_text/i, 'no path inventory columns') +}) + +/* ── ownership_history_work — EPHEMERAL counters ─────────────────────── */ + +test('history_work carries counters only — no plaintext identity, no paths', () => { + const t = tableSql('ownership_history_work') + for (const col of ['user_id', 'repository_id', 'scope_id', 'calculation_version', + 'period_start', 'work_key', 'is_bot', 'touch_commits', 'first_seen_at', + 'last_seen_at', 'created_at', 'updated_at']) { + assert.match(t, new RegExp(`\\b${col}`), `missing ${col}`) + } + for (const bad of ['github_node_id', 'display_login', 'email', 'author_name', + 'commit_message', 'patch', 'diff', 'source', 'path', 'payload', 'database_id']) { + assert.doesNotMatch(t, new RegExp(`\\b${bad}\\b`, 'i'), `forbidden field ${bad} in history_work`) + } + assert.match(t, /work_key = 'unlinked' or work_key ~ '\^\[0-9a-f\]\{64\}\$'/i, + 'work_key domain must be literal unlinked or 64-hex') + assert.match(t, /unique \(scope_id, calculation_version, period_start, work_key\)/i, + 'missing scope×version×month×key grain') + assert.match(t, /foreign key \(repository_id, user_id\)[\s\S]*?on delete cascade/i, 'tenant cascade missing') + assert.match(t, /foreign key \(scope_id, repository_id\)[\s\S]*?on delete cascade/i, 'scope cascade missing') + assert.match(t, /calculation_version > 0/i) + // month grain mirrors the snapshot period rule — explicit UTC wall clock + assert.match(t, /date_trunc\('month', period_start at time zone 'UTC'\)\s*at time zone 'UTC'/i) +}) + +/* ── ownership_contributor_build_work — EPHEMERAL staging ────────────── */ + +test('build_work stages represented keys only — no synthetics, no provenance column', () => { + const t = tableSql('ownership_contributor_build_work') + for (const col of ['user_id', 'repository_id', 'calculation_version', 'work_key', + 'github_node_id', 'display_login', 'is_bot', 'first_seen_at', 'last_seen_at']) { + assert.match(t, new RegExp(`\\b${col}`), `missing ${col}`) + } + assert.match(t, /unique \(repository_id, calculation_version, work_key\)/i, 'missing build grain') + assert.match(t, /work_key ~ '\^\[0-9a-f\]\{64\}\$'/i, 'staging must refuse synthetic bucket keys') + assert.doesNotMatch(t, /scope_id/i, 'no scope provenance column (§5.3 uses committed-V provenance)') + for (const bad of ['email', 'author_name', 'database_id', 'avatar', 'path', 'payload']) { + assert.doesNotMatch(t, new RegExp(`\\b${bad}\\b`, 'i'), `forbidden field ${bad} in build_work`) + } +}) + +/* ── snapshot FK actions — exact NO ACTION ───────────────────────────── */ + +test('snapshot->scope FK re-created NO ACTION; snapshot->membership composite FK NO ACTION', () => { + // the deployed constraint is dropped and re-added under the SAME name + // and keys with the delete action changed — not left as CASCADE + assert.match(MIG, /drop constraint ownership_snapshots_scope_repo[\s\S]*?add constraint ownership_snapshots_scope_repo[\s\S]*?foreign key \(scope_id, repository_id\)[\s\S]*?references public\.ownership_scopes \(id, repository_id\)[\s\S]*?on delete no action/i, + 'ownership_snapshots_scope_repo must be re-created with exact NO ACTION') + assert.match(MIG, /add constraint ownership_snapshots_scope_version[\s\S]*?foreign key \(scope_id, repository_id, user_id, calculation_version\)[\s\S]*?references public\.ownership_scope_versions[\s\S]*?\(scope_id, repository_id, user_id, calculation_version\)[\s\S]*?on delete no action/i, + 'snapshot->membership composite FK must be NO ACTION over all four columns') + // no surviving CASCADE on the snapshot->scope relation + const scopeRepo = MIG.match(/add constraint ownership_snapshots_scope_repo[\s\S]*?on delete (\w+( \w+)?)/i) + assert.ok(scopeRepo && !/cascade/i.test(scopeRepo[1]), 'scope_repo FK must not remain CASCADE') +}) + +/* ── backfill exists before the composite FK validates ───────────────── */ + +test('012-era membership backfill runs inside 013 before the FK is added', () => { + const backfill = MIG.indexOf('insert into public.ownership_scope_versions') + const fkAdd = MIG.indexOf('add constraint ownership_snapshots_scope_version') + assert.ok(backfill !== -1 && fkAdd !== -1 && backfill < fkAdd, + 'membership backfill must precede the composite FK validation') +}) + +/* ── RLS + grants — same model as 012 ────────────────────────────────── */ + +test('RLS enabled, zero browser policies, service_role-only DML on all new tables', () => { + for (const t of NEW_TABLES) { + assert.match(MIG, new RegExp(`alter table public\\.${t}\\s+enable row level security`, 'i')) + assert.match(MIG, new RegExp(`revoke all on public\\.${t}\\s+from public, anon, authenticated`, 'i')) + assert.match(MIG, new RegExp(`grant select, insert, update, delete on public\\.${t}\\s+to service_role`, 'i')) + } + assert.doesNotMatch(MIG, /create\s+policy/i, 'no RLS policies may be created') +}) + +/* ── database primitives — service-role-only SECURITY INVOKER ────────── */ + +test('all own_* primitives exist, are SECURITY INVOKER, service_role-only', () => { + const fns = ['own_checkpoint_page', 'own_restart_scope', 'own_promote_build', + 'own_abandon_build', 'own_build_preflight', 'own_build_init', 'own_sweep_stale_work'] + for (const fn of fns) { + const i = MIG.indexOf(`function public.${fn}(`) + assert.notEqual(i, -1, `${fn} missing`) + const j = MIG.indexOf('$$;', i) + const body = MIG.slice(i, j) + assert.match(body, /security invoker/i, `${fn} must be SECURITY INVOKER`) + assert.match(body, /set search_path = public/i, `${fn} must pin search_path`) + assert.match(MIG, new RegExp(`revoke all on function public\\.${fn}\\([^)]*\\)\\s+from public, anon, authenticated`, 'i'), + `${fn} missing revoke`) + assert.match(MIG, new RegExp(`grant execute on function public\\.${fn}\\([^)]*\\)\\s+to service_role`, 'i'), + `${fn} missing service_role grant`) + } + assert.doesNotMatch(MIG, /security definer/i, 'no function may be SECURITY DEFINER') +}) + +/* ── closed error taxonomy — no provider text leaks ──────────────────── */ + +test('all function raises use the closed OWNERSHIP_INTERNAL_ERROR taxonomy', () => { + const raises = [...MIG.matchAll(/raise exception\s+('[^']*')/g)].map((m) => m[1]) + assert.ok(raises.length > 10, 'expected many fail-closed raise sites') + for (const r of raises) assert.equal(r, `'OWNERSHIP_INTERNAL_ERROR'`, `non-canonical error ${r}`) +}) + +/* ── dash_ownership_scopes — active-version membership ───────────────── */ + +test('dash_ownership_scopes enumerates scopes via scope_versions at active_version', () => { + const i = MIG.indexOf('function public.dash_ownership_scopes(') + assert.notEqual(i, -1) + const body = MIG.slice(i, MIG.indexOf('$$;', i)) + assert.match(body, /join public\.ownership_scope_versions sv/i, 'must join version membership') + assert.match(body, /sv\.calculation_version = st\.active_version/i, 'must gate at active_version') + assert.match(body, /sv\.excluded/i, 'exclusion state must come from version membership') + assert.match(body, /security invoker/i) + assert.doesNotMatch(body, /sc\.excluded/, 'legacy base-scope excluded must not be authoritative') + assert.match(MIG, /revoke all on function public\.dash_ownership_scopes\(uuid, uuid\)/i) + assert.match(MIG, /grant execute on function public\.dash_ownership_scopes\(uuid, uuid\) to service_role/i) +}) + +/* ── checkpoint primitive semantics — structural assertions ──────────── */ + +test('own_checkpoint_page: CAS envelope returns stale, single-tx cursor advance', () => { + const i = MIG.indexOf('function public.own_checkpoint_page(') + const body = MIG.slice(i, MIG.indexOf('$$;', i)) + assert.match(body, /for update/i, 'must take the sync-state row lock') + assert.match(body, /is distinct from p_version/i, 'CAS must compare building_version') + assert.match(body, /current,cursor/i, 'CAS must compare the cursor') + assert.match(body, /return 'stale'/i, 'mismatch must return stale, not raise') + assert.match(body, /on conflict \(scope_id, calculation_version, period_start, work_key\)/i, + 'accumulate must be an additive upsert at the work grain') + assert.match(body, /jsonb_set\(st\.history_cursor, '\{current\}'/i, + 'cursor advance must commit in the same transaction') + assert.match(body, /least\(public\.ownership_history_work\.first_seen_at/i) + assert.match(body, /greatest\(public\.ownership_history_work\.last_seen_at/i) +}) + +// §6.4 durable-identity minimization, enforced in the database itself: +// existence in ownership_history_work is NOT sufficient authorization to +// persist contributor identity — only membership in the deterministic +// per-period Top-25 union (touch_commits DESC, work_key ASC, 'unlinked' +// excluded) may stage github_node_id/display_login. +test('own_checkpoint_page: resolve gates staging on the represented Top-25 union', () => { + const i = MIG.indexOf('function public.own_checkpoint_page(') + const body = MIG.slice(i, MIG.indexOf('$$;', i)) + const resolve = body.slice(body.indexOf("p_pass = 'resolve'")) + // the represented set is computed from THIS scope's frozen pass-1 + // counters with the reducer's ranking semantics + assert.match(resolve, /row_number\(\) over \(\s*partition by period_start\s+order by touch_commits desc, work_key asc\s*\)/i, + 'represented set must rank per period by touch_commits DESC, work_key ASC') + assert.match(resolve, /rnk <= 25/i, 'only ranks 1..25 are represented (TOP_LINKED_PER_PERIOD)') + assert.match(resolve, /work_key <> 'unlinked'/i, 'unlinked bucket must not rank as linked') + assert.match(resolve, /scope_id = p_scope and calculation_version = p_version/i, + 'represented set is scoped to the current scope+version counters') + // the staging insert must be gated on set membership — counter + // existence alone never authorizes identity persistence + const insert = resolve.indexOf('insert into public.ownership_contributor_build_work') + assert.notEqual(insert, -1, 'resolve staging insert missing') + const insertTail = resolve.slice(insert) + assert.match(insertTail, /where i->>'work_key' = any\(v_represented\)/i, + 'staging insert must require represented-set membership') + // pass-2 identity checks apply only to represented keys — an overflow + // observation is ignored, never validated or persisted into state + const checks = resolve.slice(0, insert) + const checkCount = (checks.match(/= any\(v_represented\)/g) || []).length + assert.ok(checkCount >= 4, + `all resolve-time checks must restrict to represented keys (${checkCount}/4 found)`) + // the reducer constant must appear verbatim so drift is visible + const reducer = src('lib/ownership/history-reducer.mjs') + assert.match(reducer, /TOP_LINKED_PER_PERIOD = 25/, 'reducer Top-25 constant missing') +}) + +test('abandon deletes in the NO ACTION order and preserves active_version', () => { + const i = MIG.indexOf('function public.own_abandon_build(') + const body = MIG.slice(i, MIG.indexOf('$$;', i)) + const snapDel = body.indexOf('delete from public.ownership_snapshots') + const memDel = body.indexOf('delete from public.ownership_scope_versions') + const workDel = body.indexOf('delete from public.ownership_history_work') + const stageDel = body.indexOf('delete from public.ownership_contributor_build_work') + assert.ok(snapDel !== -1 && memDel !== -1 && workDel !== -1 && stageDel !== -1) + assert.ok(snapDel < memDel, 'snapshots must be deleted before membership (NO ACTION order)') + const flip = body.indexOf('building_version = null') + assert.ok(flip !== -1, 'must clear building_version') + assert.doesNotMatch(body, /active_version\s*=/, 'abandon must never write active_version') +}) + +test('preflight GC order + init gate: snapshots -> membership -> prune, verify-empty', () => { + const i = MIG.indexOf('function public.own_build_preflight(') + const body = MIG.slice(i, MIG.indexOf('$$;', i)) + const snapDel = body.indexOf('delete from public.ownership_snapshots') + const memDel = body.indexOf('delete from public.ownership_scope_versions') + assert.ok(snapDel !== -1 && memDel !== -1 && snapDel < memDel, 'GC must delete snapshots before membership') + assert.match(body, /<> st\.active_version/i, 'GC must target retired (non-active) versions') + const j = MIG.indexOf('function public.own_build_init(') + const init = MIG.slice(j, MIG.indexOf('$$;', j)) + assert.match(init, /return 'blocked'/i, 'init must refuse when retired rows remain') + assert.match(init, /building_version = p_new_version/i, 'init sets the building version only after the gate') +}) + +test('promotion validates the exact-set invariant before applying metadata', () => { + const i = MIG.indexOf('function public.own_promote_build(') + const body = MIG.slice(i, MIG.indexOf('$$;', i)) + const extra = body.indexOf('a staged key no committed V snapshot represents') + const missing = body.indexOf('no staged build-work row') + const apply = body.indexOf('update public.ownership_contributors') + const flip = body.indexOf('set active_version = p_version') + assert.ok(extra !== -1 && missing !== -1, 'both directions of the exact-set check required') + assert.ok(extra < apply && missing < apply, 'metadata application must come after exact-set validation') + assert.ok(apply < flip, 'staged metadata applies before the version flip, same transaction') + assert.match(body, /active_version = p_version/i) +}) + +test('restart preserves staging referenced by committed-V snapshots only', () => { + const i = MIG.indexOf('function public.own_restart_scope(') + const body = MIG.slice(i, MIG.indexOf('$$;', i)) + assert.match(body, /delete from public\.ownership_history_work/i) + assert.match(body, /delete from public\.ownership_contributor_build_work/i) + assert.match(body, /ownership_snapshots/i, 'provenance is committed-V snapshots, not a scope column') + assert.doesNotMatch(body, /delete from public\.ownership_contributors\b/i, + 'restart must never delete retained contributor rows') + assert.doesNotMatch(body, /delete from public\.ownership_scopes\b/i, + 'restart must never delete base scopes') +}) + +/* ── backup classification is consistent with the migration ──────────── */ + +test('backup-scope.mjs classification: scope_versions CORE, work tables EPHEMERAL', async () => { + const { CORE_TABLES, EPHEMERAL_TABLES, BACKUP_TABLES } = await import('../scripts/backup-scope.mjs') + assert.ok(CORE_TABLES.includes('ownership_scope_versions'), 'membership must be CORE') + assert.ok(BACKUP_TABLES.includes('ownership_scope_versions')) + for (const t of ['ownership_history_work', 'ownership_contributor_build_work']) { + assert.ok(EPHEMERAL_TABLES.includes(t), `${t} must be EPHEMERAL`) + assert.ok(!BACKUP_TABLES.includes(t), `${t} must not be backed up`) + } +}) diff --git a/test/ownership-schema.test.mjs b/test/ownership-schema.test.mjs index d61ff35..50f6b7b 100644 --- a/test/ownership-schema.test.mjs +++ b/test/ownership-schema.test.mjs @@ -34,9 +34,10 @@ test('012 is purely additive — no drops, truncates, or column rewrites', () => const alters = [...MIG.matchAll(/alter\s+table\s+public\.(\w+)/gi)].map((m) => m[1]) for (const t of alters) assert.ok(TABLES.includes(t), `unexpected ALTER on ${t}`) assert.match(MIG, /create unique index if not exists repositories_id_user_key\s+on public\.repositories \(id, user_id\)/) - // forward-only file numbering + // forward-only file numbering — 013 extends 012, never edits it const files = readdirSync(path.join(ROOT, 'migrations')).filter((f) => f.endsWith('.sql')).sort() - assert.equal(files[files.length - 1], '012_ownership.sql', '012 must be the latest migration') + assert.equal(files[files.length - 1], '013_ownership_checkpointing.sql', '013 must be the latest migration') + assert.ok(files.includes('012_ownership.sql')) }) /* ── tables, columns, tenant FKs ─────────────────────────────────────── */ diff --git a/test/recovery.test.mjs b/test/recovery.test.mjs index ca3443a..2095ac5 100644 --- a/test/recovery.test.mjs +++ b/test/recovery.test.mjs @@ -73,12 +73,16 @@ test('backup is allowlist-only — explicit --table per resolved table, no exclu assert.match(b, /resolved\.map\(.*--table=public\./s, 'must emit --table=public. per resolved allowlisted table'); assert.match(b, /resolved\.length === 0/, 'must refuse to dump when nothing allowlisted exists'); assert.doesNotMatch(b, /--exclude-table/, 'exclusion-list model silently backs up unclassified tables'); - assert.equal(BACKUP_TABLES.length, 14); + assert.equal(BACKUP_TABLES.length, 15); for (const t of ['users', 'github_installations', 'repositories', 'repository_languages', 'commits', 'pull_requests', 'repo_coverage', 'repo_sync', 'user_sync', 'feedback', - 'ownership_scopes', 'ownership_contributors', 'ownership_snapshots', 'ownership_sync_state']) { + 'ownership_scopes', 'ownership_scope_versions', 'ownership_contributors', + 'ownership_snapshots', 'ownership_sync_state']) { assert.ok(BACKUP_TABLES.includes(t), `BACKUP_TABLES missing ${t}`); } + for (const t of ['ownership_history_work', 'ownership_contributor_build_work']) { + assert.ok(!BACKUP_TABLES.includes(t), `ephemeral ${t} must never enter the allowlist`); + } // nothing outside the allowlist can enter the archive — no whole-db dump assert.doesNotMatch(b, /--schema-only|--format=custom'\s*\]/, 'no unscoped dump args'); });