Expose conflict and review state in the supported RDF read contract · Issue #564 · deeplethe/utopia · GitHub
Skip to content

Expose conflict and review state in the supported RDF read contract #564

Closed

Description

@ansonnmm

Summary

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.

Context

As clarified in #550:

  • RDF export is the supported machine-readable read contract;
  • MCP is the agent-facing surface;
  • /api/v1 is UI-internal and is not a compatibility promise.

The RDF export already preserves the important epistemic lineage:

entity
→ assertion
→ world time
→ record time
→ correction / supersession
→ evidence quote
→ source document
→ derivation / rule / premises

The missing piece is conflict and review state.

Example

Suppose the knowledge base contains two evidence-backed assertions such as:

Acme revenue = 100M
Acme revenue = 130M

and Utopia has already detected that they form a conflict.

Internally, Utopia can preserve both assertions and surface the conflict for review.

A downstream reader of the RDF export currently sees both assertions, but does not receive the epistemic fact that:

these assertions are contested

Without that state, the integration has two undesirable options:

  1. treat both assertions as ordinary independent facts; or
  2. rebuild Utopia's conflict-detection logic downstream.

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:

  1. Should conflict/review state be represented directly on the affected statements, as separate conflict resources, or both?
  2. Which statuses are part of the durable epistemic model versus UI workflow state?
  3. 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:

  1. Create two assertions that Utopia recognizes as conflicting.
  2. Export the base as Turtle or JSON-LD.
  3. Both assertions remain present with their normal evidence/provenance.
  4. The export also contains enough structured information to identify that the assertions are part of the conflict.
  5. The conflict kind and current state can be read without depending on /api/v1 or database tables.
  6. 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.

Activity

  1. ansonnmm commented on Sep 11, 2026

    @ansonnmm
    ContributorAuthor

    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:

    1. Should both fact_conflicts and axiom_violations be part of the first RDF contract cut?
    2. 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.

  2. WaylandYang commented on Sep 11, 2026

    @WaylandYang
    Contributor

    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.

  3. WaylandYang commented on Sep 11, 2026

    @WaylandYang
    Contributor

    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.

  4. ansonnmm commented on Sep 12, 2026

    @ansonnmm
    ContributorAuthor

    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:

    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.

  5. WaylandYang commented on Sep 12, 2026

    @WaylandYang
    Contributor

    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:

    kind criterion
    self_loop owl:IrreflexiveProperty
    asymmetry owl:AsymmetricProperty
    cycle owl:TransitiveProperty
    functional owl:FunctionalProperty / owl:InverseFunctionalProperty
    signature rdfs:domain and rdfs:range, together

    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.

  6. ansonnmm commented on Sep 12, 2026

    @ansonnmm
    ContributorAuthor

    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:

    1. a reasoning run records an open violation under axiom A;
    2. the relation is edited so A is removed or widened;
    3. update_relation_type commits the ontology edit without rerunning consistency;
    4. the old axiom_violations row remains open until a later reasoning run;
    5. /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:

    1. 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.

    2. 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.

  7. ansonnmm commented on Sep 21, 2026

    @ansonnmm
    ContributorAuthor

    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?

  8. WaylandYang commented on Sep 25, 2026

    @WaylandYang
    Contributor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions