Skip to content

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


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_nodeeidos: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 in server.py), the metric catalog reads no parent_of, /metrics/batch injects 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 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.