ID-115 {115.2} PRODUCT — Data API schema isolation (INV-1..21)
PRODUCT — Data API Schema Isolation (public unexposed → api exposed)
Section titled “PRODUCT — Data API Schema Isolation (public unexposed → api exposed)”Status: PRODUCT spec. Behaviour invariants for the move of the Supabase Data API
(PostgREST) from exposing public to exposing a dedicated api schema. All six §6 recon
decisions are ratified at their stated leans. Recon: specs/id-115-data-api-schema-isolation/notes/data-api-schema-isolation-recon.md.
Surface inventory: /tmp/claude/data-api-surface.md.
Each invariant is numbered, testable, and carries its verification. Categories: security,
behavior-preservation, maintenance, provisioning. These are the acceptance gates the
TECH design (data-api-isolation-TECH.md) maps its changes onto, and the PLAN
(data-api-isolation-PLAN.md) sequences to satisfy.
INV-1 — public is not exposed (PGRST106 boundary) · security
Section titled “INV-1 — public is not exposed (PGRST106 boundary) · security”The public schema is NOT in the project’s PostgREST exposed-schemas list on local, staging, and
prod. Any anon/authenticated/service-role Data API request naming a public object — e.g.
.from('content_items') without an api view, or .schema('public').from(...) from a non-service
client — returns PostgREST error PGRST106 (schema not exposed), not data.
Verification: Negative integration test — a raw PostgREST request (or supabase-js with
.schema('public')) against content_items and at least one RPC asserts HTTP 406 / PGRST106. Plus
a config assertion that supabase/config.toml [api] schemas does not contain public, and the
remote exposed-schemas (read via Management API / dashboard) matches.
INV-2 — api is the only exposed app schema · security
Section titled “INV-2 — api is the only exposed app schema · security”The api schema is the ONLY schema exposed to the Data API for application objects:
config.toml [api] schemas = ["api"] (with graphql_public dropped per decision 5, public
removed). anon + authenticated hold USAGE on api only via migration, not dashboard DDL.
Verification: Config-file assertion on [api] schemas; a CI lint that fails if public or
graphql_public reappear; a migration-presence check that create schema api +
grant usage on schema api to anon, authenticated exist in supabase/migrations/.
INV-3 — every view is security_invoker (RLS preserved) · security
Section titled “INV-3 — every view is security_invoker (RLS preserved) · security”Every one of the 60 api views is created WITH (security_invoker = true), so RLS on the
underlying public base table is enforced for the calling role. A non-owner authenticated user
reading/writing through an api view sees and mutates only rows their base-table RLS policy
permits — identical row visibility to the pre-switch public-exposed behaviour.
Verification: Supabase advisor lint 0010_security_definer_view returns zero findings (CI gate);
a catalog query (pg_class.reloptions) asserts security_invoker=true on all api.* views; RLS
behavioural test — authenticated user A cannot read/update workspace rows owned by user B through
api.workspaces (the same assertion that passed against public.workspaces pre-switch).
INV-4 — every .from() target has an api view (incl. dynamic-only) · behavior-preservation
Section titled “INV-4 — every .from() target has an api view (incl. dynamic-only) · behavior-preservation”All 60 tables/views in the Data API surface — the 57 string-literal .from() targets PLUS the 3
dynamic-only tables signup_policy, tenant_config, content_propagation_version — have a
corresponding api.<name> view, so every .from() call site (static and dynamic) resolves under
the api default schema.
Verification: Catalog query asserts an api view exists for each of the 60 names (driven from an
enumerated list that includes the 3 dynamic-only tables); a drift test maps every .from('literal')
and each known dynamic resolution from the surface inventory to an existing api view.
INV-5 — every .rpc() has an api entrypoint with a satisfying signature · behavior-preservation
Section titled “INV-5 — every .rpc() has an api entrypoint with a satisfying signature · behavior-preservation”All 58 distinct RPCs in the .rpc() surface have a callable api.<fn> entrypoint whose signature
(scalar/SETOF/TABLE return type and argument list) satisfies every call site. The 8 client-callable
RPCs (filter_by_keywords, get_items_with_quality_flags, get_user_tag_counts,
get_entity_summary, get_filter_counts, get_unique_authors, find_related_items, toggle_star)
are reachable by authenticated/anon as appropriate; the remaining server-side RPCs by
service_role.
Verification: Catalog query asserts an api function exists for each of the 58 names; a per-RPC
smoke test (or the existing MCP/route suite re-run under the api default schema) exercises each
entrypoint and asserts a non-error response shape matching the pre-switch result.
INV-6 — DEFINER RPCs reached only via thin api invoker wrappers · security
Section titled “INV-6 — DEFINER RPCs reached only via thin api invoker wrappers · security”The ~7 SECURITY DEFINER functions that are .rpc()-reachable (q_a_search, q_a_get_verbatim,
question_match_search, question_match_recompute, reference_search, reference_get_verbatim,
reference_ingest) are exposed only via thin api SECURITY INVOKER wrappers that delegate to the
public definer. The definer bodies remain in unexposed public and are never directly
.rpc()-callable.
Verification: Catalog query asserts each of the 7 api wrappers has prosecdef=false (invoker)
while the public target has prosecdef=true; negative test asserts .rpc('q_a_search') resolving
to public.q_a_search directly returns PGRST106; functional test asserts the api wrapper returns
the same result as the pre-switch direct call.
INV-7 — trigger/internal/test DEFINER functions are NOT exposed · security
Section titled “INV-7 — trigger/internal/test DEFINER functions are NOT exposed · security”SECURITY DEFINER trigger/internal/test functions (handle_new_user, rls_auto_enable,
grant_standard_public_table_access, _test_*, etc.) are NOT exposed in api and have no api
wrapper; they remain reachable only internally (triggers/event-triggers/migrations), never via the
Data API.
Verification: Catalog query asserts none of the named definer trigger/internal/test functions
exist as api.* objects; the advisor lint and the negative PGRST106 test cover their non-exposure.
INV-8 — 1:1 views are auto-updatable (writes preserved) · behavior-preservation
Section titled “INV-8 — 1:1 views are auto-updatable (writes preserved) · behavior-preservation”The 1:1 single-table api views are auto-updatable: .insert(), .update(), and .delete()
through api.<view> propagate to the public base table and are gated by the base table’s RLS
WITH CHECK/USING and column grants — no INSTEAD OF triggers required, and writes succeed/fail
identically to the pre-switch public path.
Verification: Write-path integration test through api.content_items and a representative sample
of the ~48 read+write tables: authenticated insert/update/delete succeeds for permitted rows and is
rejected (RLS violation) for non-permitted rows, matching pre-switch assertions.
INV-9 — explicit column lists, superset of call-site columns · behavior-preservation
Section titled “INV-9 — explicit column lists, superset of call-site columns · behavior-preservation”api views use explicit column lists (not SELECT *) and are regenerated via DROP/CREATE. The
projected columns of each view are a superset of (or equal to) the columns actually read/written by
its call sites, so no call site loses a column after the switch. Generated/identity columns
(e.g. content_items.content_text_hash) are included as plain passthrough columns — selectable but
never required on insert.
Verification: Generator-output check — each api view DDL enumerates columns; a column-coverage
test cross-references view columns against columns referenced in .select()/.insert()/.update() for
that table (or, minimally, against the public base-table column set) and fails on any missing
column; gen-types output (INV-13) compiling is a backstop.
INV-10 — least-privilege per-view grants; base grants retained · security
Section titled “INV-10 — least-privilege per-view grants; base grants retained · security”Per-view Data API grants are least-privilege: anon holds SELECT only; authenticated and
service_role hold SELECT, INSERT, UPDATE, DELETE on each api view as required by that view’s
read/write mode (the 9 READ-only tables grant no write to anon and only the writes their call sites
need). Base-table grants in public (via grant_standard_public_table_access) are RETAINED because
security_invoker requires underlying-table privileges.
Verification: Repurposed revoke-guard / api-grant lint (decision 4) asserts the grant matrix per
api view (anon=SELECT, authenticated/service_role=CRUD-as-needed) and fails on over-grant
(e.g. anon INSERT); catalog query confirms public base tables still carry their standard grants.
INV-11 — all client factories thread a shared db.schema='api' · behavior-preservation
Section titled “INV-11 — all client factories thread a shared db.schema='api' · behavior-preservation”All five client factory groups (browser lib/supabase/client.ts; SSR + service-role
lib/supabase/server.ts; MCP lib/mcp/auth.ts; ~37 inline-createClient scripts) plus
scripts/bid_worker.py thread a shared { db: { schema: 'api' } } (Python
ClientOptions(schema='api')) default, so .from('x')→api.x and .rpc('y')→api.y with no
per-call-site rewrites — including the 22 dynamic .from(variable) sites. Test/e2e/eval clients that
bypass the factories also thread the option (see INV-21).
Verification: Static assertion/test that each factory constructs its client with
db.schema='api'; an ast-dataflow/grep check that no call site sets a conflicting per-call schema;
the dynamic-site coverage is proven by INV-4’s drift test resolving all 22 variable targets to api
views.
INV-12 — public escape hatch only on server/service client · security
Section titled “INV-12 — public escape hatch only on server/service client · security”Rare direct-admin/service paths that must reach public do so ONLY via an explicit .schema('public')
override on the server/service-role client (never anon/authenticated), and such overrides are
enumerated and intentional — anon/authenticated clients have no public escape hatch.
Verification: Grep/ast-dataflow inventory of .schema('public') invocations asserts each is on a
service-role or server client in an admin-guarded/cron/pipeline path; a test asserts no browser/anon
client invokes .schema('public').
INV-13 — gen-types are --schema public,api and the app type-checks · maintenance
Section titled “INV-13 — gen-types are --schema public,api and the app type-checks · maintenance”Generated TypeScript types are produced with supabase gen types typescript --schema public,api
(both schemas explicit), and the app type-checks (tsc) cleanly against the api-schema client
default — Database['api'] carries the view/RPC row shapes the code consumes, and
Tables<'x'>/z.infer usages remain sound.
Verification: bun build / tsc --noEmit passes post-regen; a check asserts the gen-types
command/string in supabase/CLAUDE.md and any regen script includes --schema public,api; CI fails
if database.types.ts lacks an api schema key.
INV-14 — pipeline / auth / storage are unaffected · behavior-preservation
Section titled “INV-14 — pipeline / auth / storage are unaffected · behavior-preservation”The cocoindex ingestion pipeline (scripts/cocoindex_pipeline/*, asyncpg direct-to-Postgres writing
public.* via search_path), GoTrue auth (supabase.auth.*), and Storage
(supabase.storage.from(documents|templates|branding)) are UNAFFECTED by the Data API schema switch
and continue to operate against their existing targets.
Verification: Python pipeline tests (python3 -m pytest scripts/tests/) pass unchanged; an auth
login E2E and a storage upload/download E2E pass post-switch; grep confirms no asyncpg/auth/storage
path was rerouted through api.
INV-15 — the posture is fully migration+config reproducible · provisioning
Section titled “INV-15 — the posture is fully migration+config reproducible · provisioning”The entire posture (schema api + USAGE grants + 60 views + 58 RPC entrypoints/wrappers +
least-privilege grants) is reproduced by checked-in MIGRATIONS plus supabase/config.toml, not
dashboard DDL. supabase db reset --local rebuilds it from zero, and the migration is
idempotent/replayable so staging, prod, the re-ingest preview branch, and Platform converge with no
manual dashboard step — closing the un-migrated-DDL drift class that broke S118/S176.
Verification: supabase db reset --local exits 0 and produces all api objects; schema-parity
workflow (schema-parity.yml) shows prod==staging after apply; absence-check that the change is in
supabase/migrations/ + config.toml (not only a dashboard mutation); migration replays cleanly
twice (idempotency).
INV-16 — CI drift-check keeps the surface honest; generator idempotent · maintenance
Section titled “INV-16 — CI drift-check keeps the surface honest; generator idempotent · maintenance”A CI drift-check fails the build if any public base table reachable by the app lacks a
corresponding api view (or any .rpc()-surface function lacks an api entrypoint), keeping the
surface honest as in-flight tasks ID-50/70/71/104 continue adding tables and RPCs. The
view/entrypoint generator is idempotent (re-running produces no diff).
Verification: A drift-check script (new or repurposed revoke-guard) enumerates public base
tables + .rpc surface and asserts 1:1 api coverage, exiting non-zero on a gap; running the
generator twice yields an empty git diff (idempotency test).
INV-17 — ensure_rls event trigger retained · maintenance
Section titled “INV-17 — ensure_rls event trigger retained · maintenance”The ensure_rls event trigger / rls_auto_enable() is RETAINED (decision 3): any newly created
public base table automatically gets RLS enabled, so the defence-in-depth row gate is never
silently absent on tables added by ID-104/70/71 et al.
Verification: Catalog query asserts the ensure_rls event trigger exists post-switch; a test
creates a new public table in a transaction and asserts relrowsecurity=true was auto-set.
INV-18 — ID-70 RPCs built to FINAL RETURNS TABLE signature · behavior-preservation
Section titled “INV-18 — ID-70 RPCs built to FINAL RETURNS TABLE signature · behavior-preservation”The 5 RPCs being changed by ID-70 from RETURNS Json to RETURNS TABLE (get_user_tag_counts,
get_workspace_counts, merge_entities, get_dashboard_attention_counts, get_filter_counts) get
their api entrypoints built to the FINAL RETURNS TABLE signature (sequenced with/after ID-70),
avoiding a double-wrap and a redundant gen-types regen. The api-schema type-gen change is folded into
the ID-70 / ID-64.8-cutover regen rather than adding an extra regen pass.
Verification: Signature catalog query asserts these 5 api entrypoints return the TABLE shape
(not json); a sequencing check that the api-schema --schema public,api regen commit coincides with
the ID-70/cutover regen (single database.types.ts rewrite, no intermediate json-shape generation).
INV-19 — atomicity per remote (no half-applied window) · security
Section titled “INV-19 — atomicity per remote (no half-applied window) · security”At each remote, the exposed-schemas flip ([api] schemas), the api objects
(views+functions+grants), and the client db.schema='api' switch land together, so no window exists
where reads 404/PGRST106 against a half-applied state. On prod (already half-flipped: exposed-schemas
changed, api empty) applying the migration COMPLETES the flip and restores Data API function.
Verification: Staging/prod post-deploy smoke test (login + dashboard read + content read+write + one MCP tool + one client RPC) returns 200/data not PGRST106; a deploy-ordering check (runbook/CI) ties the migration apply, config change, and client-code deploy to the same release.
INV-20 — definer grant hygiene does not regress · security
Section titled “INV-20 — definer grant hygiene does not regress · security”The retired per-function REVOKE EXECUTE ... FROM anon discipline does NOT regress security:
sensitive public SECURITY DEFINER functions retain light defence-in-depth REVOKEs (they run as
postgres with bypassrls even when reached indirectly), and set_config remains the only
intentionally anon-executable allow-listed function.
Verification: Catalog query asserts set_config is the sole anon-EXECUTE-granted public
function and that named sensitive definers (q_a_, reference_) retain their REVOKEs; the repurposed
lint covers this in place of the old per-migration REVOKE rule.
INV-21 — embedded selects, upserts, and counts work through views · behavior-preservation
Section titled “INV-21 — embedded selects, upserts, and counts work through views · behavior-preservation”PostgREST resource embedding, onConflict upserts, and count/head:true requests that worked
against public continue to work through the api views and resolve to the same data:
- Every FK column is present verbatim (no alias/expr) in each view so PostgREST view-relationship
inference fires; multi-hop embeds (dashboard/reorient 3-level chains) and self-joins
(
content_items.parent_id/superseded_by) resolve, OR are served by an explicitapicomputed-relationship function / retained DEFINER RPC. onConflictupserts (28 call sites) arbitrate against the base-table unique index through the auto-updatable view, OR are converted to anapiRPC /.schema('public')service path.count-with-embed requests (e.g. the intelligence metrics route) return correct counts.- The generated
apiViews carryRelationshipsso nested-select result types are inferred (notnever/unknown).
Verification: A dedicated embedding/upsert/count smoke set runs against a local db reset and
asserts no PGRST200 (relationship), no 42P10 (conflict-target), and correct counts — table-by-table
across the 39+ embed sites, 28 upsert sites, and the count-with-embed sites; a post-regen type check
confirms embedded fields are typed (not never). This is the highest-blast-radius gate and runs
before any remote apply.