From 2e328b05396010030e2d911a03205917f9f665b6 Mon Sep 17 00:00:00 2001 From: aliferous3 Date: Sun, 18 Oct 2026 14:55:42 +0400 Subject: [PATCH 1/2] feat: add ownership schema foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1B of the Code Ownership Over Time contract (docs/ownership-data-privacy-spec.md): additive migration 012 creates the four ownership tables with database-enforced tenant/repository pairing, row-shape checks, closed error taxonomy, RLS with zero browser-facing policies, service-role-only grants, and dormant SECURITY INVOKER dash_ownership_* RPCs gated on active_version. No ingestion exists in this tranche — the tables hold zero production rows. Backup scope classifies ownership history as CORE and sync state as OPERATIONAL; the recovery drill seeds synthetic fixtures and proves tenant-FK rejection, cascades, the version serve-gate (build/promote/coexist/flip), and RPC output safety against real Postgres 17. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs/disaster-recovery.md | 4 + migrations/012_ownership.sql | 468 ++++++++++++++++++++++++++++++++ scripts/backup-scope.mjs | 7 + scripts/recovery-drill.mjs | 265 +++++++++++++++++- test/data-minimization.test.mjs | 24 ++ test/ownership-schema.test.mjs | 256 +++++++++++++++++ test/recovery.test.mjs | 5 +- 7 files changed, 1025 insertions(+), 4 deletions(-) create mode 100644 migrations/012_ownership.sql create mode 100644 test/ownership-schema.test.mjs diff --git a/docs/disaster-recovery.md b/docs/disaster-recovery.md index 16723bf..672e73e 100644 --- a/docs/disaster-recovery.md +++ b/docs/disaster-recovery.md @@ -76,6 +76,10 @@ schema/table is *not* backed up until explicitly classified in | `repo_coverage` | operational | yes | derived but needs full re-ingest to rebuild — cheap to keep | | `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_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 | | `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/012_ownership.sql b/migrations/012_ownership.sql new file mode 100644 index 0000000..77e3695 --- /dev/null +++ b/migrations/012_ownership.sql @@ -0,0 +1,468 @@ +-- 012 — ownership schema foundation (additive; forward-only; idempotent). +-- +-- Contract: docs/ownership-data-privacy-spec.md. This tranche creates the +-- four ownership tables, relational/tenant integrity, RLS, grants, and +-- dormant dashboard RPC foundations ONLY. No ingestion exists — the tables +-- are expected to hold zero production rows until a later tranche. +-- +-- Hard invariants encoded here: +-- * tenant consistency is DATABASE-enforced via composite foreign keys: +-- (repository_id, user_id) -> repositories(id, user_id) so no row can +-- pair one tenant's user_id with another tenant's repository. +-- * linked contributor identity = github_node_id (GraphQL User.id) + +-- display_login. Identity fields beyond that pair are never stored. +-- * 'unlinked' and 'aggregate' rows are synthetic and can never carry +-- GitHub identity fields or be mistaken for people. +-- * monthly rows are immutable period facts; mutable/current-state +-- fields live only on 'head' rows. +-- * calculation_version is part of snapshot row identity so served and +-- building versions coexist (active_version/building_version gate). +-- * error is a CHECK-constrained closed taxonomy — no provider text. + +-- ── 0. tenant-consistency surrogate on repositories ───────────────────── +-- repositories.id is already the PK; the composite unique index lets +-- ownership tables foreign-key (repository_id, user_id) as a pair, proving +-- the repo belongs to the tenant. Unique index is sufficient as an FK +-- target; idempotent via if not exists. +create unique index if not exists repositories_id_user_key + on public.repositories (id, user_id); + +-- ── 1. ownership_scopes ────────────────────────────────────────────────── + +create table if not exists public.ownership_scopes ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null, + repository_id uuid not null, + scope_path_hash text not null, + display_path text, + scope_type text not null, + depth 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(), + + constraint ownership_scopes_repo_tenant + foreign key (repository_id, user_id) + references public.repositories (id, user_id) + on delete cascade, + + constraint ownership_scopes_type + check (scope_type in ('repo', 'dir')), + + -- repo scope: no path, depth 0. dir scope: bounded normalized path. + -- there is deliberately no 'file' scope type — file paths are never + -- persisted in V1. + constraint ownership_scopes_shape + check ( + (scope_type = 'repo' and display_path is null and depth = 0) + or (scope_type = 'dir' and display_path is not null + and depth between 1 and 4 + and length(display_path) <= 200) + ), + + -- vendor/generated/binary/submodule paths never reach this table; the + -- only legitimate persisted exclusion is the cardinality cap. + constraint ownership_scopes_exclusion + check ( + (not excluded and exclusion_reason is null) + or (excluded and exclusion_reason = 'cardinality_cap') + ), + + unique (repository_id, scope_path_hash), + -- surrogate for snapshot composite FKs + unique (id, repository_id) +); + +create index if not exists ownership_scopes_repo_depth + on public.ownership_scopes (repository_id, depth); + +-- ── 2. ownership_contributors ──────────────────────────────────────────── + +create table if not exists public.ownership_contributors ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null, + repository_id uuid not null, + contributor_key text not null, + identity_kind text not null, + github_node_id text, + display_login text, + 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_contributors_repo_tenant + foreign key (repository_id, user_id) + references public.repositories (id, user_id) + on delete cascade, + + constraint ownership_contributors_kind + check (identity_kind in ('linked', 'unlinked', 'aggregate')), + + -- linked rows carry the durable identity pair; synthetic rows never can. + constraint ownership_contributors_linked + check (identity_kind <> 'linked' + or (github_node_id is not null and display_login is not null)), + + constraint ownership_contributors_unlinked + check (identity_kind <> 'unlinked' + or (contributor_key = 'unlinked' and github_node_id is null + and display_login is null and is_bot = false)), + + constraint ownership_contributors_aggregate + check (identity_kind <> 'aggregate' + or (contributor_key = 'other' and github_node_id is null + and display_login is null and is_bot = false)), + + -- bots are only ever linked identities + constraint ownership_contributors_bot + check (is_bot = false or identity_kind = 'linked'), + + constraint ownership_contributors_seen + check (first_seen_at is null or last_seen_at is null + or first_seen_at <= last_seen_at), + + unique (repository_id, contributor_key), + -- surrogate for snapshot composite FKs + unique (id, repository_id) +); + +-- Deliberately NO index on github_node_id — cross-tenant contributor +-- enumeration must not even be convenient. +create index if not exists ownership_contributors_repo + on public.ownership_contributors (repository_id); + +-- ── 3. ownership_snapshots ─────────────────────────────────────────────── + +create table if not exists public.ownership_snapshots ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null, + repository_id uuid not null, + scope_id uuid not null, + contributor_id uuid, + period_granularity text not null, + period_start timestamptz not null, + + -- monthly contributor fields + touch_commits int, + touch_share numeric(5,4), + + -- head contributor fields (mutable current-state) + surviving_lines bigint, + surviving_line_share numeric(5,4), + recent_touch_commits int, + first_meaningful_touch timestamptz, + last_meaningful_touch timestamptz, + + -- monthly scope-total fields + total_touch_commits int, + human_contributor_count int, + unlinked_touch_share numeric(5,4), + + -- head scope-total fields + recent_human_maintainer_count int, + top_human_contributor_share numeric(5,4), + total_surviving_lines bigint, + unlinked_surviving_line_share numeric(5,4), + + calculation_version int not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + + constraint ownership_snapshots_repo_tenant + foreign key (repository_id, user_id) + references public.repositories (id, user_id) + on delete cascade, + + -- scope must belong to the same repository the snapshot claims + constraint ownership_snapshots_scope_repo + foreign key (scope_id, repository_id) + references public.ownership_scopes (id, repository_id) + on delete cascade, + + -- contributor must belong to the same repository (null = scope-total row) + constraint ownership_snapshots_contrib_repo + foreign key (contributor_id, repository_id) + references public.ownership_contributors (id, repository_id) + on delete cascade, + + constraint ownership_snapshots_granularity + check (period_granularity in ('month', 'head')), + + constraint ownership_snapshots_version + check (calculation_version > 0), + + -- head rows use the fixed sentinel; month rows are first-of-month UTC + constraint ownership_snapshots_period + check ( + (period_granularity = 'head' + and period_start = '1970-01-01 00:00:00+00'::timestamptz) + or (period_granularity = 'month' + and period_start = date_trunc('month', period_start)) + ), + + constraint ownership_snapshots_counts + check ( + (touch_commits is null or touch_commits >= 0) + and (surviving_lines is null or surviving_lines >= 0) + and (recent_touch_commits is null or recent_touch_commits >= 0) + and (total_touch_commits is null or total_touch_commits >= 0) + and (human_contributor_count is null or human_contributor_count >= 0) + and (recent_human_maintainer_count is null or recent_human_maintainer_count >= 0) + and (total_surviving_lines is null or total_surviving_lines >= 0) + ), + + constraint ownership_snapshots_shares + check ( + (touch_share is null or touch_share between 0 and 1) + and (surviving_line_share is null or surviving_line_share between 0 and 1) + and (unlinked_touch_share is null or unlinked_touch_share between 0 and 1) + and (top_human_contributor_share is null or top_human_contributor_share between 0 and 1) + and (unlinked_surviving_line_share is null or unlinked_surviving_line_share between 0 and 1) + ), + + constraint ownership_snapshots_meaningful + check (first_meaningful_touch is null or last_meaningful_touch is null + or first_meaningful_touch <= last_meaningful_touch), + + -- row-shape guardrails: monthly rows are immutable period facts and must + -- not carry current-state fields; head rows must not carry monthly + -- fields; contributor rows must not carry scope-total fields and vice + -- versa. Prevents impossible mixed row shapes without constraining + -- legitimate null/zero cases. + constraint ownership_snapshots_shape + check ( + case + when period_granularity = 'month' and contributor_id is not null + then surviving_lines is null and surviving_line_share is null + and recent_touch_commits is null + and first_meaningful_touch is null and last_meaningful_touch is null + and total_touch_commits is null and human_contributor_count is null + and unlinked_touch_share is null and recent_human_maintainer_count is null + and top_human_contributor_share is null and total_surviving_lines is null + and unlinked_surviving_line_share is null + when period_granularity = 'month' and contributor_id is null + then touch_commits is null and touch_share is null + and surviving_lines is null and surviving_line_share is null + and recent_touch_commits is null + and first_meaningful_touch is null and last_meaningful_touch is null + and recent_human_maintainer_count is null + and top_human_contributor_share is null and total_surviving_lines is null + and unlinked_surviving_line_share is null + when period_granularity = 'head' and contributor_id is not null + then touch_commits is null and touch_share is null + and total_touch_commits is null and human_contributor_count is null + and unlinked_touch_share is null + and recent_human_maintainer_count is null + and top_human_contributor_share is null and total_surviving_lines is null + and unlinked_surviving_line_share is null + when period_granularity = 'head' and contributor_id is null + then touch_commits is null and touch_share is null + and surviving_lines is null and surviving_line_share is null + and recent_touch_commits is null + and first_meaningful_touch is null and last_meaningful_touch is null + and total_touch_commits is null and unlinked_touch_share is null + else false + end + ) +); + +create index if not exists ownership_snapshots_repo_period + on public.ownership_snapshots (repository_id, period_start); + +-- calculation_version is part of row identity: served (active) and +-- building versions coexist, so version is in both unique keys. +create unique index if not exists ownership_snapshots_contrib_key + on public.ownership_snapshots + (scope_id, contributor_id, period_granularity, period_start, calculation_version) + where contributor_id is not null; + +create unique index if not exists ownership_snapshots_total_key + on public.ownership_snapshots + (scope_id, period_granularity, period_start, calculation_version) + where contributor_id is null; + +-- ── 4. ownership_sync_state ────────────────────────────────────────────── + +create table if not exists public.ownership_sync_state ( + user_id uuid not null, + repository_id uuid not null, + phase text not null default 'pending', + history_cursor jsonb, + blame_cursor jsonb, + pending_scopes jsonb, + active_version int, + building_version int, + last_processed_sha text, + last_processed_at timestamptz, + error text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + + primary key (user_id, repository_id), + + constraint ownership_sync_state_repo_tenant + foreign key (repository_id, user_id) + references public.repositories (id, user_id) + on delete cascade, + + constraint ownership_sync_state_phase + check (phase in ('pending', 'history', 'blame', 'done', 'error')), + + constraint ownership_sync_state_active + check (active_version is null or active_version > 0), + + constraint ownership_sync_state_building + check (building_version is null or building_version > 0), + + -- the same version can never be both served and under construction + constraint ownership_sync_state_versions + check (active_version is null or building_version is null + or active_version <> building_version), + + -- closed taxonomy only — raw provider text can never reach this column + constraint ownership_sync_state_error + check (error is null or error in ( + 'GITHUB_RATE_LIMIT', + 'GITHUB_ACCESS_REVOKED', + 'OWNERSHIP_HISTORY_FAILED', + 'OWNERSHIP_BLAME_FAILED', + 'OWNERSHIP_INTERNAL_ERROR' + )) +); + +-- ── 5. RLS — zero browser-facing policies on every ownership table ─────── +-- Mirrors 007: the only data path is the server-side service_role key. + +alter table public.ownership_scopes enable row level security; +alter table public.ownership_contributors enable row level security; +alter table public.ownership_snapshots enable row level security; +alter table public.ownership_sync_state enable row level security; + +revoke all on public.ownership_scopes from public, anon, authenticated; +revoke all on public.ownership_contributors from public, anon, authenticated; +revoke all on public.ownership_snapshots from public, anon, authenticated; +revoke all on public.ownership_sync_state from public, anon, authenticated; + +grant select, insert, update, delete on public.ownership_scopes to service_role; +grant select, insert, update, delete on public.ownership_contributors to service_role; +grant select, insert, update, delete on public.ownership_snapshots to service_role; +grant select, insert, update, delete on public.ownership_sync_state to service_role; + +-- ── 6. dormant dashboard RPC foundations ───────────────────────────────── +-- SECURITY INVOKER (inherit caller privileges), EXECUTE service_role only, +-- every row filtered through p_user, and only rows whose +-- calculation_version equals the repo's active_version are served: +-- active_version IS NULL ("not ready") or a mid-build building_version can +-- never leak rows. Internal ids, github_node_id, contributor_key, commit +-- SHAs, and cursors are never returned. + +-- 6a. per-scope current summary (HEAD scope-total row + scope metadata) +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, sc.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 + 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 +$$; + +-- 6b. HEAD per-contributor rows for one scope (blame + recent signals) +create or replace function public.dash_ownership_head(p_user uuid, p_repository uuid, p_scope text) +returns table( + display_login text, + identity_kind text, + is_bot boolean, + surviving_lines bigint, + surviving_line_share numeric, + recent_touch_commits int, + last_meaningful_touch timestamptz +) +language sql stable security invoker set search_path = public as +$$ + select c.display_login, c.identity_kind, c.is_bot, + s.surviving_lines, s.surviving_line_share, + s.recent_touch_commits, s.last_meaningful_touch + from public.ownership_snapshots s + join public.ownership_scopes sc on sc.id = s.scope_id and sc.repository_id = s.repository_id + join public.ownership_sync_state st + on st.repository_id = s.repository_id and st.user_id = s.user_id + left join public.ownership_contributors c + on c.id = s.contributor_id and c.repository_id = s.repository_id + where s.user_id = p_user + and s.repository_id = p_repository + and sc.scope_path_hash = p_scope + and s.contributor_id is not null + and s.period_granularity = 'head' + and s.calculation_version = st.active_version + order by s.surviving_line_share desc nulls last +$$; + +-- 6c. monthly timeline for one scope — contributor rows (contributor +-- columns populated) interleaved with scope-total rows (total columns +-- populated); consumers split on contributor display/identity being null. +create or replace function public.dash_ownership_timeline(p_user uuid, p_repository uuid, p_scope text) +returns table( + period_start timestamptz, + display_login text, + identity_kind text, + is_bot boolean, + touch_commits int, + touch_share numeric, + total_touch_commits int, + human_contributor_count int, + unlinked_touch_share numeric +) +language sql stable security invoker set search_path = public as +$$ + select s.period_start, c.display_login, c.identity_kind, c.is_bot, + s.touch_commits, s.touch_share, + s.total_touch_commits, s.human_contributor_count, s.unlinked_touch_share + from public.ownership_snapshots s + join public.ownership_scopes sc on sc.id = s.scope_id and sc.repository_id = s.repository_id + join public.ownership_sync_state st + on st.repository_id = s.repository_id and st.user_id = s.user_id + left join public.ownership_contributors c + on c.id = s.contributor_id and c.repository_id = s.repository_id + where s.user_id = p_user + and s.repository_id = p_repository + and sc.scope_path_hash = p_scope + and s.period_granularity = 'month' + and s.calculation_version = st.active_version + order by s.period_start +$$; + +-- Functions default to EXECUTE for PUBLIC — revoke everywhere, then grant +-- the backend role only (007 pattern). +revoke all on function public.dash_ownership_scopes(uuid, uuid) from public, anon, authenticated; +revoke all on function public.dash_ownership_head(uuid, uuid, text) from public, anon, authenticated; +revoke all on function public.dash_ownership_timeline(uuid, uuid, text) from public, anon, authenticated; + +grant execute on function public.dash_ownership_scopes(uuid, uuid) to service_role; +grant execute on function public.dash_ownership_head(uuid, uuid, text) to service_role; +grant execute on function public.dash_ownership_timeline(uuid, uuid, text) to service_role; diff --git a/scripts/backup-scope.mjs b/scripts/backup-scope.mjs index b2be2f0..8d062de 100644 --- a/scripts/backup-scope.mjs +++ b/scripts/backup-scope.mjs @@ -18,6 +18,11 @@ export const CORE_TABLES = [ 'commits', // ingested commit rows — the expensive history 'pull_requests', // ingested PR rows (no titles/numbers/authors — data minimization) 'feedback', // user-submitted transmissions — not re-ingestable + 'ownership_scopes', // ownership scope map — irreplaceable for + // retained/disconnected repos GitHub can no + // longer serve + 'ownership_contributors',// repo-scoped contributor identity rows + 'ownership_snapshots', // ownership aggregates — same irreplaceability ] // OPERATIONAL — reconstructable in principle, but restoring them avoids a @@ -29,6 +34,8 @@ export const OPERATIONAL_TABLES = [ // full GitHub re-ingest — cheap to keep 'repo_sync', // per-repo sync phase markers (push kicks) 'user_sync', // per-user sync status; 'syncing' rows resume via cron + 'ownership_sync_state', // ownership cursors/version markers — losing it + // means a re-ingest, same as repo_sync ] // EPHEMERAL — intentionally excluded from backups. diff --git a/scripts/recovery-drill.mjs b/scripts/recovery-drill.mjs index bac0f5e..017e919 100644 --- a/scripts/recovery-drill.mjs +++ b/scripts/recovery-drill.mjs @@ -99,12 +99,258 @@ async function seed(sql) { await sql`insert into webhook_deliveries (delivery_id, event) values ('drill-delivery', 'push')` await sql`insert into security_events (event, severity, route, method, status, reason_code) values ('logout_completed', 'info', '/api/auth/logout', 'POST', 200, null)` + // ── 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. + const scopeRepo = crypto.randomUUID() + const scopeDir = crypto.randomUUID() + const cLinked = crypto.randomUUID() + const cUnlinked = crypto.randomUUID() + const cOther = crypto.randomUUID() + await sql`insert into ownership_scopes (id, user_id, repository_id, scope_path_hash, display_path, scope_type, depth) + values (${scopeRepo}, ${uid}, ${repoId}, 'h-repo', null, 'repo', 0)` + await sql`insert into ownership_scopes (id, user_id, repository_id, scope_path_hash, display_path, scope_type, depth) + values (${scopeDir}, ${uid}, ${repoId}, 'h-src-api', 'src/api', 'dir', 2)` + await sql`insert into ownership_contributors (id, user_id, repository_id, contributor_key, identity_kind, github_node_id, display_login) + values (${cLinked}, ${uid}, ${repoId}, 'k-alice', 'linked', 'MDQ6VXNlcjExMQ==', 'alice')` + await sql`insert into ownership_contributors (id, user_id, repository_id, contributor_key, identity_kind) + 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')` + 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', 12, 0.5, 1)` + await sql`insert into ownership_snapshots + (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, + total_touch_commits, human_contributor_count, unlinked_touch_share, calculation_version) + values (${uid}, ${repoId}, ${scopeDir}, null, 'month', '2026-09-01T00:00:00Z', 24, 3, 0.2, 1)` + await sql`insert into ownership_snapshots + (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, + surviving_lines, surviving_line_share, recent_touch_commits, last_meaningful_touch, calculation_version) + values (${uid}, ${repoId}, ${scopeDir}, ${cLinked}, 'head', '1970-01-01T00:00:00Z', 500, 0.31, 5, now(), 1)` + await sql`insert into ownership_snapshots + (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, + 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)` + // Sentinel outside the allowlist — proves the dump cannot pick up // unrelated schemas/tables (Supabase internals, unclassified app tables). await sql`create schema drill_internal` await sql`create table drill_internal.do_not_backup (marker text)` await sql`insert into drill_internal.do_not_backup values ('must-not-survive')` - return { uid, repoId } + return { uid, repoId, scopeRepo, scopeDir, cLinked, cUnlinked, cOther } +} + +// ── ownership schema proofs against real Postgres ──────────────────────── +// Runs on drill_src BEFORE the backup: negative inserts all fail at the +// constraint/FK level (no rows persist), and throwaway tenants prove repo + +// user cascade and the version serve-gate. Net DB effect is zero. +async function ownershipProofs(sql, { uid, repoId, scopeDir, cLinked, cOther }) { + const problems = [] + const check = (ok, msg) => { if (!ok) problems.push(msg) } + const mustFail = async (fn, label) => { + try { await fn(); problems.push(`${label}: insert/update must have been rejected`) } + catch { /* constraint worked */ } + } + + // ── tenant mismatch: database must reject cross-tenant/cross-repo pairs + const uidB = crypto.randomUUID() + const repoB = crypto.randomUUID() + await sql`insert into users (id, github_user_id, github_login) values (${uidB}, 43434343, 'drill-tenant-b')` + await sql`insert into repositories (id, user_id, github_repo_id, owner_login, name, full_name) + values (${repoB}, ${uidB}, 323232, 'drill-tenant-b', 'repo-b', 'drill-tenant-b/repo-b')` + + await mustFail(() => sql`insert into ownership_scopes + (user_id, repository_id, scope_path_hash, scope_type, depth) + values (${uid}, ${repoB}, 'x', 'dir', 1)`, 'tenant A user_id + tenant B repository_id (scope)') + // scope dir needs display_path — the tenant FK must fire regardless of shape + await mustFail(() => sql`insert into ownership_scopes + (user_id, repository_id, scope_path_hash, display_path, scope_type, depth) + values (${uid}, ${repoB}, 'x', 'lib/x', 'dir', 1)`, 'tenant mismatch scope (valid shape)') + await mustFail(() => sql`insert into ownership_contributors + (user_id, repository_id, contributor_key, identity_kind, github_node_id, display_login) + values (${uid}, ${repoB}, 'k-b', 'linked', 'MDQ6VXNlcjIyMg==', 'bob')`, 'tenant mismatch contributor') + await mustFail(() => sql`insert into ownership_snapshots + (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, touch_commits, calculation_version) + values (${uidB}, ${repoB}, ${scopeDir}, ${cLinked}, 'month', '2026-09-01T00:00:00Z', 1, 1)`, + 'snapshot: scope from repo A + repository_id repo 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 (${uidB}, ${repoB}, null, ${cLinked}, 'month', '2026-09-01T00:00:00Z', 1, 1)`, + 'snapshot: contributor from repo A + repository_id repo B') + await mustFail(() => sql`insert into ownership_sync_state (user_id, repository_id) + values (${uid}, ${repoB})`, 'tenant mismatch sync_state') + + // ── scope shape / type guards + await mustFail(() => sql`insert into ownership_scopes + (user_id, repository_id, scope_path_hash, display_path, scope_type, depth) + values (${uid}, ${repoId}, 'f', 'a.ts', 'file', 1)`, 'file scope type') + await mustFail(() => sql`insert into ownership_scopes + (user_id, repository_id, scope_path_hash, display_path, scope_type, depth) + values (${uid}, ${repoId}, 'd0', 'x', 'dir', 0)`, 'dir depth 0') + await mustFail(() => sql`insert into ownership_scopes + (user_id, repository_id, scope_path_hash, display_path, scope_type, depth) + values (${uid}, ${repoId}, 'd5', 'a/b/c/d/e', 'dir', 5)`, 'dir depth 5') + await mustFail(() => sql`insert into ownership_scopes + (user_id, repository_id, scope_path_hash, scope_type, depth) + values (${uid}, ${repoId}, 'rp', 'src/x', 'repo', 0)`, 'repo scope with path') + await mustFail(() => sql`insert into ownership_scopes + (user_id, repository_id, scope_path_hash, scope_type, depth, excluded, exclusion_reason) + values (${uid}, ${repoId}, 'ex1', 'vendor/x', 'dir', 1, true, 'vendor')`, 'exclusion_reason vendor') + await mustFail(() => sql`insert into ownership_scopes + (user_id, repository_id, scope_path_hash, scope_type, depth, excluded, exclusion_reason) + values (${uid}, ${repoId}, 'ex2', 'x/y', 'dir', 1, false, 'cardinality_cap')`, 'excluded=false with reason') + // legal cardinality_cap row is allowed — insert then delete to keep seed counts + const capId = crypto.randomUUID() + await sql`insert into ownership_scopes (id, user_id, repository_id, scope_path_hash, display_path, scope_type, depth, excluded, exclusion_reason) + values (${capId}, ${uid}, ${repoId}, 'h-deep', 'a/b/c/d', 'dir', 4, true, 'cardinality_cap')` + await sql`delete from ownership_scopes where id = ${capId}` + + // ── identity-shape guards + await mustFail(() => sql`insert into ownership_contributors + (user_id, repository_id, contributor_key, identity_kind, github_node_id, display_login) + values (${uid}, ${repoId}, 'k-noid', 'linked', null, 'bob')`, 'linked without node id') + await mustFail(() => sql`insert into ownership_contributors + (user_id, repository_id, contributor_key, identity_kind, github_node_id, display_login) + values (${uid}, ${repoId}, 'k-nologin', 'linked', 'MDQ6VXNlcjMzMw==', null)`, 'linked without login') + await mustFail(() => sql`insert into ownership_contributors + (user_id, repository_id, contributor_key, identity_kind, github_node_id, display_login) + values (${uid}, ${repoId}, 'unlinked2', 'unlinked', 'MDQ6VXNlcjQ0NA==', null)`, 'unlinked with node id') + await mustFail(() => sql`insert into ownership_contributors + (user_id, repository_id, contributor_key, identity_kind) + values (${uid}, ${repoId}, 'k-x', 'unlinked')`, 'unlinked with wrong key') + await mustFail(() => sql`insert into ownership_contributors + (user_id, repository_id, contributor_key, identity_kind, is_bot) + values (${uid}, ${repoId}, 'other2', 'aggregate', false)`, 'aggregate with wrong key') + await mustFail(() => sql`insert into ownership_contributors + (user_id, repository_id, contributor_key, identity_kind, is_bot) + values (${uid}, ${repoId}, 'unlinked-bot', 'unlinked', true)`, 'is_bot on unlinked') + // bot must be linked — this insert is legal + const botId = crypto.randomUUID() + await sql`insert into ownership_contributors (id, user_id, repository_id, contributor_key, identity_kind, github_node_id, display_login, is_bot) + values (${botId}, ${uid}, ${repoId}, 'k-bot', 'linked', 'MDQ6VXNlcjU1NQ==', 'dependabot[bot]', true)` + await sql`delete from ownership_contributors where id = ${botId}` + + // ── snapshot guards + await mustFail(() => sql`insert into ownership_snapshots + (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, touch_commits, calculation_version) + values (${uid}, ${repoId}, ${scopeDir}, ${cLinked}, 'month', '2026-09-01T00:00:00Z', 5, 0)`, + 'calculation_version 0') + await mustFail(() => sql`insert into ownership_snapshots + (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, touch_share, calculation_version) + values (${uid}, ${repoId}, ${scopeDir}, ${cLinked}, 'month', '2026-09-01T00:00:00Z', 1.5, 1)`, + 'share > 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 (${uid}, ${repoId}, ${scopeDir}, ${cLinked}, 'month', '2026-09-15T00:00:00Z', 1, 1)`, + 'month not first-of-month') + await mustFail(() => sql`insert into ownership_snapshots + (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, surviving_lines, calculation_version) + values (${uid}, ${repoId}, ${scopeDir}, ${cLinked}, 'head', '2026-01-01T00:00:00Z', 1, 1)`, + 'head without sentinel period_start') + await mustFail(() => sql`insert into ownership_snapshots + (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, touch_commits, surviving_lines, calculation_version) + values (${uid}, ${repoId}, ${scopeDir}, ${cLinked}, 'month', '2026-09-01T00:00:00Z', 3, 7, 1)`, + 'monthly contributor row carrying head fields') + await mustFail(() => sql`insert into ownership_snapshots + (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, surviving_lines, touch_commits, calculation_version) + values (${uid}, ${repoId}, ${scopeDir}, ${cLinked}, 'head', '1970-01-01T00:00:00Z', 5, 3, 1)`, + 'head contributor row carrying monthly fields') + await mustFail(() => sql`insert into ownership_snapshots + (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, total_touch_commits, touch_commits, calculation_version) + values (${uid}, ${repoId}, ${scopeDir}, null, 'month', '2026-10-01T00:00:00Z', 4, 4, 1)`, + 'scope-total row carrying contributor fields') + + // ── sync_state guards + await mustFail(() => sql`insert into ownership_sync_state (user_id, repository_id, phase) + values (${uid}, ${repoId}, 'bogus')`, 'invalid phase') + await mustFail(() => sql`insert into ownership_sync_state (user_id, repository_id, error) + values (${uid}, ${repoId}, 'ECONNRESET upstream socket hangup')`, 'raw error text') + await mustFail(() => sql`insert into ownership_sync_state (user_id, repository_id, active_version, building_version) + values (${uid}, ${repoId}, 1, 1)`, 'active = building') + // closed-taxonomy error is legal + 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 + 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)` + // 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'` + check(tl.length === 1 && Number(tl[0].touch_commits) === 12, + `timeline served ${tl.length} rows with touch_commits ${tl[0]?.touch_commits} — expected exactly 1 row at v1 value 12`) + const headRows = await sql`select * from dash_ownership_head(${uid}, ${repoId}, 'h-src-api')` + check(headRows.length === 1 && headRows[0].display_login === 'alice' && Number(headRows[0].surviving_lines) === 500, + 'dash_ownership_head served wrong rows') + check(headRows[0] && !('github_node_id' in headRows[0]) && !('contributor_key' in headRows[0]), + 'dash_ownership_head leaked identity internals') + 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` + + // ── bootstrap + promotion + rebuild lifecycle on a throwaway tenant + const uidC = crypto.randomUUID() + const repoC = crypto.randomUUID() + const scopeC = crypto.randomUUID() + const cC = crypto.randomUUID() + await sql`insert into users (id, github_user_id, github_login) values (${uidC}, 45454545, 'drill-tenant-c')` + await sql`insert into repositories (id, user_id, github_repo_id, owner_login, name, full_name) + values (${repoC}, ${uidC}, 333333, 'drill-tenant-c', 'repo-c', 'drill-tenant-c/repo-c')` + await sql`insert into ownership_scopes (id, user_id, repository_id, scope_path_hash, scope_type, depth) + 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_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)` + // FIRST BUILD: active null + building 1 → nothing served + await sql`insert into ownership_sync_state (user_id, repository_id, phase, active_version, building_version) + values (${uidC}, ${repoC}, 'history', null, 1)` + const [{ c: notReady }] = await sql`select count(*)::int c from dash_ownership_scopes(${uidC}, ${repoC})` + check(notReady === 0, 'partial first build was served — active_version gate broken') + // PROMOTION: atomic flip → V1 visible + await sql`update ownership_sync_state set active_version = 1, building_version = null, phase = 'done' + where user_id = ${uidC} and repository_id = ${repoC}` + const v1rows = await sql`select touch_commits from dash_ownership_timeline(${uidC}, ${repoC}, 'h-root')` + 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_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)` + const stillV1 = await sql`select touch_commits from dash_ownership_timeline(${uidC}, ${repoC}, 'h-root')` + check(stillV1.length === 1 && Number(stillV1[0].touch_commits) === 7, 'building V2 leaked into served results') + // FLIP: atomic serve change + await sql`update ownership_sync_state set active_version = 2, building_version = null + 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) + await sql`delete from repositories where id = ${repoC}` + for (const t of ['ownership_scopes', 'ownership_contributors', 'ownership_snapshots', 'ownership_sync_state']) { + 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)`) + } + // user delete cascades every ownership table (E) — uidC's repo already + // went with it; verify the whole tenant set is gone. + await sql`delete from users where id = ${uidC}` + for (const t of ['ownership_scopes', 'ownership_contributors', 'ownership_snapshots', 'ownership_sync_state']) { + const [{ c }] = await sql`select count(*)::int c from ${sql(t)} where user_id = ${uidC}` + check(c === 0, `${t} rows survived user delete (user cascade broken)`) + } + // tenant B cleanup — its empty repo row also exercises user cascade + await sql`delete from users where id = ${uidB}` + + return problems } // Archive TOC proof: pg_restore --list shows exactly which TABLE DATA @@ -188,6 +434,14 @@ async function verify(src, dst, ids, extraProblems = []) { select privilege_type from information_schema.role_table_grants where table_schema = 'public' and table_name = 'commits' and grantee = 'service_role'` check(sr.some((g) => g.privilege_type === 'SELECT'), 'service_role missing SELECT on commits') + const sro = await dst` + select privilege_type from information_schema.role_table_grants + 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 + 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`) // Behavioral proof, not just grant inspection: switch into each role the // way Supabase's authenticator does and actually read an app table. @@ -278,8 +532,15 @@ async function main() { const src = postgres(srcUrl, { onnotice: () => {} }) const seeded = await seed(src) Object.assign(ids, seeded) + const oProblems = await ownershipProofs(src, seeded) await src.end() - console.log('drill: seeded synthetic rows (user, repo, commit, PR, sync state, ephemeral rows)') + if (oProblems.length) { + for (const p of oProblems) console.error(` FAIL ${p}`) + fail(`${oProblems.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') // same backup path the operator uses const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dl-drill-')) diff --git a/test/data-minimization.test.mjs b/test/data-minimization.test.mjs index 253ac20..81babd6 100644 --- a/test/data-minimization.test.mjs +++ b/test/data-minimization.test.mjs @@ -354,6 +354,17 @@ test('every user-linked table cascades from users in the migrations', () => { } // repository_languages hangs off repositories — the only non-user FK. assert.match(all, /references (?:public\.)?repositories\(id\) on delete cascade/i) + // 012 ownership tables cascade through the tenant-qualified composite FK: + // (repository_id, user_id) -> repositories(id, user_id) on delete cascade, + // which chains to users(id) cascade via repositories. + const ownership = src('migrations/012_ownership.sql') + for (const tbl of ['ownership_scopes', 'ownership_contributors', 'ownership_snapshots', 'ownership_sync_state']) { + assert.match( + ownership, + 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 ─────────────────────────────── */ @@ -366,6 +377,19 @@ test('backup allowlist excludes ephemeral and carries no removed fields', async } }) +test('ownership backup split: derived history is CORE, sync state OPERATIONAL', async () => { + const { BACKUP_TABLES, CORE_TABLES, OPERATIONAL_TABLES, EPHEMERAL_TABLES } = await import('../scripts/backup-scope.mjs') + // ownership history is irreplaceable for retained/disconnected repos — + // it is backed up as CORE, never classified ephemeral + for (const t of ['ownership_scopes', 'ownership_contributors', 'ownership_snapshots']) { + assert.ok(CORE_TABLES.includes(t), `${t} must be CORE`) + assert.ok(BACKUP_TABLES.includes(t), `${t} must be backed up`) + } + assert.ok(OPERATIONAL_TABLES.includes('ownership_sync_state'), 'ownership_sync_state must be OPERATIONAL') + assert.ok(BACKUP_TABLES.includes('ownership_sync_state'), 'ownership_sync_state is backed up like repo_sync') + assert.ok(!EPHEMERAL_TABLES.includes('ownership_sync_state'), 'ownership sync state must not be ephemeral') +}) + /* ── WS5: legal text cannot re-claim removed fields ──────────────────────── */ test('privacy/security copy stays consistent with the final schema', () => { diff --git a/test/ownership-schema.test.mjs b/test/ownership-schema.test.mjs new file mode 100644 index 0000000..2cf775f --- /dev/null +++ b/test/ownership-schema.test.mjs @@ -0,0 +1,256 @@ +// Phase-1B ownership schema foundation — structural guards for +// migrations/012_ownership.sql plus dormancy proofs that no runtime wiring +// exists. Real-Postgres behavioral proofs (tenant-FK rejection, row-shape +// checks, version serve-gate, cascades, RPC output) live in +// scripts/recovery-drill.mjs and run 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/012_ownership.sql') + +const TABLES = ['ownership_scopes', 'ownership_contributors', 'ownership_snapshots', 'ownership_sync_state'] + +// extract the body of one CREATE TABLE block for table-scoped assertions +function tableSql(name) { + const i = MIG.indexOf(`create table if not exists public.${name}`) + assert.notEqual(i, -1, `${name} is not created in 012`) + // naive-but-sufficient: table body ends at the first "\n);" after start + const end = MIG.indexOf('\n);', i) + return MIG.slice(i, end) +} + +/* ── additive-only migration ─────────────────────────────────────────── */ + +test('012 is purely additive — no drops, truncates, or column rewrites', () => { + assert.doesNotMatch(MIG, /drop\s+table|drop\s+column|truncate/i) + assert.doesNotMatch(MIG, /alter\s+table[\s\S]*?\brename\b/i) + // the only existing-table change is the repositories surrogate unique + 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 + 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') +}) + +/* ── tables, columns, tenant FKs ─────────────────────────────────────── */ + +test('all four ownership tables exist with required columns', () => { + for (const t of TABLES) assert.match(MIG, new RegExp(`create table if not exists public\\.${t}\\b`)) + const scopes = tableSql('ownership_scopes') + for (const col of ['id\\s+uuid', 'user_id', 'repository_id', 'scope_path_hash', 'display_path', + 'scope_type', 'depth', 'excluded', 'exclusion_reason', 'created_at', 'updated_at']) { + assert.match(scopes, new RegExp(`\\b${col}`), `ownership_scopes missing ${col}`) + } + const contrib = tableSql('ownership_contributors') + for (const col of ['contributor_key', 'identity_kind', 'github_node_id', 'display_login', + 'is_bot', 'first_seen_at', 'last_seen_at']) { + assert.match(contrib, new RegExp(`\\b${col}`), `ownership_contributors missing ${col}`) + } + const snaps = tableSql('ownership_snapshots') + for (const col of ['scope_id', 'contributor_id', 'period_granularity', 'period_start', + 'calculation_version', 'touch_commits', 'touch_share', 'surviving_lines', + 'surviving_line_share', 'recent_touch_commits', 'first_meaningful_touch', + 'last_meaningful_touch', 'total_touch_commits', 'human_contributor_count', + 'unlinked_touch_share', 'recent_human_maintainer_count', + 'top_human_contributor_share', 'total_surviving_lines', 'unlinked_surviving_line_share']) { + assert.match(snaps, new RegExp(`\\b${col}`), `ownership_snapshots missing ${col}`) + } + const sync = tableSql('ownership_sync_state') + for (const col of ['phase', 'history_cursor', 'blame_cursor', 'pending_scopes', + 'active_version', 'building_version', 'last_processed_sha', 'error', + 'primary key \\(user_id, repository_id\\)']) { + assert.match(sync, new RegExp(`\\b${col}`), `ownership_sync_state missing ${col}`) + } +}) + +test('every ownership table enforces tenant pairing by composite FK cascade', () => { + for (const t of TABLES) { + const body = tableSql(t) + assert.match(body, /foreign key \(repository_id, user_id\)/i, `${t} missing composite tenant FK`) + assert.match(body, /references public\.repositories \(id, user_id\)/i, `${t} FK target wrong`) + assert.match(body, /on delete cascade/i, `${t} missing delete cascade`) + } +}) + +test('snapshot composite FKs pin scope and contributor to the same repository', () => { + const snaps = tableSql('ownership_snapshots') + assert.match(snaps, /foreign key \(scope_id, repository_id\)[\s\S]*?references public\.ownership_scopes \(id, repository_id\)/i) + assert.match(snaps, /foreign key \(contributor_id, repository_id\)[\s\S]*?references public\.ownership_contributors \(id, repository_id\)/i) + // contributor_id must be nullable — scope-total rows have no contributor + assert.match(snaps, /contributor_id\s+uuid(?! not null)/i) + // supporting surrogate uniques must exist on the parent tables + assert.match(tableSql('ownership_scopes'), /unique \(id, repository_id\)/i) + assert.match(tableSql('ownership_contributors'), /unique \(id, repository_id\)/i) +}) + +/* ── row-shape / identity / period checks ────────────────────────────── */ + +test('scope types are repo/dir only — no file scope, bounded dir shape', () => { + const body = tableSql('ownership_scopes') + assert.match(body, /check \(scope_type in \('repo', 'dir'\)\)/i) + assert.match(body, /depth between 1 and 4/i) + assert.match(body, /length\(display_path\) <= 200/i) + assert.match(body, /unique \(repository_id, scope_path_hash\)/i) + // the only persisted exclusion reason is the cardinality cap + assert.match(body, /exclusion_reason = 'cardinality_cap'/i) + assert.doesNotMatch(body, /'vendor'|'generated'|'binary'|'submodule'/i, + 'vendor/generated/binary/submodule paths must never reach this table') +}) + +test('identity kinds are a closed set with shape-separating checks', () => { + const body = tableSql('ownership_contributors') + assert.match(body, /check \(identity_kind in \('linked', 'unlinked', 'aggregate'\)\)/i) + assert.match(body, /contributor_key = 'unlinked'/i, 'unlinked kind must pin the fixed key') + assert.match(body, /contributor_key = 'other'/i, 'aggregate kind must pin the fixed key') + assert.match(body, /is_bot = false or identity_kind = 'linked'/i, 'bots must be linked-only') + assert.match(body, /github_node_id is not null and display_login is not null/i, + 'linked rows must require the durable identity pair') + assert.match(body, /first_seen_at <= last_seen_at/i) + assert.match(body, /unique \(repository_id, contributor_key\)/i) +}) + +test('no numeric databaseId, email, avatar, or author-name columns anywhere', () => { + for (const bad of ['database_id', 'databaseId', 'email', 'avatar', 'author_name', + 'author_email', 'committer_email', 'login_history', 'name_history']) { + assert.doesNotMatch(MIG, new RegExp(`\\b${bad}\\b`, 'i'), `forbidden field ${bad} in 012`) + } +}) + +test('no source/raw-content fields can exist in the ownership schema', () => { + for (const bad of ['commit_message', 'pr_title', 'patch', 'diff', 'blob', + 'source_code', 'source_line', 'blame_range', 'blame_text', 'raw_graphql', + 'raw_response', 'oauth_token', 'installation_token', 'webhook_body']) { + assert.doesNotMatch(MIG, new RegExp(`\\b${bad}\\b`, 'i'), `forbidden content field ${bad} in 012`) + } +}) + +test('period rules: month is first-of-month UTC, head is the fixed sentinel', () => { + const body = tableSql('ownership_snapshots') + assert.match(body, /check \(period_granularity in \('month', 'head'\)\)/i) + assert.match(body, /'1970-01-01 00:00:00\+00'::timestamptz/, 'head sentinel missing') + assert.match(body, /date_trunc\('month', period_start\)/i, 'month boundary check missing') + assert.match(body, /calculation_version > 0/i) +}) + +test('nonnegative counts, [0,1] shares, meaningful-touch ordering', () => { + const body = tableSql('ownership_snapshots') + for (const col of ['touch_commits', 'surviving_lines', 'recent_touch_commits', + 'total_touch_commits', 'human_contributor_count', 'recent_human_maintainer_count', + 'total_surviving_lines']) { + assert.match(body, new RegExp(`${col} is null or ${col} >= 0`, 'i'), `${col} missing >=0 check`) + } + for (const col of ['touch_share', 'surviving_line_share', 'unlinked_touch_share', + 'top_human_contributor_share', 'unlinked_surviving_line_share']) { + assert.match(body, new RegExp(`${col} is null or ${col} between 0 and 1`, 'i'), `${col} missing [0,1] check`) + } + assert.match(body, /first_meaningful_touch <= last_meaningful_touch/i) +}) + +test('row-shape check separates the four legitimate row forms', () => { + const body = tableSql('ownership_snapshots') + assert.match(body, /when period_granularity = 'month' and contributor_id is not null/i) + assert.match(body, /when period_granularity = 'month' and contributor_id is null/i) + assert.match(body, /when period_granularity = 'head' and contributor_id is not null/i) + assert.match(body, /when period_granularity = 'head' and contributor_id is null/i) + assert.match(body, /else false/i, 'no fallback row shape may be allowed') +}) + +test('snapshot uniqueness carries calculation_version in both partial keys', () => { + assert.match(MIG, /create unique index if not exists ownership_snapshots_contrib_key[\s\S]*?\(scope_id, contributor_id, period_granularity, period_start, calculation_version\)[\s\S]*?where contributor_id is not null/i) + assert.match(MIG, /create unique index if not exists ownership_snapshots_total_key[\s\S]*?\(scope_id, period_granularity, period_start, calculation_version\)[\s\S]*?where contributor_id is null/i) +}) + +/* ── sync state ──────────────────────────────────────────────────────── */ + +test('sync_state is resumable, versioned, and closed-taxonomy', () => { + const body = tableSql('ownership_sync_state') + assert.match(body, /check \(phase in \('pending', 'history', 'blame', 'done', 'error'\)\)/i) + assert.match(body, /active_version is null or active_version > 0/i) + assert.match(body, /building_version is null or building_version > 0/i) + assert.match(body, /active_version <> building_version/i) + for (const code of ['GITHUB_RATE_LIMIT', 'GITHUB_ACCESS_REVOKED', + 'OWNERSHIP_HISTORY_FAILED', 'OWNERSHIP_BLAME_FAILED', 'OWNERSHIP_INTERNAL_ERROR']) { + assert.ok(body.includes(`'${code}'`), `error taxonomy missing ${code}`) + } +}) + +/* ── RLS / grants ────────────────────────────────────────────────────── */ + +test('RLS enabled on all four tables with zero browser-facing policies', () => { + for (const t of 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')) + } + // zero policies anywhere for ownership tables — the only path is service_role + assert.doesNotMatch(MIG, /create\s+policy/i, 'no policies may be created for ownership tables') +}) + +/* ── dormant dashboard RPCs ──────────────────────────────────────────── */ + +test('dash_ownership_* RPCs are STABLE SECURITY INVOKER, tenant-scoped, serve-gated', () => { + for (const fn of ['dash_ownership_scopes', 'dash_ownership_head', 'dash_ownership_timeline']) { + 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, /language sql stable security invoker set search_path = public/i, `${fn} wrong language/security attrs`) + assert.match(body, /p_user uuid/i, `${fn} must accept p_user`) + assert.match(body, /user_id = p_user/i, `${fn} must filter by p_user`) + assert.match(body, /calculation_version = st\.active_version/i, `${fn} must serve active version only`) + assert.doesNotMatch(body, /github_node_id|contributor_key|last_processed_sha|history_cursor|blame_cursor/i, + `${fn} must not return internal identity/cursor fields`) + assert.match(MIG, new RegExp(`revoke all on function public\\.${fn}\\(`, '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`) + } +}) + +/* ── dormancy / no-ingestion guards ──────────────────────────────────── */ + +// The word "ownership" legitimately appears in lib/sync.mjs (lock-claim +// comments) and the ToS (repo ownership) — the dormancy guard targets the +// feature's concrete identifiers instead of the bare word. +const OWNERSHIP_IDENTIFIERS = /ownership_scopes|ownership_contributors|ownership_snapshots|ownership_sync_state|dash_ownership|ownership_sync|identity_kind|contributor_key|scope_path_hash/i + +test('no ownership ingestion or runtime wiring exists in lib/api/src', () => { + for (const dir of ['lib', 'api', 'src']) { + const files = readdirSync(path.join(ROOT, dir), { recursive: true }) + .filter((f) => /\.(mjs|ts|tsx|js|jsx)$/.test(String(f))) + for (const f of files) { + const rel = `${dir}/${String(f).replaceAll('\\', '/')}` + const text = src(rel) + assert.doesNotMatch(text, OWNERSHIP_IDENTIFIERS, + `${rel} references ownership tables/RPCs — ingestion must stay dormant`) + assert.doesNotMatch(text, /blame\(path|history\(path/i, + `${rel} must not issue blame/history(path:) GraphQL calls`) + } + } +}) + +test('no ownership feature flag, env var, cron, or webhook wiring', () => { + assert.doesNotMatch(src('vercel.json'), /OWNERSHIP/i) + assert.doesNotMatch(src('.env.example'), /OWNERSHIP/i) + for (const dir of ['lib', 'api']) { + const files = readdirSync(path.join(ROOT, dir), { recursive: true }) + .filter((f) => /\.(mjs|ts|tsx|js|jsx)$/.test(String(f))) + for (const f of files) { + const rel = `${dir}/${String(f).replaceAll('\\', '/')}` + const text = src(rel) + assert.doesNotMatch(text, /OWNERSHIP_ENABLED|dash_ownership/i, `${rel} must not wire ownership`) + } + } +}) + +test('live SecurityPrivacyPage stays unamended until ownership collects', () => { + const page = src('src/security/SecurityPrivacyPage.tsx') + assert.doesNotMatch(page, /ownership/i, + 'the public trust record changes only when ownership actually collects data') + assert.match(page, /AUTHOR IDENTIFIERS/, 'existing minimization claims must remain intact') +}) diff --git a/test/recovery.test.mjs b/test/recovery.test.mjs index 0c01261..ca3691a 100644 --- a/test/recovery.test.mjs +++ b/test/recovery.test.mjs @@ -69,9 +69,10 @@ test('backup is allowlist-only — explicit --table per BACKUP_TABLES, no exclus const b = src('scripts/backup-db.mjs'); assert.match(b, /BACKUP_TABLES\.map\(.*--table=public\./s, 'must emit --table=public. per allowlisted table'); assert.doesNotMatch(b, /--exclude-table/, 'exclusion-list model silently backs up unclassified tables'); - assert.equal(BACKUP_TABLES.length, 10); + assert.equal(BACKUP_TABLES.length, 14); for (const t of ['users', 'github_installations', 'repositories', 'repository_languages', - 'commits', 'pull_requests', 'repo_coverage', 'repo_sync', 'user_sync', 'feedback']) { + 'commits', 'pull_requests', 'repo_coverage', 'repo_sync', 'user_sync', 'feedback', + 'ownership_scopes', 'ownership_contributors', 'ownership_snapshots', 'ownership_sync_state']) { assert.ok(BACKUP_TABLES.includes(t), `BACKUP_TABLES missing ${t}`); } // nothing outside the allowlist can enter the archive — no whole-db dump From 014dc9862ca805601328f3df15f000cb1a5a87d9 Mon Sep 17 00:00:00 2001 From: aliferous3 Date: Sun, 18 Oct 2026 17:56:36 +0400 Subject: [PATCH 2/2] fix: correct ownership schema checks and drill proofs - month boundary check now evaluates on the UTC wall clock (AT TIME ZONE 'UTC' both ways) instead of following session TimeZone; drill proves Dubai-local midnight is rejected while the true UTC boundary is accepted - linked contributors can no longer squat the reserved synthetic keys 'unlinked'/'other' (ownership_contributors_linked) - mustFail now asserts the intended constraint name + SQLSTATE so malformed inserts cannot masquerade as passing invariant proofs - composite-FK proofs are isolated: scope-FK test uses a scope-total row on a fresh period; contributor-FK test uses a valid repo-B scope - user-delete cascade proven on a dedicated tenant whose repo and all four ownership tables hold live rows when the user row is deleted Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- migrations/012_ownership.sql | 16 ++- scripts/recovery-drill.mjs | 206 +++++++++++++++++++++++++-------- test/ownership-schema.test.mjs | 12 +- 3 files changed, 181 insertions(+), 53 deletions(-) diff --git a/migrations/012_ownership.sql b/migrations/012_ownership.sql index 77e3695..b30b2b6 100644 --- a/migrations/012_ownership.sql +++ b/migrations/012_ownership.sql @@ -101,10 +101,13 @@ create table if not exists public.ownership_contributors ( constraint ownership_contributors_kind check (identity_kind in ('linked', 'unlinked', 'aggregate')), - -- linked rows carry the durable identity pair; synthetic rows never can. + -- linked rows carry the durable identity pair and may never squat on the + -- reserved synthetic keys — contributor_key is unique per repository, so + -- a linked row keyed 'unlinked'/'other' would block the genuine bucket. constraint ownership_contributors_linked check (identity_kind <> 'linked' - or (github_node_id is not null and display_login is not null)), + or (github_node_id is not null and display_login is not null + and contributor_key not in ('unlinked', 'other'))), constraint ownership_contributors_unlinked check (identity_kind <> 'unlinked' @@ -194,13 +197,18 @@ create table if not exists public.ownership_snapshots ( constraint ownership_snapshots_version check (calculation_version > 0), - -- head rows use the fixed sentinel; month rows are first-of-month UTC + -- head rows use the fixed sentinel; month rows are the first instant of + -- the month IN UTC. The month boundary is computed on the UTC wall clock + -- (AT TIME ZONE 'UTC' both ways) so the check does not depend on the + -- session TimeZone. constraint ownership_snapshots_period check ( (period_granularity = 'head' and period_start = '1970-01-01 00:00:00+00'::timestamptz) or (period_granularity = 'month' - and period_start = date_trunc('month', period_start)) + and period_start = + (date_trunc('month', period_start at time zone 'UTC') + at time zone 'UTC')) ), constraint ownership_snapshots_counts diff --git a/scripts/recovery-drill.mjs b/scripts/recovery-drill.mjs index 017e919..eaf2dd5 100644 --- a/scripts/recovery-drill.mjs +++ b/scripts/recovery-drill.mjs @@ -150,61 +150,103 @@ async function seed(sql) { // Runs on drill_src BEFORE the backup: negative inserts all fail at the // constraint/FK level (no rows persist), and throwaway tenants prove repo + // user cascade and the version serve-gate. Net DB effect is zero. +// +// mustFail asserts the SPECIFIC constraint that fired — a malformed insert +// (wrong column count, NOT NULL on an unrelated field) cannot masquerade +// as a passing invariant proof. async function ownershipProofs(sql, { uid, repoId, scopeDir, cLinked, cOther }) { const problems = [] const check = (ok, msg) => { if (!ok) problems.push(msg) } - const mustFail = async (fn, label) => { - try { await fn(); problems.push(`${label}: insert/update must have been rejected`) } - catch { /* constraint worked */ } + const mustFail = async (fn, label, expected = null) => { + try { + await fn() + problems.push(`${label}: insert/update 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 CHK = (name) => ({ name, code: '23514' }) // check_violation + const FK = (name) => ({ name, code: '23503' }) // foreign_key_violation - // ── tenant mismatch: database must reject cross-tenant/cross-repo pairs + // ── tenant mismatch: database must reject cross-tenant/cross-repo pairs. + // Every attempted row is otherwise structurally valid so the ONLY thing + // that can fire is the intended composite FK. const uidB = crypto.randomUUID() const repoB = crypto.randomUUID() + const scopeB = crypto.randomUUID() + const cB = crypto.randomUUID() await sql`insert into users (id, github_user_id, github_login) values (${uidB}, 43434343, 'drill-tenant-b')` await sql`insert into repositories (id, user_id, github_repo_id, owner_login, name, full_name) values (${repoB}, ${uidB}, 323232, 'drill-tenant-b', 'repo-b', 'drill-tenant-b/repo-b')` + // valid repo-B ownership fixtures for isolated composite-FK proofs + await sql`insert into ownership_scopes (id, user_id, repository_id, scope_path_hash, scope_type, depth) + values (${scopeB}, ${uidB}, ${repoB}, 'h-b', 'repo', 0)` + await sql`insert into ownership_contributors (id, user_id, repository_id, contributor_key, identity_kind, github_node_id, display_login) + values (${cB}, ${uidB}, ${repoB}, 'k-bob', 'linked', 'MDQ6VXNlcjIyMg==', 'bob')` await mustFail(() => sql`insert into ownership_scopes (user_id, repository_id, scope_path_hash, scope_type, depth) - values (${uid}, ${repoB}, 'x', 'dir', 1)`, 'tenant A user_id + tenant B repository_id (scope)') - // scope dir needs display_path — the tenant FK must fire regardless of shape - await mustFail(() => sql`insert into ownership_scopes - (user_id, repository_id, scope_path_hash, display_path, scope_type, depth) - values (${uid}, ${repoB}, 'x', 'lib/x', 'dir', 1)`, 'tenant mismatch scope (valid shape)') + values (${uid}, ${repoB}, 'x', 'repo', 0)`, + 'tenant A user_id + tenant B repository_id (scope)', FK('ownership_scopes_repo_tenant')) await mustFail(() => sql`insert into ownership_contributors (user_id, repository_id, contributor_key, identity_kind, github_node_id, display_login) - values (${uid}, ${repoB}, 'k-b', 'linked', 'MDQ6VXNlcjIyMg==', 'bob')`, 'tenant mismatch contributor') + values (${uid}, ${repoB}, 'k-b2', 'linked', 'MDQ6VXNlcjIyMw==', 'bob2')`, + 'tenant mismatch contributor', FK('ownership_contributors_repo_tenant')) + // scope isolation: scope belongs to repo A, repository_id claims repo B, + // contributor NULL (scope-total shape), fresh period so the unique key + // cannot collide — only scope_repo FK can fire. await mustFail(() => sql`insert into ownership_snapshots - (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, touch_commits, calculation_version) - values (${uidB}, ${repoB}, ${scopeDir}, ${cLinked}, 'month', '2026-09-01T00:00:00Z', 1, 1)`, - 'snapshot: scope from repo A + repository_id repo B') + (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, + total_touch_commits, human_contributor_count, calculation_version) + values (${uidB}, ${repoB}, ${scopeDir}, null, 'month', '2026-11-01T00:00:00Z', 3, 1, 1)`, + 'snapshot: scope from repo A + repository_id repo B', FK('ownership_snapshots_scope_repo')) + // contributor isolation: scope belongs to repo B (consistent), contributor + // belongs to repo A — only contrib_repo FK can fire. await mustFail(() => sql`insert into ownership_snapshots - (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, touch_commits, calculation_version) - values (${uidB}, ${repoB}, null, ${cLinked}, 'month', '2026-09-01T00:00:00Z', 1, 1)`, - 'snapshot: contributor from repo A + repository_id repo B') + (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, + touch_commits, calculation_version) + values (${uidB}, ${repoB}, ${scopeB}, ${cLinked}, 'month', '2026-09-01T00:00:00Z', 1, 1)`, + 'snapshot: contributor from repo A + repository_id repo B', FK('ownership_snapshots_contrib_repo')) await mustFail(() => sql`insert into ownership_sync_state (user_id, repository_id) - values (${uid}, ${repoB})`, 'tenant mismatch sync_state') + values (${uid}, ${repoB})`, 'tenant mismatch sync_state', FK('ownership_sync_state_repo_tenant')) - // ── scope shape / type guards + // ── scope shape / type guards — structurally valid rows that violate + // exactly the named invariant + // 'file' violates both the type enum and the shape guard — either may be + // reported; what matters is that no file scope can ever exist. await mustFail(() => sql`insert into ownership_scopes (user_id, repository_id, scope_path_hash, display_path, scope_type, depth) - values (${uid}, ${repoId}, 'f', 'a.ts', 'file', 1)`, 'file scope type') + values (${uid}, ${repoId}, 'f', 'a.ts', 'file', 1)`, 'file scope type', + CHK(['ownership_scopes_type', 'ownership_scopes_shape'])) await mustFail(() => sql`insert into ownership_scopes (user_id, repository_id, scope_path_hash, display_path, scope_type, depth) - values (${uid}, ${repoId}, 'd0', 'x', 'dir', 0)`, 'dir depth 0') + values (${uid}, ${repoId}, 'd0', 'x', 'dir', 0)`, 'dir depth 0', CHK('ownership_scopes_shape')) await mustFail(() => sql`insert into ownership_scopes (user_id, repository_id, scope_path_hash, display_path, scope_type, depth) - values (${uid}, ${repoId}, 'd5', 'a/b/c/d/e', 'dir', 5)`, 'dir depth 5') + values (${uid}, ${repoId}, 'd5', 'a/b/c/d/e', 'dir', 5)`, 'dir depth 5', CHK('ownership_scopes_shape')) await mustFail(() => sql`insert into ownership_scopes - (user_id, repository_id, scope_path_hash, scope_type, depth) - values (${uid}, ${repoId}, 'rp', 'src/x', 'repo', 0)`, 'repo scope with path') + (user_id, repository_id, scope_path_hash, display_path, scope_type, depth) + values (${uid}, ${repoId}, 'rp', 'src/x', 'repo', 0)`, 'repo scope with path', CHK('ownership_scopes_shape')) await mustFail(() => sql`insert into ownership_scopes - (user_id, repository_id, scope_path_hash, scope_type, depth, excluded, exclusion_reason) - values (${uid}, ${repoId}, 'ex1', 'vendor/x', 'dir', 1, true, 'vendor')`, 'exclusion_reason vendor') + (user_id, repository_id, scope_path_hash, display_path, scope_type, depth, excluded, exclusion_reason) + values (${uid}, ${repoId}, 'ex1', 'vendor/x', 'dir', 1, true, 'vendor')`, + 'exclusion_reason vendor', CHK('ownership_scopes_exclusion')) await mustFail(() => sql`insert into ownership_scopes - (user_id, repository_id, scope_path_hash, scope_type, depth, excluded, exclusion_reason) - values (${uid}, ${repoId}, 'ex2', 'x/y', 'dir', 1, false, 'cardinality_cap')`, 'excluded=false with reason') + (user_id, repository_id, scope_path_hash, display_path, scope_type, depth, excluded, exclusion_reason) + values (${uid}, ${repoId}, 'ex2', 'x/y', 'dir', 1, false, 'cardinality_cap')`, + 'excluded=false with reason', CHK('ownership_scopes_exclusion')) // legal cardinality_cap row is allowed — insert then delete to keep seed counts const capId = crypto.randomUUID() await sql`insert into ownership_scopes (id, user_id, repository_id, scope_path_hash, display_path, scope_type, depth, excluded, exclusion_reason) @@ -214,22 +256,37 @@ async function ownershipProofs(sql, { uid, repoId, scopeDir, cLinked, cOther }) // ── identity-shape guards await mustFail(() => sql`insert into ownership_contributors (user_id, repository_id, contributor_key, identity_kind, github_node_id, display_login) - values (${uid}, ${repoId}, 'k-noid', 'linked', null, 'bob')`, 'linked without node id') + values (${uid}, ${repoId}, 'k-noid', 'linked', null, 'bob')`, + 'linked without node id', CHK('ownership_contributors_linked')) + await mustFail(() => sql`insert into ownership_contributors + (user_id, repository_id, contributor_key, identity_kind, github_node_id, display_login) + values (${uid}, ${repoId}, 'k-nologin', 'linked', 'MDQ6VXNlcjMzMw==', null)`, + 'linked without login', CHK('ownership_contributors_linked')) + // reserved synthetic keys can never be squatted by a linked row await mustFail(() => sql`insert into ownership_contributors (user_id, repository_id, contributor_key, identity_kind, github_node_id, display_login) - values (${uid}, ${repoId}, 'k-nologin', 'linked', 'MDQ6VXNlcjMzMw==', null)`, 'linked without login') + values (${uid}, ${repoId}, 'unlinked', 'linked', 'MDQ6VXNlcjMzNA==', 'squatter')`, + 'linked row keying unlinked', CHK('ownership_contributors_linked')) await mustFail(() => sql`insert into ownership_contributors (user_id, repository_id, contributor_key, identity_kind, github_node_id, display_login) - values (${uid}, ${repoId}, 'unlinked2', 'unlinked', 'MDQ6VXNlcjQ0NA==', null)`, 'unlinked with node id') + values (${uid}, ${repoId}, 'other', 'linked', 'MDQ6VXNlcjMzNQ==', 'squatter')`, + 'linked row keying other', CHK('ownership_contributors_linked')) + await mustFail(() => sql`insert into ownership_contributors + (user_id, repository_id, contributor_key, identity_kind, github_node_id, display_login) + values (${uid}, ${repoId}, 'unlinked2', 'unlinked', 'MDQ6VXNlcjQ0NA==', null)`, + 'unlinked with node id', CHK('ownership_contributors_unlinked')) await mustFail(() => sql`insert into ownership_contributors (user_id, repository_id, contributor_key, identity_kind) - values (${uid}, ${repoId}, 'k-x', 'unlinked')`, 'unlinked with wrong key') + values (${uid}, ${repoId}, 'k-x', 'unlinked')`, 'unlinked with wrong key', CHK('ownership_contributors_unlinked')) await mustFail(() => sql`insert into ownership_contributors (user_id, repository_id, contributor_key, identity_kind, is_bot) - values (${uid}, ${repoId}, 'other2', 'aggregate', false)`, 'aggregate with wrong key') + values (${uid}, ${repoId}, 'other2', 'aggregate', false)`, 'aggregate with wrong key', CHK('ownership_contributors_aggregate')) + // violates both the unlinked shape (is_bot=false) and the linked-only bot + // rule — either constraint may be reported. await mustFail(() => sql`insert into ownership_contributors (user_id, repository_id, contributor_key, identity_kind, is_bot) - values (${uid}, ${repoId}, 'unlinked-bot', 'unlinked', true)`, 'is_bot on unlinked') + values (${uid}, ${repoId}, 'unlinked-bot', 'unlinked', true)`, 'is_bot on unlinked', + CHK(['ownership_contributors_unlinked', 'ownership_contributors_bot'])) // bot must be linked — this insert is legal const botId = crypto.randomUUID() await sql`insert into ownership_contributors (id, user_id, repository_id, contributor_key, identity_kind, github_node_id, display_login, is_bot) @@ -240,39 +297,69 @@ async function ownershipProofs(sql, { uid, repoId, scopeDir, cLinked, cOther }) await mustFail(() => sql`insert into ownership_snapshots (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, touch_commits, calculation_version) values (${uid}, ${repoId}, ${scopeDir}, ${cLinked}, 'month', '2026-09-01T00:00:00Z', 5, 0)`, - 'calculation_version 0') + 'calculation_version 0', CHK('ownership_snapshots_version')) await mustFail(() => sql`insert into ownership_snapshots (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, touch_share, calculation_version) values (${uid}, ${repoId}, ${scopeDir}, ${cLinked}, 'month', '2026-09-01T00:00:00Z', 1.5, 1)`, - 'share > 1') + 'share > 1', CHK('ownership_snapshots_shares')) await mustFail(() => sql`insert into ownership_snapshots (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, touch_commits, calculation_version) values (${uid}, ${repoId}, ${scopeDir}, ${cLinked}, 'month', '2026-09-15T00:00:00Z', 1, 1)`, - 'month not first-of-month') + 'month not first-of-month', CHK('ownership_snapshots_period')) await mustFail(() => sql`insert into ownership_snapshots (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, surviving_lines, calculation_version) values (${uid}, ${repoId}, ${scopeDir}, ${cLinked}, 'head', '2026-01-01T00:00:00Z', 1, 1)`, - 'head without sentinel period_start') + 'head without sentinel period_start', CHK('ownership_snapshots_period')) await mustFail(() => sql`insert into ownership_snapshots (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, touch_commits, surviving_lines, calculation_version) values (${uid}, ${repoId}, ${scopeDir}, ${cLinked}, 'month', '2026-09-01T00:00:00Z', 3, 7, 1)`, - 'monthly contributor row carrying head fields') + 'monthly contributor row carrying head fields', CHK('ownership_snapshots_shape')) await mustFail(() => sql`insert into ownership_snapshots (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, surviving_lines, touch_commits, calculation_version) values (${uid}, ${repoId}, ${scopeDir}, ${cLinked}, 'head', '1970-01-01T00:00:00Z', 5, 3, 1)`, - 'head contributor row carrying monthly fields') + 'head contributor row carrying monthly fields', CHK('ownership_snapshots_shape')) await mustFail(() => sql`insert into ownership_snapshots (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, total_touch_commits, touch_commits, calculation_version) values (${uid}, ${repoId}, ${scopeDir}, null, 'month', '2026-10-01T00:00:00Z', 4, 4, 1)`, - 'scope-total row carrying contributor fields') + 'scope-total row carrying contributor fields', CHK('ownership_snapshots_shape')) + + // ── month boundary is UTC, not session-timezone ── + // On a dedicated connection (reserved so SET TIME ZONE sticks): in + // Asia/Dubai, 2026-08-31T20:00:00Z is local-midnight Sep 1 — it must be + // REJECTED while true UTC boundary 2026-09-01T00:00:00Z is accepted. + { + const conn = await sql.reserve() + try { + await conn`set time zone 'Asia/Dubai'` + const okId = crypto.randomUUID() + await conn`insert into ownership_snapshots + (id, user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, touch_commits, calculation_version) + values (${okId}, ${uid}, ${repoId}, ${scopeDir}, ${cLinked}, 'month', '2026-10-01T00:00:00Z', 2, 1)` + await conn`delete from ownership_snapshots where id = ${okId}` + let rejected = false + try { + await conn`insert into ownership_snapshots + (user_id, repository_id, scope_id, contributor_id, period_granularity, period_start, touch_commits, calculation_version) + values (${uid}, ${repoId}, ${scopeDir}, ${cLinked}, 'month', '2026-08-31T20:00:00Z', 2, 1)` + } catch (e) { + rejected = String(e.message || '').includes('ownership_snapshots_period') + || (e.constraint_name || e.constraint) === 'ownership_snapshots_period' + if (!rejected) problems.push(`non-UTC boundary rejected by wrong constraint: ${e.message}`) + } + if (!rejected) problems.push('Asia/Dubai-local midnight 2026-08-31T20:00Z was accepted as month boundary — UTC check broken') + await conn`reset time zone` + } finally { + conn.release() + } + } // ── sync_state guards await mustFail(() => sql`insert into ownership_sync_state (user_id, repository_id, phase) - values (${uid}, ${repoId}, 'bogus')`, 'invalid phase') + values (${uid}, ${repoId}, 'bogus')`, 'invalid phase', CHK('ownership_sync_state_phase')) await mustFail(() => sql`insert into ownership_sync_state (user_id, repository_id, error) - values (${uid}, ${repoId}, 'ECONNRESET upstream socket hangup')`, 'raw error text') + values (${uid}, ${repoId}, 'ECONNRESET upstream socket hangup')`, 'raw error text', CHK('ownership_sync_state_error')) await mustFail(() => sql`insert into ownership_sync_state (user_id, repository_id, active_version, building_version) - values (${uid}, ${repoId}, 1, 1)`, 'active = building') + values (${uid}, ${repoId}, 1, 1)`, 'active = building', CHK('ownership_sync_state_versions')) // closed-taxonomy error is legal 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}` @@ -340,14 +427,37 @@ async function ownershipProofs(sql, { uid, repoId, scopeDir, cLinked, cOther }) 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)`) } - // user delete cascades every ownership table (E) — uidC's repo already - // went with it; verify the whole tenant set is gone. await sql`delete from users where id = ${uidC}` + + // ── user-delete cascade proof on a SEPARATE tenant: the user row is + // removed directly while its repository + all four ownership tables hold + // live rows — exercising users -> repositories -> ownership_* through + // the real FK chain (E). + const uidD = crypto.randomUUID() + const repoD = crypto.randomUUID() + const scopeD = crypto.randomUUID() + const cD = crypto.randomUUID() + await sql`insert into users (id, github_user_id, github_login) values (${uidD}, 46464646, 'drill-tenant-d')` + await sql`insert into repositories (id, user_id, github_repo_id, owner_login, name, full_name) + values (${repoD}, ${uidD}, 343434, 'drill-tenant-d', 'repo-d', 'drill-tenant-d/repo-d')` + await sql`insert into ownership_scopes (id, user_id, repository_id, scope_path_hash, scope_type, depth) + 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_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']) { - const [{ c }] = await sql`select count(*)::int c from ${sql(t)} where user_id = ${uidC}` - check(c === 0, `${t} rows survived user delete (user cascade broken)`) + 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)`) } - // tenant B cleanup — its empty repo row also exercises user cascade + const [{ c: repoDLeft }] = await sql`select count(*)::int c from repositories where id = ${repoD}` + check(repoDLeft === 0, 'repositories row survived user delete') + + // tenant B cleanup — its remaining rows exercise user cascade too await sql`delete from users where id = ${uidB}` return problems diff --git a/test/ownership-schema.test.mjs b/test/ownership-schema.test.mjs index 2cf775f..0ded4c6 100644 --- a/test/ownership-schema.test.mjs +++ b/test/ownership-schema.test.mjs @@ -112,6 +112,8 @@ test('identity kinds are a closed set with shape-separating checks', () => { assert.match(body, /is_bot = false or identity_kind = 'linked'/i, 'bots must be linked-only') assert.match(body, /github_node_id is not null and display_login is not null/i, 'linked rows must require the durable identity pair') + assert.match(body, /contributor_key not in \('unlinked', 'other'\)/i, + 'linked rows must not squat the reserved synthetic keys') assert.match(body, /first_seen_at <= last_seen_at/i) assert.match(body, /unique \(repository_id, contributor_key\)/i) }) @@ -135,7 +137,15 @@ test('period rules: month is first-of-month UTC, head is the fixed sentinel', () const body = tableSql('ownership_snapshots') assert.match(body, /check \(period_granularity in \('month', 'head'\)\)/i) assert.match(body, /'1970-01-01 00:00:00\+00'::timestamptz/, 'head sentinel missing') - assert.match(body, /date_trunc\('month', period_start\)/i, 'month boundary check missing') + // month boundary must be computed on the UTC wall clock — a bare + // date_trunc('month', timestamptz) follows the session TimeZone. + assert.match( + body, + /date_trunc\('month', period_start at time zone 'UTC'\)\s*at time zone 'UTC'/i, + 'month boundary must be computed explicitly in UTC', + ) + assert.doesNotMatch(body, /date_trunc\('month', period_start\)/i, + 'session-timezone-dependent date_trunc must not appear') assert.match(body, /calculation_version > 0/i) })