Skip to content

Effective Relations Engine

Verified against the code · 9e119ef0950aa1f8c8f87a640132addf0109872c · 2026-10-20

956 D1b-3 (2026-10-20): 2 finding(s) fixed — the 5.1b scope boundary now lists the 8 backend modules calling read_relations (incl. metrics/context.py ModelCtx.effective_outgoing) and states _BYPASS_BASELINE is 0 (5.1e #867). Re-verified against main 9e119ef0. #1126 #607 S2 (2026-10-25): new section 4 and the node_label injection — an inherited signal relation carries occurrence_id (key_derivation.derive_occurrence_uuid), occurrence_ref and derivation. Checked against backend/domain/effective_relations.py on the #1126 branch (base b4bead2f). #1127 #607 S3 (2026-10-25): section 4 addressing — GET /resolve answers an occurrence IRI with its product (id_domain_index.resolve_occurrence, the product's gate) and the proxy's overlay rows carry the occurrence fields (inherited_occurrence). Checked against id_domain_index.py, server.py and proxy_router.py on the #1127 branch (base 6774a2b6).

Layer: domain

Overview — one answer to "which relations does this node carry?"

A node's effective outgoing relations are the relations stored on the node itself plus the ones it inherits from its ancestors. Several readers need that answer: the detail-panel header coordinates (Location, Type), the Connections and Related-information panels, cross-domain property propagation, the Documentation 360° report, the documents resolver, and the tree-view metric chips. Before 2026-09-05 the answer was computed twice — once inline in server.py, once in the metric catalog with a hard-coded "nearest locating ancestor" walk — and the two disagreed on a DEV node: the Location chip showed the parent's location, the panel showed "Add location".

The Effective Relations Engine (backend/domain/effective_relations.py) is now the only implementation. It is a pure domain function; every collaborator is injected:

Injected What it is Who supplies it in production
get_node_relations(node_id) stored relations of one node eidos_loader.get_node_relations (server) or rels_by_node of the loaded graph (metrics)
rulebook the active Rulebook Engine, or None for "no rulebook rules" the cross-worker-fresh RULEBOOK proxy in server.py, passed into /metrics/batch
relation_inherit_on, propagation_on tier gates api.capability.can("relation_inheritance") / can("propagation")
node_props(node_id) the source node's engineering properties, for rule conditions the effective graph
get_ancestors the ancestor walk the engine's own get_ancestors, or the server's patchable _get_ancestors
node_label(node_id) (optional) a node's label, for the human-readable occurrence_ref the loader's nodes (server), the report's or the historical fold's nodes; the metric chips pass none

Because nothing is read from module globals, the engine is testable without the server (GCF 02.01 R9) and lifts into eidos-core unchanged.


What the engine decides

1. Which ancestor relations flow down

The chain is [node] + ancestors (immediate parent first, root last), walked only when at least one tier gate is on; otherwise only the node's own relations are returned. An ancestor's relation is included when either:

  • the relation carries inherit_source: true (the node-specific mechanism set from the relation dialog), or
  • the rulebook has an enabled, non-stop relationInheritance rule for the predicate whose appliesTo covers the node (global, or a subtree containing the node), and whose optional body.condition holds for the source node's properties.

rulebook=None means no rulebook rules at all — flag-only inheritance. There is no third mechanism: no predicate is inherited "by default", and no caller may add its own ancestor walk (GCF 02.01 §3.6 R21 — the rulebook is the canonical registry of inheritance rules).

2. How duplicates and overrides are keyed (BL-ARCH-013)

Every relation is keyed while walking the chain; the first entry for a key wins, and later ones are dropped:

Predicate cardinality (relation catalog) Key Effect
"1" (e.g. isLocatedIn, hasType) (predicate,) nearest wins — a node's own location beats an inherited one even with a different target; two stored locations on one node collapse to the first
"many" (e.g. isConnectedTo) (predicate, target-uuid-or-ref) every distinct target survives

external_domain is deliberately not part of the key, so the same relation loaded twice (once without and once with it) is one entry. When both variants share a key and the same source node, the external_ref-bearing one replaces the UUID-only one, because it resolves to a proper /node-by-ref URL. A farther ancestor never replaces a nearer one.

3. What each result carries

The stored relation, unchanged, plus is_inherited (bool) and inherited_from (source node id, or None). Consumers render provenance from these — the panel's ↗ marker, the chip's inherited state, propagation's inherited_from.

4. Signal occurrences (#607 S2, #1126)

An inherited signal relation — one whose target is in the signal domain, or whose predicate the relation catalog limits to signal targets (hasSignal) — also carries the product's own occurrence of that signal. The occurrence is derived on read; nothing is stored and no invalidation changes (#607 D2).

Field Value
occurrence_id uuid5(NAMESPACE_EIDOS, "occurrence:" + product_id + ":" + signal_node_id + ":" + predicate), minted only by infra.key_derivation.derive_occurrence_uuid (#607 D3). REST (occurrenceId) and FME 2.1.0 emit it as its node IRI.
occurrence_ref product-slug:signal-slug, for example Pumpe-01:Tryk. A label only: no input parses it, and the public node-id parser refuses it (#607 D5). It is set only when the caller injects node_label.
derivation inherit_source (the relation flag carried it down) or rule (a relationInheritance rule did).

Node ids and the predicate are the only inputs, so the id stays the same when the type or the signal is renamed, when the type is re-related to the same signal, across restarts, and across a tools.migration build → import. The source is not an input: a product that reaches one signal through its own type and through its parent's type has one occurrence, and the nearest source rides along as inherited_from and derivation (#607 D6). The keying in §2 already gives that single row, because two paths to one signal node share its (predicate, target-uuid) key. A row gets no occurrence fields when it is the node's own relation, when it is not a signal relation, or when the signal's node id is unknown (an unresolved target, or an external target with no recorded remote uuid). Every other field of every row is unchanged; the occurrence fields are additive.

Addressing (#607 S3, #1127). An occurrence is addressable read-only. GET /resolve?id={occurrenceIri} answers with the owning product's iri, uuid and domain, plus the echoed occurrenceId. Because the id is a one-way uuid5, domain.id_domain_index.resolve_occurrence keeps a reverse map {occurrence_id: product_id} for each index build. It builds the map on the first occurrence lookup by running this engine (through server._resolve_effective_outgoing) over every primary node in the index. The answer goes through the product's own gate in the index (_answer), so a caller who cannot read the product, a degraded build and an unknown occurrence all get the same plain 404. Only primary-domain products are mapped. The hash link opens the product with the signal row revealed (see Canonical Deep Link). A secondary node's inherited overlay rows, built by the proxy (api.proxy_router._apply_secondary_overlay), get the same three fields through inherited_occurrence, the engine's public door to the same derivation.


Callers

Caller How it binds the engine
server._resolve_effective_outgoing(node_id, parent_of) thin adapter: reads RULEBOOK, _tier_can, loader.get_node_relations, _get_ancestors at call time (so tests can patch them) and reads rule-condition properties from the effective graph
make_jsonld_node → eidos:externalRelations / eidos:outgoingRelations via the adapter — this is what the detail panel renders
_compute_cross_domain_propagated, Documentation 360°, routes/documents.py, mutations_io_router via the adapter
metrics.context.ModelCtx.effective_outgoing(node_id) binds the loaded graph and the rulebook / tier_can handed to compute_node; memoised per node for the life of the context
GET /metrics/batch passes rulebook=RULEBOOK, tier_can=_tier_can into metrics.engine.compute_node, so connections, type, location, unresolved and the *_label / *_uuid chips equal the panel
Full-model archive (daily writer + /admin/export/full-model/download?mode=archive) via the 5.1b façade domain.relation_reads.read_relations(mode=EFFECTIVE); build_export_doc(mode="archive") receives the adapter as an inheritance_resolver= DI argument. See Archive materialisation (FMDE) below.

Invariant: a tree chip and the detail panel always show the same location and type for a node, because both numbers come from this one function with the same rulebook. A node under no covering rule shows no location in the panel and no location chip. The daily archive now carries the same inherited-row set the panel renders for the primary domain — the archive-vs-panel inheritance disagreement that preceded #821 is gone there. Secondary domains in the archive still use the pre-#821 flag-only fallback until a per-domain resolver is injected on the secondary side (see Archive materialisation (FMDE) — scope boundary).


The read_relations façade (5.1b)

Hærdning 5.1b (#813) added ONE declared surface over this engine: backend/domain/relation_reads.py. Every relation-read in the backend will eventually flow through it. The engine itself stays pure and unchanged — the façade stacks a thin projection + mode gate on top. Spec SSOT: docs/specs/graphview-engine.md §4 (target architecture), §5 (slice 5.1b).

Why one façade

Spec §2 ("projektion er spredt") catalogued three filters that live scattered across today's relation readers: a domain_filter (nodes in the current domain's subtree), an IAM read predicate, and an as_of timestamp for historical reconstruction. ReadScope consolidates all three in one dataclass; the façade applies them in one place.

Spec §3 names the two legitimate reads of relations. ReadMode makes them explicit:

  • ReadMode.EFFECTIVE — delegates to resolve_effective_outgoing(...) per source node (via the injected resolver — exactly the adapter server._resolve_effective_outgoing already is). Own + rulebook / flag-inherited rows — what the detail panel, GraphView, cross-domain propagation, Documentation 360° and the tree-chip metrics see.
  • ReadMode.RAW — persisted relations as they stand, no materialisation, inherit_source preserved. For lossless export / round-trip exporters (spec §3 paths #9-#12). Migrating a RAW path to EFFECTIVE would double-materialise on re-import, so each call site must choose explicitly.

The surface

# backend/domain/relation_reads.py
class ReadMode(Enum):      RAW = "raw"; EFFECTIVE = "effective"

@dataclass(frozen=True)
class ReadScope:
    domain_filter: frozenset[str] | None = None
    iam_principal: object = None   # IamScope-like or None
    as_of: str | None = None

@dataclass(frozen=True)
class RelationView:
    mode: ReadMode
    scope: ReadScope
    relations: tuple[dict, ...] = ()

def read_relations(scope, mode, *, eff, resolver=None) -> RelationView: ...
def serialize(view, fmt="dict") -> list[dict] | str: ...
  • read_relations(..., mode=EFFECTIVE, resolver=None) raises ValueError (fail fast — EFFECTIVE requires an engine-bound adapter).
  • EFFECTIVE rows carry a layer-added field _viewed_from (underscore prefix signals layer metadata, not engine schema) — the id of the viewing node the engine was asked about. The engine's from on an inherited row points at the ancestor it inherits from; _viewed_from keeps the viewer identity so a multi-node iteration flattens into one list. RAW rows are viewer-agnostic by construction and do not carry _viewed_from.
  • serialize(view, fmt="dict") returns a shallow-copied list[dict]. fmt="json" returns a byte-stable UTF-8 JSON string (sort_keys=True). Adding a format here is pure formatting — zero relation logic (if tempted to re-derive is_inherited or walk parent_of here, put it in the engine or the mode gate instead).

Scope boundary

Slice 5.1b landed only the surface + its own unit tests; the production callers were migrated by the later 5.1 slices. Today read_relations( is called from eight backend modules: server.py, persistence/export_service.py (the full-model archive — see Archive materialisation (FMDE) below), persistence/domain_export.py, api/mutations_io_router.py, iam/relations.py, reports/documentation_360.py, metrics/context.py (ModelCtx.effective_outgoing, metrics/context.py:94) and routes/documents.py. _BYPASS_BASELINE in tests/test_graphview_relation_ratchet.py:281 is 0: trin 5.1 closed at 5.1e (#867) and the remaining sites were reclassified as node-walkers.

Guards

  • tests/test_graphview_relation_ratchet.py freezes the baseline count of hand-rolled relation traversals outside the façade (_BYPASS_BASELINE).
  • Unit tests cover: RAW output equals the raw iteration path, EFFECTIVE output equals the engine output, and the three projection filters compose correctly.

Archive materialisation (FMDE)

The full-model archive (eidos:mode: "archive", both the daily snapshot and the /admin/export/full-model/download?mode=archive endpoint) is where this engine's output lands on disk. Before hærdning 5.1c-2 (#821), the archive built its own flag-only descendant expansion — a second inheritance implementation that ignored the rulebook, rule conditions, and cardinality. Spec SSOT: docs/specs/graphview-engine.md §10.

Post-5.1c-2:

  • export_service.build_export_doc(mode="archive") takes a inheritance_resolver= kwarg of shape (node_id, parent_of) -> list[dict]. The caller (server._write_today_archive_once + routes/domain_io.py download endpoint) binds it to server._resolve_effective_outgoing — the same adapter the detail panel and metric chips call. Persistence does NOT import the engine (01.01 R4/R6 — DI only, via domain.relation_reads).
  • The façade read_relations(scope, ReadMode.EFFECTIVE, eff, resolver) iterates each primary-domain node once, routes through the engine, and stamps _viewed_from = node_id on every row.
  • _shape_archive_inherited_rows remaps engine rows to the archive's inherited-row shape (preserved invariant — read by reconstruction_service and graphanalyzer):
Field Source Note
from _viewed_from (descendant) overrides the engine's from (which points to the ancestor the row inherits from) so the row reads as "lives on the descendant"
inherited_onto _viewed_from (descendant) the base-vs-inherited split filter
inherited_from engine's provenance (ancestor id) new; additive provenance
inherit_source preserved as present on the engine row a rulebook-only inherited row keeps inherit_source=false — provenance is in inherited_from
  • Base rows (not inherited) are unchanged. The inherited_onto is None filter still splits base from inherited correctly.
  • Resolver is memoised per archive build (one engine walk per source node per build — Contract 13 §3.4).

Expected delta vs. pre-5.1c-2

Row class Pre Post
Flag-inheritable, no condition included included (unchanged)
Rulebook-rule-inheritable, flag absent missed included (rows VUNDET)
Flag=true, condition blocks descendant wrongly included excluded (rows TABT i)
Cardinality=1, multiple ancestors inherit duplicated deduped — nearest wins (rows TABT ii)

Fingerprints for the same model change post-5.1c-2 — expected and documented in the slice. No FORMAT_VERSION bump (shape unchanged, §10.7-2).

Scope boundary (5.1c-2)

  • Only the primary domain of the archive is engine-materialised. Secondary (non-primary) domains fall back to the legacy flag-only expansion via _materialize_inherited_relations — the pre-#821 behaviour kept as a documented fallback. The primary's engine cannot answer rulebook questions for a secondary domain (its loader does not know the secondary's nodes/rulebook), and a rulebook-faithful secondary path needs a per-domain resolver injection on the secondary side (deferred to a later slice). Consequences a reader of a secondary-domain archive row should know:
  • inherit_source: true base rows are still expanded onto descendants (unchanged pre-#821 shape).
  • That expansion is not rulebook-faithful — rule conditions and cardinality are ignored for secondary rows (same two divergent-implementation defect the primary just moved past).
  • Secondary inherited rows do not carry inherited_from provenance (that is an engine-only field; the flag-only path has no ancestor chain to attribute).
  • Reconstruction (reconstruct_as_of) now wires the engine with a HISTORICAL rulebook via rulebook_as_of(T) (issue #604) — the inheritance resolver reads from the fold's post-state (nodes, parents, relations minus tombstones) and is bound to RulebookEngine(book_as_of_T), not today's live RULEBOOK. The resolver is built in backend/domain/historical_inheritance.py the way RulebookEngine.load() builds the live engine (v2 schema check + documentPropagation seed) and is bound on the CALLER side (route handler), so persistence.reconstruction_service stays engine-free — same pattern as the live-path server._resolve_effective_outgoing (#821). An as_of export is therefore historically faithful in its inherited rows too; the eidos:delivery.reconstruction_semantic marker on a reconstruction response is raw_graph_and_derived_quantities_as_of_T. If T precedes the earliest snapshot-bearing rulebook event, reconstruction raises RulebookHistoryFloorError → canonical 404 rulebook_before_history_floor (same shape as the audit floor; see the operation-record-engine page §"Rulebook events are reconstructable (#592)" for the pointer-lookup mechanics). An instance with NO rulebook history at all falls back to flag-only inheritance and stamps eidos:delivery.rulebook_history_available: false + a downgraded reconstruction_semantic: raw_graph_as_of_T_inherited_rows_flag_only so a consumer can distinguish fallback from fully historical reconstruction. IIS criticality is still today's engine on today's IIS DB — a scoped simplification, tracked for a sibling issue.

Guards

  • tests/test_effective_relations_engine.py — unit tests for every gate and keying rule, plus contract tests: the server adapter delegates (no chain walk left in server.py), the metric catalog reads no parent_of, /metrics/batch injects the live rulebook.
  • tests/test_occurrence_addressing.py — occurrence addressing (#1127 A, B, E): /resolve answers the product and its domain, an unreadable product or a degraded index gives the product's plain 404, and the proxy's secondary-node rows carry the node detail's occurrence fields.
  • tests/test_occurrence_identity.py — the signal-occurrence acceptance (#1126 A–F): determinism, rename and re-relation stability, one occurrence per path set, the ratchet that keeps occurrence-id derivation in infra.key_derivation, and the refusal of occurrence_ref as an input.
  • tests/test_metrics_batch_location_rulebook.py — the DEV configuration through the real route (subtree rule elsewhere → chip 0 == panel; rule covering the node → chip 1 == panel).
  • .archon/enforcement/engine_registry.yaml — registered as effective-relations-engine; tests/test_engine_registry_guard.py keeps the entry point and interface honest.

Known gap

Relations authored via the UI dialog are stored UUID-only (to + external_uuid, no external_ref). The engine returns them correctly, but the chip's location_label is then empty (the metric layer does no cross-domain I/O), while the panel resolves the tag through the secondary domain. Tracked as issue #572.