Effective Relations Engine¶
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 |
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.
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 bulk_node_metrics, so connections, type, location, unresolved and the *_label / *_uuid chips equal the panel |
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.
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_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.