Summary
The Postgres dialect discovers foreign keys through information_schema.table_constraints / key_column_usage / constraint_column_usage. Postgres restricts those views to the table owner (or a role holding a privilege other than SELECT). A least-privilege SELECT-only role therefore sees zero declared foreign keys, introspection silently falls back to name+type matching, and the inferred joins can point at the wrong parent table.
This matters now because ACE-036 (merged in 41cfd7d) makes the SELECT-only role the required DATASOURCE_URL posture for a deploy. The documented-correct deployment is exactly the one that loses every FK.
Severity: sev-2. No data loss or exposure, and the degraded joins land as confidence: proposed / review_state: unreviewed, so the review queue is a human gate in front of them. But they are usable while unreviewed (Rule 2), they are wrong rather than absent, and the resulting numbers reconcile at the top line while the breakdown is badly off — the failure mode a trust layer exists to prevent.
Affects PostgreSQL and Redshift (which subclasses it, dialects.py:246). Other information_schema-based dialects (MySQL, SQL Server, BigQuery, Oracle) were not tested and should be checked — MySQL's key_column_usage has different, more permissive visibility rules.
Root cause
packages/agami-core/src/semantic_model/dialects.py:194-209 builds the FK query from information_schema:
SELECT kcu.table_name AS from_table, kcu.column_name AS from_column, ...
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu ...
JOIN information_schema.constraint_column_usage ccu ...
WHERE tc.constraint_type = 'FOREIGN KEY' ...
Per the Postgres docs, table_constraints shows only constraints on tables the current user owns or has a privilege other than SELECT on, and constraint_column_usage is owner-only. introspect.py:678 gates the declared-FK pass on catalog_ok and catalog_fks, so an empty result is indistinguishable from "this engine has no FKs" and the name-matching path takes over (introspect.py:657-711).
pg_catalog.pg_constraint carries no such restriction and is readable by any role.
Repro
Uses the shipped synthetic sample (plugins/agami/samples/store) — no customer data.
- Load the sample into Postgres as an owner role (the SQLite DDL is ANSI-shaped;
REFERENCES constraints survive verbatim). 12 FKs result.
- Create the read-only role using the shipped recipe,
plugins/agami/shared/readonly-grants.md:
CREATE USER agami_ro WITH PASSWORD 'ro_pw';
GRANT CONNECT ON DATABASE agami_ace_test TO agami_ro;
GRANT USAGE ON SCHEMA public TO agami_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO agami_ro;
ALTER DEFAULT PRIVILEGES FOR ROLE store_owner IN SCHEMA public GRANT SELECT ON TABLES TO agami_ro;
DATASOURCE_URL=postgresql://agami_ro:ro_pw@localhost:5432/agami_ace_test python -m semantic_model.cli introspect --profile store --db-type postgres --artifacts ./artifacts
- Repeat with
DATASOURCE_URL pointed at the owner role.
Expected vs actual
- Expected: the same 12
confirmed FK-derived relationships under either role — a SELECT-only login should not change what the model believes about the schema.
- Actual: as owner, 12 relationships, all
confirmed, all correct. As agami_ro, 9 relationships, all proposed, two wrong and three missing:
|
as agami_ro |
as owner |
| relationships built |
9 (proposed) |
12 (confirmed) |
payments.order_id → |
order_items ✗ |
orders ✓ |
invoices.subscription_id → |
subscription_events ✗ |
subscriptions ✓ |
products → categories |
missing |
present |
subscription_events → plans (from_plan_id, to_plan_id) |
missing |
present |
The value-overlap probe cannot discriminate here: order_items.id spans 1..10000 and payments.order_id spans 1..4000, so the wrong parent overlaps perfectly.
Evidence
Same database, same 12 constraints in pg_constraint:
| catalog query |
as agami_ro |
as owner |
information_schema.table_constraints where constraint_type='FOREIGN KEY' |
0 |
12 |
information_schema.constraint_column_usage |
0 |
— |
pg_catalog.pg_constraint where contype='f' |
12 |
12 |
Impact of the payments → order_items mis-join on a routine question ("payment revenue by order status"). The total is identical either way — 5,950,666 across 3,800 payments — so nothing looks wrong until you break it down:
| status |
correct |
via the inferred join |
error |
| pending |
80,794 |
317,829 |
+293% |
| refunded |
1,049,264 |
607,646 |
−42% |
| paid |
685,799 |
577,059 |
−16% |
| shipped |
1,087,295 |
1,190,897 |
+10% |
| delivered |
3,047,516 |
2,955,422 |
−3% |
| cancelled |
— |
301,814 |
bucket that shouldn't exist |
Fix
Read declared FKs from pg_catalog.pg_constraint (joining pg_attribute for the column names), which every role can see, instead of the owner-restricted information_schema views. Worth also making an empty FK result distinguishable from "engine has no FK support" at introspect.py:678, so a privilege-shaped zero can be reported rather than silently downgrading to name matching.
Related: ACE-036 (41cfd7d) — the spec that makes this posture required.
Summary
The Postgres dialect discovers foreign keys through
information_schema.table_constraints/key_column_usage/constraint_column_usage. Postgres restricts those views to the table owner (or a role holding a privilege other than SELECT). A least-privilegeSELECT-only role therefore sees zero declared foreign keys, introspection silently falls back to name+type matching, and the inferred joins can point at the wrong parent table.This matters now because ACE-036 (merged in 41cfd7d) makes the SELECT-only role the required
DATASOURCE_URLposture for a deploy. The documented-correct deployment is exactly the one that loses every FK.Severity: sev-2. No data loss or exposure, and the degraded joins land as
confidence: proposed/review_state: unreviewed, so the review queue is a human gate in front of them. But they are usable while unreviewed (Rule 2), they are wrong rather than absent, and the resulting numbers reconcile at the top line while the breakdown is badly off — the failure mode a trust layer exists to prevent.Affects
PostgreSQLandRedshift(which subclasses it,dialects.py:246). Otherinformation_schema-based dialects (MySQL, SQL Server, BigQuery, Oracle) were not tested and should be checked — MySQL'skey_column_usagehas different, more permissive visibility rules.Root cause
packages/agami-core/src/semantic_model/dialects.py:194-209builds the FK query frominformation_schema:Per the Postgres docs,
table_constraintsshows only constraints on tables the current user owns or has a privilege other than SELECT on, andconstraint_column_usageis owner-only.introspect.py:678gates the declared-FK pass oncatalog_ok and catalog_fks, so an empty result is indistinguishable from "this engine has no FKs" and the name-matching path takes over (introspect.py:657-711).pg_catalog.pg_constraintcarries no such restriction and is readable by any role.Repro
Uses the shipped synthetic sample (
plugins/agami/samples/store) — no customer data.REFERENCESconstraints survive verbatim). 12 FKs result.plugins/agami/shared/readonly-grants.md:DATASOURCE_URL=postgresql://agami_ro:ro_pw@localhost:5432/agami_ace_test python -m semantic_model.cli introspect --profile store --db-type postgres --artifacts ./artifactsDATASOURCE_URLpointed at the owner role.Expected vs actual
confirmedFK-derived relationships under either role — a SELECT-only login should not change what the model believes about the schema.confirmed, all correct. Asagami_ro, 9 relationships, allproposed, two wrong and three missing:agami_roproposed)confirmed)payments.order_id→order_items✗orders✓invoices.subscription_id→subscription_events✗subscriptions✓products → categoriessubscription_events → plans(from_plan_id,to_plan_id)The value-overlap probe cannot discriminate here:
order_items.idspans 1..10000 andpayments.order_idspans 1..4000, so the wrong parent overlaps perfectly.Evidence
Same database, same 12 constraints in
pg_constraint:agami_roinformation_schema.table_constraintswhereconstraint_type='FOREIGN KEY'information_schema.constraint_column_usagepg_catalog.pg_constraintwherecontype='f'Impact of the
payments → order_itemsmis-join on a routine question ("payment revenue by order status"). The total is identical either way — 5,950,666 across 3,800 payments — so nothing looks wrong until you break it down:Fix
Read declared FKs from
pg_catalog.pg_constraint(joiningpg_attributefor the column names), which every role can see, instead of the owner-restrictedinformation_schemaviews. Worth also making an empty FK result distinguishable from "engine has no FK support" atintrospect.py:678, so a privilege-shaped zero can be reported rather than silently downgrading to name matching.Related: ACE-036 (41cfd7d) — the spec that makes this posture required.