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-
stoprelationInheritancerule for the predicate whoseappliesTocovers the node (global, or a subtree containing the node), and whose optionalbody.conditionholds 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 toresolve_effective_outgoing(...)per source node (via the injectedresolver— exactly the adapterserver._resolve_effective_outgoingalready 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_sourcepreserved. 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)raisesValueError(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'sfromon an inherited row points at the ancestor it inherits from;_viewed_fromkeeps 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-copiedlist[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-deriveis_inheritedor walkparent_ofhere, 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.pyfreezes 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 ainheritance_resolver=kwarg of shape(node_id, parent_of) -> list[dict]. The caller (server._write_today_archive_once+routes/domain_io.pydownload endpoint) binds it toserver._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, viadomain.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_idon every row. _shape_archive_inherited_rowsremaps engine rows to the archive's inherited-row shape (preserved invariant — read byreconstruction_serviceand 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 Nonefilter 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: truebase 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_fromprovenance (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 viarulebook_as_of(T)(issue #604) — the inheritance resolver reads from the fold's post-state (nodes, parents, relations minus tombstones) and is bound toRulebookEngine(book_as_of_T), not today's liveRULEBOOK. The resolver is built inbackend/domain/historical_inheritance.pythe wayRulebookEngine.load()builds the live engine (v2 schema check +documentPropagationseed) and is bound on the CALLER side (route handler), sopersistence.reconstruction_servicestays engine-free — same pattern as the live-pathserver._resolve_effective_outgoing(#821). Anas_ofexport is therefore historically faithful in its inherited rows too; theeidos:delivery.reconstruction_semanticmarker on a reconstruction response israw_graph_and_derived_quantities_as_of_T. If T precedes the earliest snapshot-bearing rulebook event, reconstruction raisesRulebookHistoryFloorError→ canonical 404rulebook_before_history_floor(same shape as the audit floor; see theoperation-record-enginepage §"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 stampseidos:delivery.rulebook_history_available: false+ a downgradedreconstruction_semantic: raw_graph_as_of_T_inherited_rows_flag_onlyso 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 inserver.py), the metric catalog reads noparent_of,/metrics/batchinjects the live rulebook.tests/test_occurrence_addressing.py— occurrence addressing (#1127 A, B, E):/resolveanswers 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 ininfra.key_derivation, and the refusal ofoccurrence_refas 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 aseffective-relations-engine;tests/test_engine_registry_guard.pykeeps 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.