Relation Catalog¶
Verified against the code · 19d408be · 2026-08-25
Every canonical operation in the YAML (frontend: loadRelationCatalog, getApplicablePredicates, getAssociationPredicates, isTargetCompatible, getPredicateById, resolveTargetDomainForPredicate, isCableNode; backend: get_all, get, filter_for_source_type, validate_relation, validate_relation_domains, is_hierarchy, is_available_for_association, get_association_predicates, get_default_association_predicate, get_allow_unresolved, get_cardinality, invalidate_cache) matches an identically-named, identically-signatured function in frontend/relation-catalog.js and backend/domain/relation_catalog.py respectively. The compiled doc's detailed claims — validation stage order and error codes (HIERARCHY_VIA_RELATION_ENDPOINT, UNRESOLVED_NOT_ALLOWED, CATALOG_VIOLATION, DOMAIN_NOT_ALLOWED_FOR_PREDICATE) in mutation_engine.validate_create_relation, the lazy from domain import relation_catalog import there, the module-level import and GET /relation-catalog endpoint plus get_cardinality() use during inheritance resolution in server.py, and the isCableNode cable-code-prefix logic — all verified against the current code.
Layer: frontend
Overview — What the Relation Catalog Is¶
The Relation Catalog is the single authority for predicate definitions in EIDOS Explorer. A predicate is the type of a relation — for example isLocatedIn, isConnectedTo, or hasPart. The catalog defines what predicates exist, what constraints they carry, and how the UI and backend treat them.
Why relation types are cataloged separately from instances. Relation instances (the actual edges in the knowledge graph) live in the mutation overlay: files under mutations/ that are loaded, merged, and persisted by the Mutation Engine and Mutation Store. The catalog is concerned only with what kinds of edges are legal and what rules govern them. This separation means:
- The catalog can be read-only and loaded once per process without touching the mutable overlay.
- Validation rules are co-located with type definitions rather than scattered across callers.
- The frontend can pre-load the full type vocabulary once per session and filter it locally for every user action, without roundtripping to the server for each predicate lookup.
The catalog is stored as a JSON file at backend/relation_catalog.json and is served to the frontend via the GET /relation-catalog endpoint. On the backend, all domain logic for predicate lookups and validation is in backend/domain/relation_catalog.py. On the frontend, session caching and filtering helpers are in frontend/relation-catalog.js.
Catalog Schema¶
Each predicate entry in relation_catalog.json has the following fields:
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Stable camelCase identifier used in mutation commands and stored relation documents (e.g. "isLocatedIn"). |
label |
string | yes | Human-readable display name shown in the UI (e.g. "Is Located In"). |
noun_label |
string | no | Short noun form used as the button caption in the Add-association picker — surfaces what the user is creating, not which domain they are targeting. Falls back to label when absent. |
category |
string | yes | Logical grouping. One of Hierarchy, Spatial, Connectivity, Classification, Reference, Procurement, General. The value Hierarchy triggers special enforcement: these predicates are structural tree edges and are blocked from the association API. |
description |
string | yes | One-sentence description shown in tooltips and documentation. |
cardinality |
string | yes | "1" (single-valued — nearest-ancestor-wins when inherited) or "many" (multiple targets allowed). Governs deduplication during inheritance resolution in the server. |
source_types |
array of string | yes | Allowed source node class values (e.g. ["Component"]). An empty array means any source type is allowed. Reserved for a future second refinement layer; all current entries are empty. |
target_types |
array of string | yes | Allowed target node class values. Same semantics as source_types. |
source_domains |
array of string | yes | Allowed source domain keys (e.g. ["product"]). An empty array means any domain is allowed. First-layer constraint enforced at write time. |
target_domains |
array of string | yes | Allowed target domain keys (e.g. ["location"]). An empty array means any domain is allowed. |
allow_inheritance |
boolean | yes | Whether this predicate may be marked inheritSource=true in a CreateRelation command, causing the relation to be applied to the entire source subtree. |
allow_unresolved |
boolean | yes | Whether a CreateRelation command may carry an unresolvedTargetRef — a deferred local reference to a target node that does not yet exist. |
creates_interface |
boolean | yes | Informational flag; signals that instantiating this predicate should cause an interface edge to be rendered in the graph view. |
inverse |
string or null | yes | The id of the inverse predicate (e.g. "isConnectedTo" is its own inverse). null means no inverse is defined. |
enabled |
boolean | yes | When false, the predicate is excluded from get_all() and therefore from every downstream filter and validation. |
The catalog file also carries a top-level "version" field (currently "1.0") and two _comment keys that are documentation-only and are not read by code.
Lookup Operations¶
Frontend: session cache and filter helpers¶
The frontend loads the catalog once per browser session and caches it in the module-level _catalog variable.
async function loadRelationCatalog()
Fetches GET /relation-catalog via apiFetch. On success, stores the response in _catalog. On failure, falls back to { predicates: [] } and logs a warning — the UI degrades gracefully (empty picker) rather than crashing.
function getPredicateById(id)
| Param | Type | Description |
|---|---|---|
id |
string | The predicate id field to match. |
Returns the predicate object from the in-memory cache, or null if not found. Used when the caller already knows the predicate ID (e.g. when rendering an existing relation pill).
function getApplicablePredicates(sourceTypeClass)
| Param | Type | Description |
|---|---|---|
sourceTypeClass |
string or falsy | The class / productType of the source node. Pass null or undefined to return all predicates. |
Returns all cached predicates whose source_types is empty (any source allowed) or includes sourceTypeClass. This is the coarse first-pass filter; domain-level filtering is applied subsequently by getAssociationPredicates.
function isTargetCompatible(predicate, targetTypeClass)
| Param | Type | Description |
|---|---|---|
predicate |
object or null | A predicate definition object from the catalog. |
targetTypeClass |
string or falsy | The class of the candidate target node. |
Returns true when the predicate's target_types is empty (any target allowed), or when targetTypeClass is absent, or when targetTypeClass is in the target_types list. Used in the Relation Builder tree to grey out incompatible target nodes.
function getAssociationPredicates(sourceTypeClass, sourceDomain, availableDomains)
| Param | Type | Description |
|---|---|---|
sourceTypeClass |
string or falsy | The source node's class. |
sourceDomain |
string or falsy | The domain key of the source node (e.g. "product"). |
availableDomains |
array of string or falsy | The set of domain keys that actually exist in this EIDOS instance, read from DOMAIN_REGISTRY. |
This is the primary filter for the Add-association picker (BL-FE-064). It applies three sequential filters to the cache:
- Keeps only predicates with
available_for_association === true(the backend-computed flag that excludes Hierarchy predicates and respects the per-instance allow-list). - If
sourceDomainis given, keeps only predicates whosesource_domainsis empty or includessourceDomain. - If
sourceTypeClassis given, keeps only predicates whosesource_typesis empty or includessourceTypeClass. - If
availableDomainsis a non-empty array, drops predicates whosetarget_domainsis non-empty but contains no key present inavailableDomains— this prevents dead-end buttons for predicates that require a secondary domain (e.g.isLocatedInon a single-domain deployment with no location tree).
function resolveTargetDomainForPredicate(predicate, sourceDomain, availableDomains)
| Param | Type | Description |
|---|---|---|
predicate |
object or null | A predicate definition from the catalog. |
sourceDomain |
string or null | Domain key of the source node. |
availableDomains |
array of string or null | Deployed domain keys for this instance. |
Returns the concrete domain key the Add-association editor should open for the target picker. If the predicate constrains target_domains to a specific list, it picks the first key from that list that is present in availableDomains. If the predicate has no target domain constraint (intra-domain predicates such as relatedTo or isConnectedTo), it returns sourceDomain. Returns null when no usable target domain exists — the predicate should have been filtered out upstream by getAssociationPredicates, but this acts as defence in depth.
Backend: catalog module lookups¶
def get_all() -> list
Returns all enabled predicate definitions (those with "enabled": true or with the field absent, defaulting to true). This is the base query used by every other backend function — callers should never read relation_catalog.json directly.
def get(predicate_id: str) -> dict | None
| Param | Type | Description |
|---|---|---|
predicate_id |
str | The predicate id to look up. |
Returns the predicate dict or None. The canonical single-record lookup; used by validate_relation, is_hierarchy, get_cardinality, and get_allow_unresolved.
def filter_for_source_type(type_class: str) -> list
| Param | Type | Description |
|---|---|---|
type_class |
str | The source node class to filter against. |
Backend equivalent of the frontend getApplicablePredicates. Returns enabled predicates whose source_types is empty or contains type_class.
Validation at Write Time¶
When the frontend submits a CreateRelation mutation command to POST /mutations/relation, the Mutation Engine calls two catalog validation functions before the command is written to the overlay.
Stage 1: Hierarchy guard¶
# In mutation_engine.validate_create_relation
if rel_type_raw and _catalog.is_hierarchy(rel_type_raw):
result.add_error("HIERARCHY_VIA_RELATION_ENDPOINT", ...)
return result
def is_hierarchy(predicate_id: str) -> bool
Returns True if the predicate's category equals "Hierarchy". The mutation endpoint rejects any such attempt immediately with error code HIERARCHY_VIA_RELATION_ENDPOINT. Hierarchy predicates (hasPart, hasSubLocation, hasSubDiscipline, hasSubType, hasSubSignal) are exclusively managed by the tree-edit flow (Add child / Insert) and must never be created via the association API. This guard prevents stale frontends, CLI scripts, or third-party integrations from silently re-parenting nodes.
Stage 2: Unresolved-target gate¶
def get_allow_unresolved(predicate_id: str) -> bool
If the command carries an unresolvedTargetRef (a deferred local target that does not yet exist), the engine checks whether the predicate permits it. Unknown predicates return False (conservative deny). Error code: UNRESOLVED_NOT_ALLOWED.
Stage 3: Type-class validation¶
def validate_relation(
predicate_id: str,
source_type: str,
target_type: str,
inherit_source: bool,
) -> list[str]
| Param | Type | Description |
|---|---|---|
predicate_id |
str | The predicate ID from the command's relationType field. |
source_type |
str | The class of the source node, looked up from the effective graph state. |
target_type |
str | The class of the target node. Empty string when the target is cross-domain or unresolved. |
inherit_source |
bool | Whether the command carries inheritSource=true. |
Returns a list of error strings. An empty list means the relation is valid. Checks:
- Unknown predicate ID → error, no further checks.
source_typesnon-empty andsource_typenot in list → error.target_typesnon-empty andtarget_typenot in list → error.inherit_source=Truebutallow_inheritance=false→ error.
All errors are wrapped by the engine with error code CATALOG_VIOLATION.
Stage 4: Domain-pair validation¶
def validate_relation_domains(
predicate_id: str,
source_domain: str | None,
target_domain: str | None,
) -> list[str]
| Param | Type | Description |
|---|---|---|
predicate_id |
str | The predicate ID. |
source_domain |
str or None | Domain key of the source node. Derived from externalSourceDomain in the command or from the EIDOS_DOMAIN / DOMAIN_KEY environment variable. |
target_domain |
str or None | Domain key of the target. Derived from externalDomain in the command or from the primary domain env var. |
Checks source_domains and target_domains in the same style as the type check: if the predicate's domain list is non-empty and the supplied domain is not in it, an error is returned. None for either side is treated as "unknown" and the corresponding constraint is not enforced — existing cross-domain flows that do not pass explicit domain information continue to work. Errors are wrapped with DOMAIN_NOT_ALLOWED_FOR_PREDICATE.
Cardinality enforcement (server-side, not mutation-engine)¶
def get_cardinality(predicate_id: str) -> str | None
The server uses this function during relation inheritance resolution (BL-ARCH-013) to decide the deduplication key. For cardinality="1" predicates the key is (predicate,) — so the nearest ancestor's value wins regardless of target. For cardinality="many" the key is (predicate, target_ident) — all distinct targets survive.
Adding a New Relation Type¶
To add a new predicate to the catalog, follow these steps exactly.
Step 1: Add an entry to backend/relation_catalog.json.
Insert a new object into the predicates array. Every field must be present:
{
"id": "myNewPredicate",
"label": "My New Predicate",
"noun_label": "New Thing",
"category": "General",
"description": "One-sentence description of what this relation means.",
"cardinality": "many",
"source_types": [],
"target_types": [],
"source_domains": [],
"target_domains": [],
"allow_inheritance": false,
"allow_unresolved": false,
"creates_interface": false,
"inverse": null,
"enabled": true
}
Choose category carefully: setting "Hierarchy" blocks the predicate from the association API permanently (by design). If the predicate is structural, that is correct; otherwise use any other category string.
Set source_domains and target_domains to restrict which domain pairs the predicate is legal for. An empty array means any domain is allowed. Example: "source_domains": ["product"], "target_domains": ["location"] restricts to product-to-location edges only.
Step 2: Verify the catalog loads.
The backend loads relation_catalog.json once at startup and caches it. In development, call relation_catalog.invalidate_cache() in the Python REPL or restart the server. The endpoint GET /relation-catalog returns the enriched predicate list; confirm the new entry appears with the expected fields and available_for_association computed correctly.
Step 3: Confirm validation behavior.
Call POST /mutations/relation with a CreateRelation command that uses the new predicate ID. Verify that:
- A command with a valid source and target passes.
- A command with a source domain not in
source_domainsis rejected withDOMAIN_NOT_ALLOWED_FOR_PREDICATE. - If
allow_inheritanceisfalse, a command with"inheritSource": trueis rejected withCATALOG_VIOLATION.
Step 4: Confirm frontend exposure.
If the new predicate should appear in the Add-association picker, ensure category is not "Hierarchy". If the instance uses add_association_allow_list (set in instance config under relations.add_association_allow_list), add the new predicate's id to that list as well, otherwise it will be suppressed even though it is enabled in the catalog.
Effect on validation. From the moment the backend reloads the catalog, validate_relation and validate_relation_domains enforce the new predicate's constraints on every CreateRelation mutation. The frontend picks up the new predicate on its next loadRelationCatalog call (once per session; a hard refresh is sufficient in development).
Error Handling¶
| Error Code | Source | Cause |
|---|---|---|
HIERARCHY_VIA_RELATION_ENDPOINT |
mutation_engine.validate_create_relation |
Attempt to create a Hierarchy-category predicate via the association API. |
UNRESOLVED_NOT_ALLOWED |
mutation_engine.validate_create_relation |
unresolvedTargetRef on a predicate with allow_unresolved=false. |
CATALOG_VIOLATION |
mutation_engine.validate_create_relation |
validate_relation returned errors (unknown predicate, type mismatch, or inheritance not allowed). |
DOMAIN_NOT_ALLOWED_FOR_PREDICATE |
mutation_engine.validate_create_relation |
validate_relation_domains returned errors (source or target domain not in the predicate's allowed list). |
On the frontend, a failure to fetch /relation-catalog (network error, 401, etc.) is caught in loadRelationCatalog, a warning is logged to the browser console, and _catalog is set to { predicates: [] }. All downstream filter functions (getApplicablePredicates, getAssociationPredicates, etc.) return empty arrays on an empty catalog, so the Add-association picker shows no options rather than crashing the UI.
Integration with Other Engines¶
Mutation Engine (backend/domain/mutation_engine.py): imports domain.relation_catalog lazily inside validate_create_relation to avoid circular imports. Calls is_hierarchy, get_allow_unresolved, validate_relation, and validate_relation_domains in sequence. Wraps errors with typed codes before returning a ValidationResult.
Server / API layer (backend/server.py): imports relation_catalog at module level. Calls get_all() and is_available_for_association() inside the GET /relation-catalog handler to build the enriched predicate list. Calls get_cardinality() during relation inheritance resolution to determine the deduplication key for single-valued predicates.
Frontend Relation Builder (frontend/relation-catalog.js): the frontend module is the sole consumer of the /relation-catalog endpoint. It holds the session cache and exposes the filter helpers (getAssociationPredicates, resolveTargetDomainForPredicate, isTargetCompatible) that drive the Add-association picker and the Relation Builder tree without further server requests.
Cable classification (isCableNode): also lives in frontend/relation-catalog.js as a shared canonical function (GCF-02.01 single-owner rule). It is not a catalog lookup — it classifies a node as a cable by its IEC 81346 designation prefix. Both the cable-list export and the node detail panel's connection traversal call isCableNode; the logic is never duplicated.