You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Migration 0073 (#882) added implied_fact_sources, the durable provenance link for
rule-derived (facts.implied) facts:
CREATETABLEimplied_fact_sources (
fact_id UUID NOT NULLREFERENCES facts(id) ON DELETE CASCADE,
rule_id UUID NOT NULLREFERENCES implication_rules(id) ON DELETE CASCADE,
statement_id UUID REFERENCES facts(id) ON DELETE CASCADE,
entity_id UUID REFERENCES entities(id) ON DELETE CASCADE,
PRIMARY KEY (fact_id, rule_id),
CHECK ((statement_id IS NOT NULL) <> (entity_id IS NOT NULL))
);
Every reference is a plain single-column foreign key. A column FK proves the target
row exists — it does not prove the target lives in the same knowledge base as the
implied fact that cites it. Three semantic lineage edges are exposed:
rule_id → implication_rules.id (which rule implied the fact)
fact_id → facts.id is the ownership edge: the row carries no kb_id, so its KB
authority is the owning fact's kb_id — the same shape as typed_fact_sources
(fact_id → facts), which is already in the protected family.
Why this is a generic Utopia invariant
implied_fact_sources is the same kind of durable semantic provenance as typed_fact_sources: it records which stored rows an implied fact is derived from,
and the ledger consumes it internally — the materialize sweep deletes a source row
when its rule is unapproved or its trigger died, and invalidates a computed fact
whose sources have all gone. A lineage row that names a rule, statement, or entity
in another base is wrong on its face under a local knowledge-base interpretation,
independently of whether the exporter ever serializes it.
Current protection gap
ADR 0048 / migration 0070 (#832) protects 39 reference edges — 26 by composite (kb_id, ref) foreign keys, 13 covered by owner-derived triggers — plus kb_id
immutability on the owned tables. #874 added the catalog-derived guard in migration_0070_runs_under_any_search_path.rs and the per-edge export preflight in export_provenance_integrity.sql.
implied_fact_sources is outside all of it:
It is not in LEDGER_TABLES, so the catalog guard never enumerates its columns —
the "reference column added later" failure mode The same-KB invariant does not notice a reference column added later #846 closed has recurred at table
granularity. Structurally the gap is wider than a missing registry entry: the
guard's scratch database is migrated only through 0070, so post-0070 tables are
invisible to it — registering the table today would fail until the scratch chain
is extended too.
No composite FK or *_same_kb trigger covers rule_id, statement_id, or entity_id; implication_rules is also absent from the *_keep_their_kb
immutability triggers (and has no UNIQUE (kb_id, id)), so UPDATE implication_rules SET kb_id can silently re-anchor every source row
pointing at it.
It has no branch in export_provenance_integrity.sql (preflight covers exactly
the protected edges, so this follows automatically — but there is no protection
to backstop).
Store-layer write paths (imply_in_tx, implication_rules::propose, record_reading) construct all references inside the acting kb_id, but nothing
downstream enforces it: a plain INSERT/UPDATE through any SQL path persists a
cross-KB row today — no replica-mode session is even required, since there is no
trigger to bypass.
The same migration left the rest of the rule surface outside the guard as well: implication_rules.subject_type_id / object_type_id → entity_types and conclude_property_id → relation_types, and phrase_readings.entity_id → entities are likewise plain FKs on kb-owned rows. (For conclude_property_id and
a reading's entity_id, the composite keys 0070 put on facts turn a violation
into a materialize-time FK failure rather than silent corruption; the cross-KB rule
and reading rows themselves still persist, and foreign type references fail
silently as rules that can never match.)
Failure shape
A single INSERT is enough:
-- KB A holds implied fact F_A; KB B holds approved rule R_B.INSERT INTO implied_fact_sources (fact_id, rule_id, statement_id)
VALUES ('<F_A>', '<R_B>', '<a statement in KB B>');
Every foreign key is satisfied; nothing objects. Consequences through existing code
paths, in both directions:
The next materialize run for KB B joins implied_fact_sources to implication_rules on r.kb_id = B; when R_B is rejected or its trigger dies,
the sweep deletes F_A's source row. F_A then has zero sources, so on KB A's
next materialize it is found sourceless and invalidated — a rule decision in KB B
silently retires a fact in KB A.
While the phantom row exists it counts as a live source for F_A (the
zero-source check only tests NOT EXISTS): if F_A's legitimate sources die
first, the cross-KB row pins it alive — the opposite failure, an implied fact
that can no longer be retired.
The lineage is also orphaned from KB A's side: A's sweep can never reach the row
(r.kb_id = B fails the join), and resolving "which rule implied F_A" through implication_rules scoped to A finds nothing — a durable provenance pointer
naming a row the owning base cannot see.
Desired invariant
Every durable implied-fact provenance reference resolves to a semantically valid
target in the same knowledge base as the implied fact at the appropriate
transaction boundary — rule_id, statement_id, entity_id alike.
Design question
Should implied_fact_sources (and the rest of the 0073 rule surface) join the
ADR 0048 / migration-0070 invariant family?
implied_fact_sources has no kb_id column; its authority is the owning fact —
the owner-derived pattern already used for typed_fact_sources fits, if the
trigger path is acceptable here.
implication_rules and phrase_readings carry their own kb_id, so their
references could take the direct-kb (composite FK) pattern instead — which would
also need the supporting UNIQUE (kb_id, id) on implication_rules — plus *_keep_their_kb on implication_rules.
The membership question is already inconsistent elsewhere in the registry: name_vectors (0080) carries full _same_kb composite keys and a keep_their_kb
trigger yet sits outside LEDGER_TABLES and the preflight, and errata_runs / errata_actions (0074) carry kb-owned plain-FK semantic references
unregistered. Happy to split that wider audit out if you would rather keep this
issue to the 0073 provenance chain.
Suggested regression
In the spirit of cross_kb_provenance_fails_closed.rs (which seeds cross-KB rows
under SET LOCAL session_replication_role = 'replica'): a minimal test inserting
an implied_fact_sources row whose rule/statement/entity lives in a second base
must be rejected, while an all-same-KB row remains valid — and the catalog guard
should enumerate the table's edges so a future column cannot slip again.
Problem
Migration 0073 (#882) added
implied_fact_sources, the durable provenance link forrule-derived (
facts.implied) facts:Every reference is a plain single-column foreign key. A column FK proves the target
row exists — it does not prove the target lives in the same knowledge base as the
implied fact that cites it. Three semantic lineage edges are exposed:
rule_id→implication_rules.id(which rule implied the fact)statement_id→facts.id(which statement triggered it,phraserules)entity_id→entities.id(which entity triggered it,kind_wordrules)fact_id→facts.idis the ownership edge: the row carries nokb_id, so its KBauthority is the owning fact's
kb_id— the same shape astyped_fact_sources(
fact_id→facts), which is already in the protected family.Why this is a generic Utopia invariant
implied_fact_sourcesis the same kind of durable semantic provenance astyped_fact_sources: it records which stored rows an implied fact is derived from,and the ledger consumes it internally — the materialize sweep deletes a source row
when its rule is unapproved or its trigger died, and invalidates a computed fact
whose sources have all gone. A lineage row that names a rule, statement, or entity
in another base is wrong on its face under a local knowledge-base interpretation,
independently of whether the exporter ever serializes it.
Current protection gap
ADR 0048 / migration 0070 (#832) protects 39 reference edges — 26 by composite
(kb_id, ref)foreign keys, 13 covered by owner-derived triggers — pluskb_idimmutability on the owned tables. #874 added the catalog-derived guard in
migration_0070_runs_under_any_search_path.rsand the per-edge export preflight inexport_provenance_integrity.sql.implied_fact_sourcesis outside all of it:LEDGER_TABLES, so the catalog guard never enumerates its columns —the "reference column added later" failure mode The same-KB invariant does not notice a reference column added later #846 closed has recurred at table
granularity. Structurally the gap is wider than a missing registry entry: the
guard's scratch database is migrated only through 0070, so post-0070 tables are
invisible to it — registering the table today would fail until the scratch chain
is extended too.
*_same_kbtrigger coversrule_id,statement_id, orentity_id;implication_rulesis also absent from the*_keep_their_kbimmutability triggers (and has no
UNIQUE (kb_id, id)), soUPDATE implication_rules SET kb_idcan silently re-anchor every source rowpointing at it.
export_provenance_integrity.sql(preflight covers exactlythe protected edges, so this follows automatically — but there is no protection
to backstop).
imply_in_tx,implication_rules::propose,record_reading) construct all references inside the actingkb_id, but nothingdownstream enforces it: a plain
INSERT/UPDATEthrough any SQL path persists across-KB row today — no replica-mode session is even required, since there is no
trigger to bypass.
The same migration left the rest of the rule surface outside the guard as well:
implication_rules.subject_type_id/object_type_id→entity_typesandconclude_property_id→relation_types, andphrase_readings.entity_id→entitiesare likewise plain FKs on kb-owned rows. (Forconclude_property_idanda reading's
entity_id, the composite keys 0070 put onfactsturn a violationinto a materialize-time FK failure rather than silent corruption; the cross-KB rule
and reading rows themselves still persist, and foreign type references fail
silently as rules that can never match.)
Failure shape
A single
INSERTis enough:Every foreign key is satisfied; nothing objects. Consequences through existing code
paths, in both directions:
implied_fact_sourcestoimplication_rulesonr.kb_id = B; whenR_Bis rejected or its trigger dies,the sweep deletes
F_A's source row.F_Athen has zero sources, so on KB A'snext materialize it is found sourceless and invalidated — a rule decision in KB B
silently retires a fact in KB A.
F_A(thezero-source check only tests
NOT EXISTS): ifF_A's legitimate sources diefirst, the cross-KB row pins it alive — the opposite failure, an implied fact
that can no longer be retired.
(
r.kb_id = Bfails the join), and resolving "which rule impliedF_A" throughimplication_rulesscoped to A finds nothing — a durable provenance pointernaming a row the owning base cannot see.
Desired invariant
Every durable implied-fact provenance reference resolves to a semantically valid
target in the same knowledge base as the implied fact at the appropriate
transaction boundary —
rule_id,statement_id,entity_idalike.Design question
Should
implied_fact_sources(and the rest of the 0073 rule surface) join theADR 0048 / migration-0070 invariant family?
implied_fact_sourceshas nokb_idcolumn; its authority is the owning fact —the owner-derived pattern already used for
typed_fact_sourcesfits, if thetrigger path is acceptable here.
implication_rulesandphrase_readingscarry their ownkb_id, so theirreferences could take the direct-kb (composite FK) pattern instead — which would
also need the supporting
UNIQUE (kb_id, id)onimplication_rules— plus*_keep_their_kbonimplication_rules.The membership question is already inconsistent elsewhere in the registry:
name_vectors(0080) carries full_same_kbcomposite keys and akeep_their_kbtrigger yet sits outside
LEDGER_TABLESand the preflight, anderrata_runs/errata_actions(0074) carry kb-owned plain-FK semantic referencesunregistered. Happy to split that wider audit out if you would rather keep this
issue to the 0073 provenance chain.
Suggested regression
In the spirit of
cross_kb_provenance_fails_closed.rs(which seeds cross-KB rowsunder
SET LOCAL session_replication_role = 'replica'): a minimal test insertingan
implied_fact_sourcesrow whose rule/statement/entity lives in a second basemust be rejected, while an all-same-KB row remains valid — and the catalog guard
should enumerate the table's edges so a future column cannot slip again.
Related