Backend Port Contract¶
This document specifies the constraints a third-party labelled-property-graph (LPG)
backend must satisfy to be a conforming doxastica storage backend. It is keyed to the
internal BackendPort Protocol (src/doxastica/ports.py), the seam below the public
BeliefStore, the one the backend-agnostic core writes against.
The port is LPG-primitive, not Cypher-level: a backend implements five graph
primitives, never a query language. A conforming backend exposes no run / query /
execute method and no method taking a query string. That boundary makes a dialect
passthrough (and the injection / triple-leak surface it would re-open) unrepresentable.
The executable form of this contract is the parameterised conformance suite, which the LadybugDB reference adapter and the in-memory oracle both pass identically.
1. Data model¶
A backend MUST emulate a labelled property graph:
- Nodes carry a
label(a string) plus a property map keyed by a node id (node_id, aUUIDorstr). - Edges are typed and directed (
edge_type,from_id→to_id), with an optional property map.
No relational or document semantics are required beyond emulating this graph. A backend built on a relational store, a document store, or an in-memory dictionary is conforming so long as it presents these node/edge semantics through the five primitives below.
2. Primitive operation semantics¶
A backend MUST implement exactly these five primitives, with these semantics:
upsert_node(label, node_id, props)— insert-or-update keyed bynode_id. Idempotent: re-upserting the samenode_idnever produces a duplicate node.add_edge(edge_type, from_id, to_id, props=None)— add a typed directed edge. Append-only in practice: the core never asks a backend to delete an edge, so a backend may assume edges are never removed.match_nodes(label, where) -> list[dict]— return the nodes oflabelwhose properties exact-match every entry inwhere(AND-combined). No query language is exposed;whereis a plain property-equality predicate map, never a string.traverse(start, edge_types, max_depth, direction="out") -> (reached, frontier)— the single graph-walk primitive. Following only edges inedge_typesfromstart, it returns:reached: the de-duplicated, cycle-safe set of reachable nodes (a visited-set walk; the backend MUST terminate on cyclic graphs, never loop);frontier: the set of node ids left unexpanded whenmax_depthis reached.
max_depth=None ⇒ full transitive closure with an empty frontier. A finite
max_depth bounds the walk and reports the boundary in frontier, so a bounded cascade
never silently under-reports. get_impact and get_scope_at compose from this single
primitive; there is no separate per-query method.
direction selects which edges to follow from start:
- "out" (the default) follows edges FROM start (its successors): the OUTGOING
walk get_scope_at composes. The default is "out" so every existing positional caller
and get_scope_at keep their behaviour unchanged.
- "in" follows edges INTO start (its predecessors): the dependency cascade
get_impact walks AGAINST the stored arrows (edge storage is dependent→source).
BOTH directions on BOTH backends MUST agree (the conformance suite exercises each).
reached ordering remains non-contractual in either direction (see
Ordering left to the core).
- unit_of_work() -> AbstractContextManager[None] — an atomic (all-or-nothing)
write-transaction context manager. The core groups a multi-write operation (e.g. a
revise: append a new BeliefState, lay its HAS_REVISION hub edge, and lay the
SUPERSEDES edge to the prior state) inside one unit_of_work; either all writes commit
or none do. There is no stored CURRENT_STATE pointer for the backend to re-point:
current state is derived by the core from the append-only states, so the backend only
ever appends.
3. Uniqueness¶
The backend MUST enforce uniqueness on the node primary id (node_id). The core relies
on state_id uniqueness for correctness. LadybugDB provides this via PRIMARY KEY; no
separate UNIQUE constraint is needed or available. A backend MUST reject or coalesce a
second node sharing an existing primary id (consistent with upsert_node idempotency).
4. Append-only safety¶
The core is append-only: it never deletes nodes or edges. Contraction marks a state
(status='retracted') rather than removing it. A backend MAY therefore assume no deletions
ever occur and optimise accordingly; it MUST NOT require a delete primitive.
5. Ordering left to the core¶
The core orders match_nodes / traverse results itself, by
(source_event_id byte-order, state_id tiebreak) (the UUID7 ordering contract). A
backend need not return results in any particular order, but it MUST return all
matches (no silent truncation, no pagination cap that drops matches).
6. Value opacity¶
Node value properties are opaque blobs (JSON-encoded by the core). The backend stores
and returns them verbatim and never interprets them: no eval, no query construction
from value content, no schema imposed on their shape. Opacity is the control that keeps
arbitrary value content from becoming a backend-side execution surface.
7. Conformance¶
A backend is conforming exactly when it passes the parameterised AGM / Hansson +
structural-invariant suite, the executable form of this contract.
The in-memory oracle and the LadybugDB reference adapter run the identical parameterised
tests; passing them is the definition of a conforming backend. AGM recovery is a named
xfail (it is false for belief bases) and is never asserted against a correct backend.