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
{{ message }}
Repository navigation
Expose conflict and review state in the supported RDF read contract #564
Following the clarification in #550 that RDF export is Utopia's supported machine-readable integration boundary, there is one remaining gap that seems important for downstream readers:
the export contains the assertions themselves, but not the conflict / review state Utopia has already established about those assertions.
This means an external system can recover two evidence-backed facts, their provenance, validity and history, but cannot reliably tell from the supported contract that Utopia considers them currently contested.
The second would duplicate logic that Utopia already owns, while the first loses part of Utopia's epistemic model.
Desired property
The supported machine-readable contract should make it possible to determine that an exported assertion participates in an unresolved or otherwise relevant conflict/review state.
I am not suggesting a particular RDF vocabulary or storage model.
The minimum useful information seems to be equivalent to:
Conflict {
id
kind
status
affected_fact_ids[]
}
or RDF relationships carrying the same semantics.
The important invariants are:
the conflict has a stable machine-addressable identity, if Utopia treats it as an addressable object;
affected assertions can be joined to it through their existing stable IRIs/UUID-derived identities;
conflict kind is machine-readable;
current resolution/review status is machine-readable;
a downstream reader does not need to reconstruct conflict detection from the exported assertions.
Scope
This issue is about exporting Utopia's existing epistemic judgment that facts are contested.
It is not asking the RDF consumer to receive the entire Review UI model, queue presentation state, or reviewer workflow unless those are necessary to express the semantic state.
The key distinction is:
fact exists
versus:
fact exists, but Utopia currently considers it contested
That distinction should survive the supported machine-readable boundary.
Questions
A few design questions are intentionally left to the project:
Should conflict/review state be represented directly on the affected statements, as separate conflict resources, or both?
Which statuses are part of the durable epistemic model versus UI workflow state?
Should resolved historical conflicts remain present in RDF for audit/history, or should the export expose only current conflict state?
Suggested acceptance criteria
A regression fixture could establish the external invariant:
Create two assertions that Utopia recognizes as conflicting.
Export the base as Turtle or JSON-LD.
Both assertions remain present with their normal evidence/provenance.
The export also contains enough structured information to identify that the assertions are part of the conflict.
The conflict kind and current state can be read without depending on /api/v1 or database tables.
After the conflict is resolved, the export reflects the intended durable resolution semantics.
Related
#550 clarified that this missing conflict/review information should become its own issue.
The goal here is to keep the supported RDF boundary epistemically complete enough that downstream integrations do not need to reimplement Utopia's conflict semantics.
I did a read-only pass over the current conflict/review model to avoid proposing an implementation before the RDF contract semantics are settled.
Would the following be a reasonable minimum first cut for #564?
Export the existing fact_conflicts and axiom_violations state as addressable RDF resources.
Link them to the existing fact IRIs rather than duplicating fact identity.
Carry only durable epistemic fields such as kind/reason, status/resolution, and the relevant timestamps.
Keep contested assertions themselves exported exactly as they are today; the conflict resource adds context rather than hiding or replacing the assertions.
Explicitly exclude pending_facts, duplicate/entity-resolution review, mappings, agent proposals, and other Review UI/workflow state from this first cut.
Keep the export read-only; it should not reuse any read path that lazily mutates conflict state.
The two design points I would especially like guidance on before any PR:
Should both fact_conflicts and axiom_violations be part of the first RDF contract cut?
Should resolved conflicts remain exportable as historical epistemic state, or should the contract expose only currently open conflicts?
If this direction matches the project, I’d be happy to draft the ADR first, before touching code.
Thanks for doing the read-only pass first — it is the right order, and the exclusions you list are right as stated. pending_facts sit before the ledger, and duplicate review, mappings and agent proposals are workflow rather than epistemic state. Keep all of them out.
Before answering the two questions: I went back through the two tables and their read paths, and there is a constraint neither of us had on the table. It changes the second answer, so it comes first.
What the storage actually retains
fact_conflicts is resolved lazily on read.utopia_store::temporal::list_conflicts runs an UPDATE ... SET status = 'resolved', resolution = 'stale', resolved_at = now() for conflicts where either side has been invalidated, and only then selects. Two consequences:
stale is not in the value set the migration documents (closed | kept_both | rejected_new), and there is no CHECK on that column. So the column's domain is wider than its comment.
For a stale row, resolved_at is when somebody opened the Review page, not when the conflict stopped applying. It is not a decision time and should not be exported as one.
axiom_violations rows are rewritten in place. When a re-run finds a violation that had been resolved as fact_retracted | fact_closed | axiom_relaxed, reasoning.rs sets it back to status = 'open', nulls resolution, decided_by and decided_at, and resets detected_at to now(). So a violation row is current state, not an append-only record: a resolve-then-reopen leaves no trace, and detected_at means "last detected", not "first detected".
That is the part worth settling in the ADR, because "export resolved conflicts as historical epistemic state" promises something the storage does not keep today.
1. Both — but they are two classes, and they are not equally ready
Both should be in the first cut. They have the same shape, so doing one now and one later only makes downstream adapt twice. But do not flatten them into one class:
fact_conflicts says two assertions disagree about the same slot in the ledger (no_time, simultaneous, low_confidence): the ledger could not decide whether the new one closes the old one.
axiom_violations says the data contradicts a rule the ontology declares (self_loop, asymmetry, cycle, functional).
They also have different resolution vocabularies (closed | kept_both | rejected_new | stale vs fact_retracted | axiom_relaxed | accepted), which is a second reason not to merge them.
A violation should point at the axiom it breaks, not only at the two facts: the relation IRI plus the OWL term. emit_relation already writes owl:FunctionalProperty, owl:AsymmetricProperty, owl:IrreflexiveProperty and owl:TransitiveProperty onto the relation, so this is a link into vocabulary the export already carries. Without it a reader learns that something is wrong but not what rule it is wrong against, and cannot re-derive the finding.
For cycles, path is an ordered list of facts and should be exported as one (rdf:List, or an index property). "A→B→C→A" is the evidence; "A and C conflict" is not.
Where they differ in readiness: fact_conflicts rows are durable, axiom_violations rows are recomputed state. The contract can carry both, but it should not describe a violation as a historical record.
2. Export current contest state, and only promise history where there is history
The direction of your question is right — an export that showed a clean graph while hiding that half of it was retracted in March is the failure mode 0020 names, and silently dropping "these two were contested" is the same failure one level up. Facts already export prov:invalidatedAtTime and utopia:supersedes; conflicts should not be the one thing that only shows the present.
But given the above, I would scope the promise rather than state it in general:
Open conflicts and open violations: full, with their timestamps.
Resolved fact_conflicts: exportable, with the resolution and resolved_at — except stale, which is not an adjudication. Export it as withdrawn/no-longer-applicable, and leave its timestamp out rather than presenting the page-open moment as a decision time.
Resolved axiom_violations: exportable as the current finding, without claiming it is history. The ADR should say plainly that a resolve-then-reopen is not recoverable from the row.
Two things that follow:
Define the contract's own resolution vocabulary and map to it, rather than echoing the column. Echoing makes stale — an internal artefact of a lazy cleanup — part of a public contract, and the column has no CHECK to keep the set from growing again.
Note that GET /export has no as_of parameter: it always serialises now. A consumer reconstructs "was this contested in March" from the timestamps in the payload, which is exactly why the timestamps have to be there and have to mean what they say.
If we later want the contract to carry real conflict history, the storage needs an append-only event for adjudications rather than a mutable status column. That is a bigger decision than this cut and should not block it — but it is worth one line in the ADR so the next person knows the limit was deliberate.
Smaller points
Leave decided_by out. Who clicked is workflow and a person; that it was decided, when, and to what, is the epistemic part. Only axiom_violations has this column; fact_conflicts has no actor at all.
Point from the conflict to the two statements, not from the statements to the conflict. A consumer that ignores conflicts then reads facts exactly as it does today, and a statement can sit in several conflicts without its own description growing.
On read-only: agreed, and the specific trap is the one above — the exporter must select from the two tables and must not go through list_conflicts or the detection run.
ADR first, yes
0039 is the next free number; conventions are in docs/decisions/README.md and records are English. The record matters more than the serialisation here, and the things worth nailing down are: what is in the contract, what is deliberately out and why (your exclusion list), that contest state is exported with its timestamps so an as-of question can be answered from the payload, and where the storage stops short of what the contract could otherwise be read to promise.
Once that is settled the code cut looks small enough to be one PR.
Filed the two storage-side findings separately so they do not sit inside the contract discussion: #612 (the conflict list resolves conflicts while reading them, which is where stale and its timestamp come from) and #613 (that column has no CHECK). Neither blocks the ADR — they are the reason the record should define its own vocabulary rather than echo the column.
I paused the ADR before submitting it and did one more adversarial pass against the current storage/runtime. Two cases look like they need an explicit contract decision before I can state the RDF semantics safely.
1. Some violations do not retain enough information to identify the exact axiom historically
For signature, signature_breaks() returns the fact id and record_signature_breaks() stores the same fact as left_fact / right_fact, but it does not retain whether the failure was domain, range, or both.
There is a similar general problem after axiom_relaxed: the violation row remains, but the ontology may have changed, so deriving the criterion from the current relation would not necessarily identify the rule that the resolved row originally violated.
So for the first cut, should the contract:
require an exact axiom link only where the retained row can support it, and otherwise expose the stored violation kind without claiming the original exact criterion; or
I do not want the ADR to promise historical criterion identity that the ledger does not retain.
2. path / detail can be retained evidence rather than the latest recomputation
The reasoning write path keys axiom_violations by (kb_id, kind, left_fact, right_fact) and existing rows are generally left unchanged on conflict.
That means a later run can find the same keyed violation through a different intermediate cycle path or changed derivation while the stored path / detail still describes the earlier occurrence.
Would you want the RDF contract to define these fields as:
the evidence snapshot retained on the violation row, not a guarantee that it is the latest recomputed proof of the current finding
or should current evidence be a contract requirement, which would imply changing the persistence/update behavior first?
I can fix two smaller wording issues in the ADR without needing a design decision:
detected_at should describe its actual insert/reopen semantics rather than simply “last detected”;
the export's shared now controls temporal rendering but does not imply a transactionally consistent snapshot across the paged stream.
I’ll keep the ADR paused until the two contract questions above are settled.
Both hold. I went back through the two write paths, and the second one is worse than your wording allows for, in a way that changes what the record can say.
What I confirmed
signature does not retain which side broke.signature_breaks is one query whose predicate is (subject not in domain) OR (object not in range); the branch that matched is never projected. record_signature_breaks then writes kind='signature', left_fact = right_fact = the fact, and no path, no detail. A fact that breaks both domain and range is one row, indistinguishable from one that breaks only the range.
Evidence is written once and never refreshed. Both inserts are ON CONFLICT (kb_id, kind, left_fact, right_fact) DO NOTHING, and nothing else ever writes path or detail — the reopen branch sets status, resolution, decided_by, decided_at and detected_at, and not those two. So the retained evidence is from the first run that recorded this key, not merely from an earlier one, and for a row that stays open nothing would ever bring it forward. On reopen, detected_at becomes now() while path and detail do not move: the row then presents a current timestamp beside evidence from another run.
Two more facts about that table, since they bound what the contract can carry:
detail is populated only for derived_contradiction (it is looked up from the clash map keyed by the two facts); every other kind gets {}. path is non-empty only for cycle. So signature, self_loop, asymmetry and functional rows carry no evidence fields at all.
Open rows a run does not re-find are DELETEd, not resolved. A violation that goes away leaves nothing behind.
And a third thing, which I think is the real answer to your question about identity. For cycle, left is the first fact of the traversal and right is the last, and the traversal starts from adj.keys() — a HashMap. The doc comment says each cycle is reported "from the smallest node on the cycle", but the code does not sort. I measured it: one three-edge cycle, same input, check() called 200 times in one process produces three distinct (left, right, path) triples — all three rotations.
So a cycle violation has no stable key (#618). A re-run that rotates it inserts a new row and deletes the old open one, with a new id and a new detected_at. Worse, if a person had resolved that cycle, the resolved row is kept while a new open row appears under the rotated key — the resolution silently stops applying, and the reopen path never fires because it matches on the key.
1. Your first option — the kind names the criterion, at the grain the kind actually has
No storage cut is needed for #564 on this account. The axiom is declared on the relation, not on the violation row, so kind plus the relation IRI names it exactly for every kind:
Require the link, and let signature link to the signature rather than to one side of it. Do not model it as "domain violated" — the row does not know that. If we later add the side as a column the contract narrows; it does not change shape.
One correction while you are writing this down: cycle is gated on transitive alone (check calls cycles under if ax.transitive). The comment in migrations/0012 says the predicate must be transitive and asymmetric. The code is the authority; that comment is stale.
But do not resolve the criterion against the current ontology for a resolved row. This is the real content of your second paragraph, and it has a clean rule. An open violation was recomputed by the run that left it open, so the axiom it names still holds and the link is true. A violation resolved as axiom_relaxed means the axiom was removed or widened — linking that row into the current vocabulary asserts the opposite of what happened. So: carry the criterion link on open violations; on resolved ones carry the kind and the resolution and no link. fact_retracted and fact_closed do not themselves touch the ontology, but the ontology can have moved since for an unrelated reason, and one rule that stays true beats three that need checking.
2. Snapshot, and say which snapshot
Your wording, with one word changed and one clause added:
path and detail are the evidence retained when this violation was first recorded under its key (kind, left_fact, right_fact). A later run that finds the same key by a different path does not update them, and neither does a reopen. They are not a proof of the current finding, and they are not necessarily contemporaneous with detected_at.
"First" rather than "retained on the row", because DO NOTHING makes it the first and not merely an earlier one: a reader who assumed "most recent as of some run" would still be wrong.
I would not make current evidence a contract requirement in this cut. It is a one-line change (DO UPDATE SET path = EXCLUDED.path, detail = EXCLUDED.detail) and probably right, but it changes what the Review page shows as well as what the export says, and the cycle key has to be settled first or refreshing the evidence only moves the inconsistency. Both are filed: #618 for the cycle key, #619 for the evidence. Neither blocks this record, as long as it says the limits are deliberate rather than promising around them.
Your two wording fixes
Both right, and detected_at needs a little more than insert/reopen: it is set at insert and reset only when a row resolved as fact_retracted | fact_closed | axiom_relaxed is found again. It is never refreshed for a row that simply stays open. So "last detected" is wrong in both directions — for a long-open row it is the first detection, for a reopened one it is the reopen.
The paging point is right and worth its own sentence: the shared now fixes temporal rendering, not isolation. The pages are separate transactions, and a write between them is visible to the later page.
One remaining invariant showed up in the fresh review of the post-#620 ADR draft.
I think this one needs your call rather than another wording adjustment.
An open violation is not always guaranteed to match the current ontology
Your earlier rule was clear:
carry the criterion link on open violations; on resolved ones carry the kind and the resolution and no link
and the reason was that an open violation had been recomputed by the run that left it open, so its axiom still held.
I found one path where that invariant does not currently hold.
update_relation_type can replace the relation axioms and domain/range declarations, then return without running reasoning::run.
So the sequence can be:
a reasoning run records an open violation under axiom A;
the relation is edited so A is removed or widened;
update_relation_type commits the ontology edit without rerunning consistency;
the old axiom_violations row remains open until a later reasoning run;
/export must remain read-only and therefore cannot repair that state itself.
In that interval — which can last indefinitely if no later run happens — open does not prove that the criterion still exists in the current ontology.
This is especially unrecoverable for the already-discussed coarse kinds:
signature does not retain domain vs range;
functional does not retain which uniqueness direction produced the row.
So I see two possible contract directions:
Preserve the stronger invariant: an open violation always represents a finding under the current ontology. Ontology mutations that can change a criterion must reconcile/re-run the relevant violations before returning (or otherwise invalidate the stale rows). Then the required open-row criterion link remains sound.
Weaken the RDF contract: an open violation links to the current criterion only when the current ontology still provides an unambiguous matching declaration; otherwise it exports the stored kind without that link.
My preference is (1), because this looks broader than an RDF-export problem: any consumer of axiom_violations can otherwise observe an open finding whose governing axiom has already changed. Persisting the old criterion would preserve provenance, but it would not fix the stale-open state.
I do not want ADR 0039 to silently choose between these, especially since (2) would narrow the earlier requirement that open violations carry the criterion link.
Should the invariant be:
status = open means the violation has been reconciled against the current ontology
with ontology mutation responsible for maintaining that invariant?
I’ll keep the ADR paused until this point is settled.
Following up on the remaining post-#620 contract question above. I re-checked current dev: update_relation_type can still change axioms/domain/range and return without reconciling axiom_violations, so an open row can temporarily outlive the criterion it was detected under.
I’m still holding ADR 0039 at this boundary rather than choosing contract semantics unilaterally. My preference remains the stronger invariant: status = open means the finding has been reconciled against the current ontology, with ontology mutation responsible for maintaining that invariant.
If you prefer the weaker export rule instead — only emit a criterion link when the current ontology still supports an unambiguous match — I can encode that in the ADR. Which direction should the record take?
Take the stronger invariant: open means the finding has been reconciled against the current ontology, and ontology mutation is responsible for keeping that true. update_relation_type changing axioms, domain or range should, in the same transaction, close the open axiom_violations rows whose criterion no longer applies, with a reason that says the criterion changed, and re-detect under the new one. The export then carries the criterion link on open rows without a caveat, and the weaker export rule is not needed. You confirmed on current dev that the route does not do this yet; that is the code change the record should name as its precondition, and it is small.
One housekeeping point: 0039 is taken (a chunk is what extraction sees), and 0055 to 0061 are reserved by work in flight; number the record 0062.
Activity
ansonnmm commentedon Sep 11, 2026
I did a read-only pass over the current conflict/review model to avoid proposing an implementation before the RDF contract semantics are settled.
Would the following be a reasonable minimum first cut for #564?
fact_conflictsandaxiom_violationsstate as addressable RDF resources.pending_facts, duplicate/entity-resolution review, mappings, agent proposals, and other Review UI/workflow state from this first cut.The two design points I would especially like guidance on before any PR:
fact_conflictsandaxiom_violationsbe part of the first RDF contract cut?If this direction matches the project, I’d be happy to draft the ADR first, before touching code.
WaylandYang commentedon Sep 11, 2026
Thanks for doing the read-only pass first — it is the right order, and the exclusions you list are right as stated.
pending_factssit before the ledger, and duplicate review, mappings and agent proposals are workflow rather than epistemic state. Keep all of them out.Before answering the two questions: I went back through the two tables and their read paths, and there is a constraint neither of us had on the table. It changes the second answer, so it comes first.
What the storage actually retains
fact_conflictsis resolved lazily on read.utopia_store::temporal::list_conflictsruns anUPDATE ... SET status = 'resolved', resolution = 'stale', resolved_at = now()for conflicts where either side has been invalidated, and only then selects. Two consequences:staleis not in the value set the migration documents (closed | kept_both | rejected_new), and there is noCHECKon that column. So the column's domain is wider than its comment.stalerow,resolved_atis when somebody opened the Review page, not when the conflict stopped applying. It is not a decision time and should not be exported as one.axiom_violationsrows are rewritten in place. When a re-run finds a violation that had been resolved asfact_retracted | fact_closed | axiom_relaxed,reasoning.rssets it back tostatus = 'open', nullsresolution,decided_byanddecided_at, and resetsdetected_attonow(). So a violation row is current state, not an append-only record: a resolve-then-reopen leaves no trace, anddetected_atmeans "last detected", not "first detected".That is the part worth settling in the ADR, because "export resolved conflicts as historical epistemic state" promises something the storage does not keep today.
1. Both — but they are two classes, and they are not equally ready
Both should be in the first cut. They have the same shape, so doing one now and one later only makes downstream adapt twice. But do not flatten them into one class:
fact_conflictssays two assertions disagree about the same slot in the ledger (no_time,simultaneous,low_confidence): the ledger could not decide whether the new one closes the old one.axiom_violationssays the data contradicts a rule the ontology declares (self_loop,asymmetry,cycle,functional).They also have different resolution vocabularies (
closed | kept_both | rejected_new | stalevsfact_retracted | axiom_relaxed | accepted), which is a second reason not to merge them.A violation should point at the axiom it breaks, not only at the two facts: the relation IRI plus the OWL term.
emit_relationalready writesowl:FunctionalProperty,owl:AsymmetricProperty,owl:IrreflexivePropertyandowl:TransitivePropertyonto the relation, so this is a link into vocabulary the export already carries. Without it a reader learns that something is wrong but not what rule it is wrong against, and cannot re-derive the finding.For cycles,
pathis an ordered list of facts and should be exported as one (rdf:List, or an index property). "A→B→C→A" is the evidence; "A and C conflict" is not.Where they differ in readiness:
fact_conflictsrows are durable,axiom_violationsrows are recomputed state. The contract can carry both, but it should not describe a violation as a historical record.2. Export current contest state, and only promise history where there is history
The direction of your question is right — an export that showed a clean graph while hiding that half of it was retracted in March is the failure mode 0020 names, and silently dropping "these two were contested" is the same failure one level up. Facts already export
prov:invalidatedAtTimeandutopia:supersedes; conflicts should not be the one thing that only shows the present.But given the above, I would scope the promise rather than state it in general:
fact_conflicts: exportable, with the resolution andresolved_at— exceptstale, which is not an adjudication. Export it as withdrawn/no-longer-applicable, and leave its timestamp out rather than presenting the page-open moment as a decision time.axiom_violations: exportable as the current finding, without claiming it is history. The ADR should say plainly that a resolve-then-reopen is not recoverable from the row.Two things that follow:
stale— an internal artefact of a lazy cleanup — part of a public contract, and the column has noCHECKto keep the set from growing again.GET /exporthas noas_ofparameter: it always serialises now. A consumer reconstructs "was this contested in March" from the timestamps in the payload, which is exactly why the timestamps have to be there and have to mean what they say.If we later want the contract to carry real conflict history, the storage needs an append-only event for adjudications rather than a mutable status column. That is a bigger decision than this cut and should not block it — but it is worth one line in the ADR so the next person knows the limit was deliberate.
Smaller points
decided_byout. Who clicked is workflow and a person; that it was decided, when, and to what, is the epistemic part. Onlyaxiom_violationshas this column;fact_conflictshas no actor at all.list_conflictsor the detection run.ADR first, yes
0039is the next free number; conventions are indocs/decisions/README.mdand records are English. The record matters more than the serialisation here, and the things worth nailing down are: what is in the contract, what is deliberately out and why (your exclusion list), that contest state is exported with its timestamps so an as-of question can be answered from the payload, and where the storage stops short of what the contract could otherwise be read to promise.Once that is settled the code cut looks small enough to be one PR.
WaylandYang commentedon Sep 11, 2026
Filed the two storage-side findings separately so they do not sit inside the contract discussion: #612 (the conflict list resolves conflicts while reading them, which is where
staleand its timestamp come from) and #613 (that column has noCHECK). Neither blocks the ADR — they are the reason the record should define its own vocabulary rather than echo the column.ansonnmm commentedon Sep 12, 2026
I paused the ADR before submitting it and did one more adversarial pass against the current storage/runtime. Two cases look like they need an explicit contract decision before I can state the RDF semantics safely.
1. Some violations do not retain enough information to identify the exact axiom historically
For
signature,signature_breaks()returns the fact id andrecord_signature_breaks()stores the same fact asleft_fact/right_fact, but it does not retain whether the failure was domain, range, or both.There is a similar general problem after
axiom_relaxed: the violation row remains, but the ontology may have changed, so deriving the criterion from the current relation would not necessarily identify the rule that the resolved row originally violated.So for the first cut, should the contract:
I do not want the ADR to promise historical criterion identity that the ledger does not retain.
2.
path/detailcan be retained evidence rather than the latest recomputationThe reasoning write path keys
axiom_violationsby(kb_id, kind, left_fact, right_fact)and existing rows are generally left unchanged on conflict.That means a later run can find the same keyed violation through a different intermediate cycle path or changed derivation while the stored
path/detailstill describes the earlier occurrence.Would you want the RDF contract to define these fields as:
or should current evidence be a contract requirement, which would imply changing the persistence/update behavior first?
I can fix two smaller wording issues in the ADR without needing a design decision:
detected_atshould describe its actual insert/reopen semantics rather than simply “last detected”;nowcontrols temporal rendering but does not imply a transactionally consistent snapshot across the paged stream.I’ll keep the ADR paused until the two contract questions above are settled.
WaylandYang commentedon Sep 12, 2026
Both hold. I went back through the two write paths, and the second one is worse than your wording allows for, in a way that changes what the record can say.
What I confirmed
signaturedoes not retain which side broke.signature_breaksis one query whose predicate is(subject not in domain) OR (object not in range); the branch that matched is never projected.record_signature_breaksthen writeskind='signature',left_fact = right_fact = the fact, and nopath, nodetail. A fact that breaks both domain and range is one row, indistinguishable from one that breaks only the range.Evidence is written once and never refreshed. Both inserts are
ON CONFLICT (kb_id, kind, left_fact, right_fact) DO NOTHING, and nothing else ever writespathordetail— the reopen branch setsstatus,resolution,decided_by,decided_atanddetected_at, and not those two. So the retained evidence is from the first run that recorded this key, not merely from an earlier one, and for a row that stays open nothing would ever bring it forward. On reopen,detected_atbecomesnow()whilepathanddetaildo not move: the row then presents a current timestamp beside evidence from another run.Two more facts about that table, since they bound what the contract can carry:
detailis populated only forderived_contradiction(it is looked up from the clash map keyed by the two facts); every other kind gets{}.pathis non-empty only forcycle. Sosignature,self_loop,asymmetryandfunctionalrows carry no evidence fields at all.DELETEd, not resolved. A violation that goes away leaves nothing behind.And a third thing, which I think is the real answer to your question about identity. For
cycle,leftis the first fact of the traversal andrightis the last, and the traversal starts fromadj.keys()— aHashMap. The doc comment says each cycle is reported "from the smallest node on the cycle", but the code does not sort. I measured it: one three-edge cycle, same input,check()called 200 times in one process produces three distinct(left, right, path)triples — all three rotations.So a cycle violation has no stable key (#618). A re-run that rotates it inserts a new row and deletes the old open one, with a new id and a new
detected_at. Worse, if a person had resolved that cycle, the resolved row is kept while a new open row appears under the rotated key — the resolution silently stops applying, and the reopen path never fires because it matches on the key.1. Your first option — the kind names the criterion, at the grain the kind actually has
No storage cut is needed for #564 on this account. The axiom is declared on the relation, not on the violation row, so
kindplus the relation IRI names it exactly for every kind:self_loopowl:IrreflexivePropertyasymmetryowl:AsymmetricPropertycycleowl:TransitivePropertyfunctionalowl:FunctionalProperty/owl:InverseFunctionalPropertysignaturerdfs:domainandrdfs:range, togetherRequire the link, and let
signaturelink to the signature rather than to one side of it. Do not model it as "domain violated" — the row does not know that. If we later add the side as a column the contract narrows; it does not change shape.One correction while you are writing this down:
cycleis gated ontransitivealone (checkcallscyclesunderif ax.transitive). The comment inmigrations/0012says the predicate must be transitive and asymmetric. The code is the authority; that comment is stale.But do not resolve the criterion against the current ontology for a resolved row. This is the real content of your second paragraph, and it has a clean rule. An
openviolation was recomputed by the run that left it open, so the axiom it names still holds and the link is true. A violation resolved asaxiom_relaxedmeans the axiom was removed or widened — linking that row into the current vocabulary asserts the opposite of what happened. So: carry the criterion link on open violations; on resolved ones carry the kind and the resolution and no link.fact_retractedandfact_closeddo not themselves touch the ontology, but the ontology can have moved since for an unrelated reason, and one rule that stays true beats three that need checking.2. Snapshot, and say which snapshot
Your wording, with one word changed and one clause added:
"First" rather than "retained on the row", because
DO NOTHINGmakes it the first and not merely an earlier one: a reader who assumed "most recent as of some run" would still be wrong.I would not make current evidence a contract requirement in this cut. It is a one-line change (
DO UPDATE SET path = EXCLUDED.path, detail = EXCLUDED.detail) and probably right, but it changes what the Review page shows as well as what the export says, and the cycle key has to be settled first or refreshing the evidence only moves the inconsistency. Both are filed: #618 for the cycle key, #619 for the evidence. Neither blocks this record, as long as it says the limits are deliberate rather than promising around them.Your two wording fixes
Both right, and
detected_atneeds a little more than insert/reopen: it is set at insert and reset only when a row resolved asfact_retracted | fact_closed | axiom_relaxedis found again. It is never refreshed for a row that simply stays open. So "last detected" is wrong in both directions — for a long-open row it is the first detection, for a reopened one it is the reopen.The paging point is right and worth its own sentence: the shared
nowfixes temporal rendering, not isolation. The pages are separate transactions, and a write between them is visible to the later page.ansonnmm commentedon Sep 12, 2026
One remaining invariant showed up in the fresh review of the post-#620 ADR draft.
I think this one needs your call rather than another wording adjustment.
An
openviolation is not always guaranteed to match the current ontologyYour earlier rule was clear:
and the reason was that an open violation had been recomputed by the run that left it open, so its axiom still held.
I found one path where that invariant does not currently hold.
update_relation_typecan replace the relation axioms and domain/range declarations, then return without runningreasoning::run.So the sequence can be:
update_relation_typecommits the ontology edit without rerunning consistency;axiom_violationsrow remainsopenuntil a later reasoning run;/exportmust remain read-only and therefore cannot repair that state itself.In that interval — which can last indefinitely if no later run happens —
opendoes not prove that the criterion still exists in the current ontology.This is especially unrecoverable for the already-discussed coarse kinds:
signaturedoes not retain domain vs range;functionaldoes not retain which uniqueness direction produced the row.So I see two possible contract directions:
Preserve the stronger invariant: an open violation always represents a finding under the current ontology. Ontology mutations that can change a criterion must reconcile/re-run the relevant violations before returning (or otherwise invalidate the stale rows). Then the required open-row criterion link remains sound.
Weaken the RDF contract: an open violation links to the current criterion only when the current ontology still provides an unambiguous matching declaration; otherwise it exports the stored kind without that link.
My preference is (1), because this looks broader than an RDF-export problem: any consumer of
axiom_violationscan otherwise observe anopenfinding whose governing axiom has already changed. Persisting the old criterion would preserve provenance, but it would not fix the stale-openstate.I do not want ADR 0039 to silently choose between these, especially since (2) would narrow the earlier requirement that open violations carry the criterion link.
Should the invariant be:
with ontology mutation responsible for maintaining that invariant?
I’ll keep the ADR paused until this point is settled.
ansonnmm commentedon Sep 21, 2026
Following up on the remaining post-#620 contract question above. I re-checked current dev: update_relation_type can still change axioms/domain/range and return without reconciling axiom_violations, so an open row can temporarily outlive the criterion it was detected under.
I’m still holding ADR 0039 at this boundary rather than choosing contract semantics unilaterally. My preference remains the stronger invariant: status = open means the finding has been reconciled against the current ontology, with ontology mutation responsible for maintaining that invariant.
If you prefer the weaker export rule instead — only emit a criterion link when the current ontology still supports an unambiguous match — I can encode that in the ADR. Which direction should the record take?
WaylandYang commentedon Sep 25, 2026
Take the stronger invariant:
openmeans the finding has been reconciled against the current ontology, and ontology mutation is responsible for keeping that true.update_relation_typechanging axioms, domain or range should, in the same transaction, close the openaxiom_violationsrows whose criterion no longer applies, with a reason that says the criterion changed, and re-detect under the new one. The export then carries the criterion link on open rows without a caveat, and the weaker export rule is not needed. You confirmed on current dev that the route does not do this yet; that is the code change the record should name as its precondition, and it is small.One housekeeping point: 0039 is taken (a chunk is what extraction sees), and 0055 to 0061 are reserved by work in flight; number the record 0062.
Export what is contested: fact conflicts and axiom violations in RDF (#…