Rulebook Engine¶
Diverges from the code — 3 finding(s) · 19d408be · 2026-08-25
All three first-pass findings survive adversarial verification with exact file:line confirmation: canonical_operations names three functions (apply_property_inheritance/apply_relation_inheritance/apply_property_propagation) that exist nowhere in backend/domain/rulebook_engine.py (whose real methods are cascading_properties, is_relation_inheritable, get_relation_rule, relation_inheritance_overridable, intra_propagation_rules, inter_propagation_rules); the doc's RuleCatalog.add()/remove()/serialize_catalog_for_audit()-as-instance-method claims are false against rule_catalog.py's actual surface (from_rulebook, rules, get, referencing_objects, with add/delete done via free functions add_movement_rule/delete_movement_rule in rulebook_store.py, and serialize_catalog_for_audit being a module-level function); and the doc's three per-category admin PUT routes do not exist in admin_rulebook_router.py, whose actual routes are generic movement-rule CRUD plus separate propagation-rules and rule-catalog families. Each is a genuine API-kind, high-severity contradiction that would mislead a reader into calling nonexistent functions/routes.
Divergences from the code — details
| Sev | Where | Doc says | Code does | Evidence |
|---|---|---|---|---|
| HIGH | canonical_operations: apply_property_inheritance / apply_relation_inheritance / apply_property_propagation | canonical_operations lists apply_property_inheritance, apply_relation_inheritance, and apply_property_propagation as operations of the Rulebook Engine. | No function or method named apply_property_inheritance, apply_relation_inheritance, or apply_property_propagation exists anywhere in backend/domain/rulebook_engine.py (confirmed by full-file read and repo-wide grep — matches only in the KM YAML and generated knowledge.json). The actual read-path evaluation methods are cascading_properties(), is_relation_inheritable(), get_relation_rule(), relation_inheritance_overridable(), intra_propagation_rules(), and inter_propagation_rules(). | backend/domain/rulebook_engine.py:69-129 (whole file) |
| HIGH | ## Rule Catalog -- rule_catalog.py | 'RuleCatalog provides: add(rule) -- validate and register a new rule, raising DuplicateRuleError if the ID already exists; remove(rule_id) -- remove a rule, raising PropagationRuleNotFoundError if absent; serialize_catalog_for_audit() -- produces a JSON-serializable summary.' | RuleCatalog (backend/domain/rule_catalog.py:141-233) has no add() or remove() method; its public surface is init, from_rulebook() classmethod, rules(), get(rule_id), and referencing_objects(rule_id). Adding/removing rules is done by free functions add_movement_rule()/delete_movement_rule() in rulebook_store.py operating on a plain rulebook dict, and delete raises MovementRuleNotFoundError (rulebook_store.py:42,279), not PropagationRuleNotFoundError. serialize_catalog_for_audit is a standalone module-level function taking a catalog argument (rule_catalog.py:257), not a RuleCatalog instance method. | backend/domain/rule_catalog.py:141-233,257-279 (RuleCatalog class + serialize_catalog_for_audit); backend/domain/rulebook_store.py:34,42,50,237,279 (error classes + add/delete_movement_rule) |
| HIGH | ## Integration -- Admin API routes | 'Admin API routes in server.py expose PUT /admin/rulebook/property-inheritance, PUT /admin/rulebook/relation-inheritance, and PUT /admin/rulebook/propagation for managing each rule type.' | No such routes exist. The actual admin rulebook routes, defined in backend/api/admin_rulebook_router.py (included from server.py), are generic movement-rule CRUD: GET/POST /admin/rulebook/rules, PUT/DELETE /admin/rulebook/rules/{rule_id}, POST /admin/rulebook/validate, POST /admin/rulebook/preview, GET/PUT /admin/rulebook (whole-document), plus a separate /admin/propagation-rules family and GET /admin/rule-catalog. There is no per-category property-inheritance/relation-inheritance/propagation PUT endpoint. | backend/api/admin_rulebook_router.py:9-27 |
Layer: domain
Overview — What Rules Are¶
The Rulebook Engine evaluates and applies data-movement rules over the EffectiveGraph. Rules are a distinct concept from IAM permissions: IAM controls who can perform an action, while rules control how data flows between nodes once an action is allowed. Specifically, rules govern three kinds of computed behaviour:
- Property inheritance — a property set on an ancestor node cascades down to all descendants (most-specific value wins).
- Relation inheritance — a relation attached to an ancestor node is inherited by descendants.
- Property propagation — a property on a source node is copied to a target node across a named relation, either within the same domain (intra) or across domains (inter, tier-gated).
- Document context (
documentContext) — which nodes' or documents' documentation counts as context for another node, for the Documentation 360° PDF export. Unlike the first three, this category does not move properties or relations through the effective graph itself — it governs a read-only report, evaluated bydocumentation_360_engine.py, not byRulebookEngine's own apply methods.
Rules are evaluated on the read path only — they do not write new overlay nodes. The resolver functions in server.py call RULEBOOK.get() and apply rule outputs when assembling the response, leaving the on-disk EffectiveGraph unchanged.
The engine is loaded once at startup from project.rulebook.json and is kept coherent across workers via the RulebookProvider sentinel mechanism.
Rule Schema (v2)¶
The rulebook file is a JSON object with four top-level keys:
| Field | Type | Description |
|---|---|---|
version |
string |
Schema version. "2.0" — see Schema v2 migration |
propertyInheritance |
array[object] |
One enveloped rule per cascading property |
relationInheritance |
array[object] |
Enveloped relation-inheritance rules |
propertyPropagation |
array[object] |
Enveloped property-propagation rules |
documentContext |
array[object] |
Enveloped document-context rules for Documentation 360° — see documentContext entry below |
Every category is a list of uniform enveloped rule objects. There is no longer a special block shape for propertyInheritance — a cascading property is its own first-class rule, individually toggleable, nameable, and scoped, exactly like a relation-inheritance or propagation rule.
The rule envelope¶
Every rule, in every category, shares this envelope:
| Field | Type | Description |
|---|---|---|
id |
string |
Persisted, stable. Assigned once (<PREFIX>-<sha1(identifying content)[:10]>) at creation/migration and never recomputed on read or on edit — an opaque handle the editor, endpoints, and any references use. Editing a rule's body keeps the same id; only truly new identifying content gets a new one. |
enabled |
bool |
Per-rule on/off. A disabled rule is stored but not applied at all — distinct from effect:"stop", which is applied and actively cancels. |
effect |
"apply" \| "stop" |
apply (default) moves data; stop cancels a broader rule from its scope forward without supplying a replacement — see Stop rules. |
name |
string |
Human-readable rule name (defaults to a generated summary at migration, e.g. "Inherit owner"). |
description |
string? |
Optional human text for the editor. |
appliesTo |
object |
Explicit, queryable applicability — see below. |
provenance |
object |
{"source": "seed" \| "user" \| "migration" \| "legacy-propagation"} — who/what authored the rule. "legacy-propagation" is set by the separate startup fold (rulebook_migration.fold_legacy_into_rulebook) that migrates the old per-domain propagation_rules.json store into a propertyPropagation envelope. |
body |
object |
The category-specific payload (unchanged in meaning from v1). |
Uniqueness is still enforced on identifying content — category + predicate/property/scope + appliesTo — independent of the stored id (Contract 02.01 §3.6); adding or editing a rule whose identifying content collides with an existing rule raises DuplicateRuleError.
appliesTo — explicit applicability¶
A resolvable, discriminated union naming exactly who a rule governs:
kind |
Shape | Meaning |
|---|---|---|
global |
{kind:"global"} |
Every node in scope of the category |
subtree |
{kind:"subtree", node, label?, path?} |
A node and its descendants. node (a UUID) is the only field any Plan-1 code stores or reads. label/path are optional fields reserved for a future resolver (Plan 2 — the node↔rules applicability model); nothing in the shipped code populates, reads, or recomputes them yet, so no rename-safety or cache-refresh behaviour exists today — treat them as forward-compatible schema slots, not working functionality. |
domain |
{kind:"domain", source, target} |
Cross-domain (propagation): fires from source domain to target domain. |
class |
{kind:"class", class} |
Reserved — not yet resolved or rendered by any code path. |
propertyInheritance entry (body)¶
| Field | Type | Description |
|---|---|---|
property |
string |
The property key that cascades ancestor → descendant |
appliesTo is always {kind:"global"} for property inheritance — there is no per-node scoping. enabled:false on a rule removes just that one property from cascading (there is no longer a single master switch for the whole category).
relationInheritance entry (body)¶
| Field | Type | Description |
|---|---|---|
predicate |
string |
The relation type to inherit (e.g. "owns") |
direction |
string |
Defaults to "downward" |
The old v1 overridable field is dropped — it is not relocated anywhere. appliesTo (see above) replaces the old bare applies_to UUID: {kind:"subtree", node:<uuid>, …} scopes the rule to that subtree, {kind:"global"} applies everywhere. A scoped (subtree) rule takes precedence over a global rule for the same predicate — see most-specific-wins.
appliesTo is part of the rule's identifying content: the persisted id folds in the scope (see _derive_rule_id / _identifying_content in rule_catalog.py), so the same predicate scoped to two different subtree nodes yields two distinct registry entries. This is what lets the generic Add Information dialog turn inheritance on for one predicate under many different parents — each write is a separate scoped rule, not a duplicate of the first. A global rule keeps its predicate-only identity.
Relation-inheritance rules are user-authored only — they are never seeded and never hardcoded. A newly created project's rulebook has an empty relationInheritance; rules enter solely through the Add Information form or the Rulebook editor (each write is a relationInheritance entry, global- or subtree-scoped). The ruleset templates in library/rulesets/ ship no relation-inheritance rules.
propertyPropagation entry (body)¶
| Field | Type | Description |
|---|---|---|
scope |
"intra" \| "inter" |
intra = within a domain; inter = across domains (requires tier2+) |
via |
string |
Relation type across which propagation fires |
direction |
string |
"forward", "inverse", or "both" |
propagatedProperties |
array[string] |
Property keys copied from source to target |
stop_at |
array[string]? |
Node classes that halt the BFS walk |
condition |
object? |
Optional condition gate — see evaluate_condition |
appliesTo is {kind:"domain", source, target}, derived from the body's sourceDomain/targetDomain (or a single legacy domain field, mapped to both). effect:"stop" is reserved/no-op for propagation — the boundary mechanism for propagation remains the existing body.stop_at class list, unchanged.
documentContext entry (body)¶
Feeds the Documentation 360° PDF export
(documentation_360_engine.py), not RulebookEngine's own apply methods —
this category is resolved through the same rules_for_node/get-style
machinery as the others, but consumed by a different, read-only report engine.
| Field | Type | Description |
|---|---|---|
kind |
"via-relation" \| "doc-to-doc" |
Which of the two context mechanisms this rule contributes |
predicate |
string |
For via-relation: a relation-catalog predicate id, or the wildcard "*association" (every non-hierarchy predicate). For doc-to-doc: locked to "hasRelatedDocument" — the only value v1 accepts |
direction |
"outgoing" \| "incoming" \| "both" |
Which direction(s) of the relation/document link count |
via-relation— a node reached from the query node overpredicate(indirection) contributes its own direct-channel (direct/inherited/from-type) documents asvia-nodecontext for the query node.doc-to-doc— from the query node's own direct-channel documents, followhasRelatedDocumentone hop indirectionto surface further documents asdoc-to-doccontext.
appliesTo (global / subtree / type) and effect: "stop" work exactly
as for relationInheritance — a rule can be scoped to a subtree, and a
stop-effect rule cancels a broader via-relation/doc-to-doc rule from its
scope forward without supplying a replacement (e.g. suppressing
via-relation over locatedAt for the Location subtree). Most-specific-wins
picks one winning rule per (kind, predicate) pair per node — a rule scoped
directly to the query node beats one scoped to an ancestor, which beats a
global rule.
Defaults. Two rules are seeded once at startup
(rulebook_schema.seed_document_context_file), only when the documentContext
key is entirely absent from the rulebook — never overwriting a rulebook that
already has the key, even an empty one:
| Name | kind |
predicate |
direction |
appliesTo |
provenance.source |
|---|---|---|---|---|---|
| Associative relations | via-relation |
*association |
both |
global |
default |
| Related documents | doc-to-doc |
hasRelatedDocument |
both |
global |
default |
Rule ids for these defaults are content-derived
(_derive_rule_id("documentContext", {kind, predicate, direction}), a
DC-<sha1[:10]> hash — documentContext's entry in _ID_PREFIX), not
literal strings — provenance.source == "default" marks them as ordinary,
fully editable/removable rules (distinct from a "builtin"-provenance row like
documentPropagation), so a customer may disable or rescope either default
through the generic admin CRUD without any special-case handling. A rulebook
with no documentContext rules (all removed, or the category never seeded)
simply yields no context rules — Documentation 360° then reports the three
direct channels only.
documentContext is registered in rulebook_store._MOVEMENT_CATEGORIES and
rulebook_schema alongside the other three categories, so
GET/POST /admin/rulebook/rules, PUT/DELETE .../{rule_id},
POST /admin/rulebook/validate, POST /admin/rulebook/preview, and
GET /admin/rulebook/for-node all work for it with no new endpoints;
RuleCatalog.from_rulebook registers documentContext rows exactly like the
other movement categories (no documentContext-specific branch beyond the two
already listed above).
Stop rules + most-specific-wins¶
Most-specific-wins is a fixed engine invariant — it is always in force, is never a stored field, and is never toggleable (the old v1 propertyInheritance.strategy field that used to name this is dropped at migration, not relocated). It decides which rule governs a given node when several rules of the same category + identifying-key could apply. Specificity order: a subtree rule whose appliesTo.node is nearer the query node (deeper in its ancestry) beats a shallower subtree rule, which beats a global rule.
A rule may carry effect:"stop" to cancel a broader rule from its scope forward, without supplying a replacement value. Example: rule R1 = relationInheritance{predicate: isLocatedIn} scoped subtree(-HG01), effect:"apply" — descendants of -HG01 inherit its location. Rule R2 = same predicate, scoped subtree(-WDB01) (a descendant of -HG01), effect:"stop". For -WDB01 and its descendants the most-specific rule is R2, so the inherited isLocatedIn relation is cancelled from -WDB01 forward — -HG01's other descendants still inherit via R1.
This subsumes the old overridable flag: overriding with your own value is a descendant having its own apply rule/relation (nearest-wins, already the behaviour); overriding to nothing is a stop rule. enabled:false rules are excluded from resolution before specificity is even considered.
Manual values vs. inheritance rules — current behaviour (explicitly documented; under review)¶
The most-specific-wins order above ranks rules against each other. It does not currently rank a rule against a node's own manually-applied value. The effective-graph relation resolver (domain/effective_relations.py::resolve_effective_outgoing, the canonical engine behind server.py::_resolve_effective_outgoing and the tree-chip metrics) treats a single-valued (cardinality == "1") predicate such as isLocatedIn with nearest-relation-instance-wins: a node's OWN relation is walked first (nearest in the [node] + ancestors chain) and wins for that predicate, and an inherited-via-rule relation from an ancestor is then skipped (key = (predicate,) already seen). The resolver does not distinguish a value a rule produced from one a user applied manually.
One resolver, every reader: the detail panel's header coordinates (Location / Type), cross-domain propagation, Documentation 360, the documents resolver and the tree-view chips (GET /metrics/batch → metrics.catalog_node) all obtain a node's effective outgoing relations from this one engine, with the live rulebook and tier gate injected. No caller walks parent_of on its own to decide inheritance — a relation is inherited only through a relationInheritance rule (or the relation's own inherit_source flag). A node under no covering rule shows no location in the panel and no location chip.
Consequence (what a reader must not be surprised by):
- Enabling a
relationInheritancerule on an ancestor does NOT overwrite a descendant that already has its ownisLocatedIn(e.g.-TAA01 --isLocatedIn--> +FEM). The descendant keeps its own value; the rule's inherited value is shadowed. - An inheritance rule therefore only fills the gap for descendants that have no own value of that predicate.
- Property inheritance (
cascading_properties+_compute_inherited_properties) follows the same nearest-wins shape: a node's own engineering value shadows an inherited one.
Under review (intended future change — not yet implemented). The product intent is nearest rule wins: a manually-applied value is not a rule, so enabling an inheritance rule over a subtree should cascade and override descendants' manual values, and the way to pin an exception under such a subtree is a scoped/stop rule at that node — not a manual value. Implementing this requires the resolver to rank rule-provenance above manual values (and decide whether an overridden manual value is shadowed non-destructively or replaced). Until that lands, the behaviour above (manual shadows rule) is the shipped, authoritative behaviour.
Schema v2 migration — no legacy runtime mode¶
There is no runtime legacy mode: the engine, RuleCatalog.from_rulebook, and every reader accept only v2 — a v1 rulebook (block-shaped propertyInheritance) is rejected loudly (ValueError("rulebook not migrated to v2")) rather than silently misread.
Reaching v2 everywhere is instead guaranteed by a one-time, idempotent, backup-first startup migration, rulebook_schema.migrate_rulebook_file, invoked from server.py before mutation-layer startup completes:
- If the on-disk rulebook is v1 (
propertyInheritanceis a dict, not a list —rulebook_schema.is_v2), it writes a timestamped backup (<name>.bak-<UTC timestamp>) of the original bytes, converts it with the puremigrate_v1_to_v2(rb) -> rb, and atomically overwrites the file — then bumps the rulebook sentinel so every worker reloads. - It is a no-op (returns
False, changes nothing) when the file is absent (fresh install) or already v2 — safe to run on every startup, every worker. - The read-check-backup-write sequence runs under the same cross-process file lock as other rulebook mutations, so sibling workers cannot interleave or double-migrate.
- It must run before
rulebook_migration.migrate_legacy_propagation(the existing fold of the separatepropagation_rules.jsonstore into the rulebook): that fold appends v2 envelopes topropertyPropagation, and running the v1→v2 pass afterwards would misinterpret an already-enveloped entry as a flat v1 body. migrate_v1_to_v2maps:propertyInheritance.cascadingProperties(a flat string array) → one rule per property,appliesTo:{kind:"global"};relationInheritance[i].applies_to→appliesTo:{kind:"subtree", node:…}(absent →{kind:"global"}), and dropsoverridable;propertyPropagation[i]→appliesTo:{kind:"domain", source, target}fromsourceDomain/targetDomain. It is pure (no I/O) and idempotent — v2 in, unchanged copy out.- Tier templates (
library/rulesets/*.rulebook.json) ship v2 from day one; they never pass through this migration.
Core Operations¶
RulebookEngine.__init__¶
def __init__(self, rulebook: dict, deployment_tier: str = "tier1") -> None
Constructs the engine. Immediately validates tier gates — raises ValueError if the rulebook contains relationInheritance rules but the deployment tier is below tier2, or contains inter-scoped propagation rules below the required tier.
RulebookEngine.load¶
@classmethod
def load(cls, path: str, deployment_tier: str | None = None) -> "RulebookEngine"
| Param | Type | Description |
|---|---|---|
path |
str |
Filesystem path to project.rulebook.json |
deployment_tier |
str \| None |
Override tier; falls back to KNOWY_TIER env var, then "tier1" |
Reads the JSON file and constructs the engine. Raises ValueError on tier violations, malformed rules, or a rulebook that is not v2 (rulebook_schema.is_v2 fails) — the loud-fail backstop for the no-legacy-runtime-mode invariant (see Schema v2 migration): the startup migration should have already upgraded the file, so this raise turns an unmigrated file into a failed/health-gated deploy rather than a silent misread.
RulebookEngine.load_default¶
@classmethod
def load_default(cls) -> "RulebookEngine"
Returns an engine (v2 shape — all three category arrays empty) reproducing pre-rulebook behaviour: no cascading, no relation-inheritance rules, no propagation. Used in tests and fresh installs.
RulebookEngine.cascading_properties¶
def cascading_properties(self) -> list[str]
Returns the de-duplicated, order-preserved list of property keys that cascade ancestor → descendant. A propertyInheritance rule contributes its body.property when it is enabled, has effect == "apply", and appliesTo.kind == "global" (property inheritance has no per-node scoping — every rule that reaches this point is post-migration global). A disabled rule, a stop-effect rule, or a non-global appliesTo (not currently produced) is excluded.
RulebookEngine.is_relation_inheritable¶
def is_relation_inheritable(
self,
predicate: str,
node_id: str | None = None,
ancestors: list[str] | None = None
) -> bool
Returns True when get_relation_rule finds a winning rule for predicate at node_id and that rule's effect is not "stop". A stop rule "winning" the resolution (see below) makes this return False — inheritance is cancelled from that rule's scope forward.
RulebookEngine.get_relation_rule¶
def get_relation_rule(
self,
predicate: str,
node_id: str | None = None,
ancestors: list[str] | None = None
) -> dict | None
Returns the most-specific enabled rule (the full v2 envelope, not just body) for predicate at node_id, or None if none matches. enabled:false rules are skipped entirely. Matching and specificity come from RulebookEngine._applies_to_node(applies_to, node_id, ancestors): {kind:"global"} always matches at specificity 0; {kind:"subtree", node} matches when node is in the chain [node_id] + ancestors (the node itself is nearest), at a specificity that grows the closer node sits to the query node — so a rule scoped to the query node itself outranks one scoped to a distant ancestor, which in turn outranks a global rule. On a specificity tie, a subtree rule beats a global one; any further tie is broken by rulebook order (first wins). The returned rule's effect ("apply" or "stop") is what is_relation_inheritable inspects — this method itself does not filter on effect.
RulebookEngine.relation_inheritance_overridable¶
def relation_inheritance_overridable(
self,
predicate: str,
node_id: str | None = None,
ancestors: list[str] | None = None
) -> bool
v1 compatibility shim. Overridability was dropped as a schema-v2 concept — most-specific-wins is a fixed invariant, so "can a descendant override" is no longer a per-rule toggle (it is always true; a descendant's own apply rule or relation always wins by specificity, and a stop rule is how a descendant instead cancels rather than overrides). This method always returns True, kept only so existing callers do not need to change.
RulebookEngine.rules_for_node — node applicability (BL-FE Rulebook Editor Plan (2))¶
@staticmethod
def rules_for_node(
node_id: str,
ancestors: list[str],
domain: str,
catalog_rules: list[dict]
) -> list[dict]
Pure, no I/O. Annotates the exhaustive rule catalog (the same rows serialize_catalog_for_audit produces — see below) down to the subset that governs one node, each row carrying a derivation tag explaining why it applies there. This is the backend half of the Rulebook editor's node-focused mode; it does not read or mutate the rulebook file itself — callers pass in catalog_rules (already built from RuleCatalog.from_rulebook).
| Param | Type | Description |
|---|---|---|
node_id |
str |
The node being queried |
ancestors |
list[str] |
Ancestor UUIDs, nearest first (same shape get_relation_rule takes) |
domain |
str |
The querying domain, used to match propagation rules whose appliesTo names it as source or target |
catalog_rules |
list[dict] |
The full catalog row list (each row already carries category, appliesTo, body, enabled, effect, …) |
Per category:
propertyInheritance— everyenabledrule whoseappliesTomatches the node (property inheritance is always{kind:"global"}, so these always match) is included,derivation.kind = "global".relationInheritance— rules are grouped bybody.predicate, and only the single most-specific-wins winner per predicate is included (the same specificity ruleget_relation_ruleuses: nearersubtreebeats farthersubtreebeatsglobal, subtree beats global on a tie). If the winner'seffect == "stop", its derivation kind is overwritten to"cancelled"— it still governs the node (it is the applicable rule), but its effect is to cancel inheritance from that point down rather than supply a value.propagation/documentPropagation— included whenever the querieddomainmatches either side (appliesTo.source/appliesTo.target, falling back tobody.sourceDomain/body.targetDomain),derivation.kind = "propagation"withsource/target. Propagation rules are not node-scoped by ancestry — they apply per-domain, so every node in a matching domain sees them.- Disabled rules (
enabled:false) are excluded before any matching is attempted, for every category.
derivation shapes (from the internal _derivation(applies_to, node_id, ancestors) helper):
kind |
Extra fields | Meaning |
|---|---|---|
"global" |
— | A {kind:"global"} rule, or a subtree rule whose target is neither the node nor an ancestor (should not normally occur among the rules returned, but is the fallback) |
"here" |
— | A subtree rule scoped directly to node_id itself |
"inherited" |
ancestor (uuid), distance (int, chain index — 0 would be the node itself, so inherited distances start at 1) |
A subtree rule scoped to an ancestor; the node inherits it |
"cancelled" |
ancestor (present when the winning stop rule is subtree-scoped) |
The most-specific relation-inheritance rule for this predicate is a stop rule — inheritance is cancelled here, not applied |
"propagation" |
source, target (domain names) |
A propagation/documentPropagation rule whose domain pair includes the queried domain |
RulebookEngine.intra_propagation_rules / inter_propagation_rules¶
def intra_propagation_rules(self) -> list[dict]
def inter_propagation_rules(self) -> list[dict]
Return the body dict of every enabled propagation rule whose body.scope matches ("intra" / "inter") — the envelope is unwrapped so callers see exactly the flat shape they consumed pre-v2 (scope, via, propagatedProperties, stop_at, …). The server calls these when assembling cross-relation property views.
RulebookEngine.evaluate_condition¶
def evaluate_condition(
self,
condition: dict,
source_props: dict,
target_props: dict
) -> bool
Pure function — no I/O. Evaluates a propagation rule condition against node property dicts. Returns True when the condition passes (rule should fire).
| Condition field | Description |
|---|---|
on |
"source" or "target" — which node's properties to inspect |
property |
Property key to evaluate |
operator |
eq, neq, in, not_in, exists, not_exists, gt, lt, gte, lte |
value |
Expected value; type-aware coercion is applied for bool and numeric comparisons |
An absent or empty condition always returns True (rule fires unconditionally).
Tier Gates¶
The engine enforces deployment-tier access gates at construction time:
| Feature | Minimum tier |
|---|---|
| Property inheritance | tier1 |
| Relation inheritance | tier2 |
inter-scope propagation |
Configured in tier_gates (tier2+) |
Tier thresholds are defined once in backend/infra/tier_gates.py (Contract 02.01 R16) and imported via _TIER_ORDER. A direct rulebook JSON edit that adds tier2 features on a tier1 deployment will be rejected at startup. In v2, the inter-scope check reads rule.get("body", {}).get("scope") — scope lives inside the propagation rule's envelope body, not at the rule's top level.
Rule Storage — rulebook_store.py¶
All mutations to project.rulebook.json go through update_rulebook, a cross-process locked read-modify-write. The store layer owns three error classes:
RulebookStoreError— base classPropagationRuleNotFoundError(rule_id)— raised when a domain-scoped propagation rule ID is not presentMovementRuleNotFoundError(rule_id)— raised when a movement rule's stored ID is not presentDuplicateRuleError(rule_id)— raised when adding or editing a rule whose content-derived identifying-content ID collides with a different existing rule
The pure mutators (add_movement_rule, update_movement_rule, delete_movement_rule, and the domain-scoped add_propagation_rule/update_propagation_rule/delete_propagation_rule) operate on an in-memory rulebook dict and build/consume v2 envelopes (_build_envelope). update_movement_rule is the one place the v2 persisted-id contract is visible end to end: it looks the rule up by its stored rule_id, replaces body/enabled/effect/name/description, and writes the new envelope back under that same id — even when the new body's derived identifying content differs, the id does not change. It only raises DuplicateRuleError if the new content would collide with a different rule's existing identity. The endpoint layer composes these mutators with update_rulebook and then calls provider.bump() to trigger cross-worker reload.
Rule Provider — rulebook_provider.py¶
RulebookProvider is the server-side holder of the active RulebookEngine. It solves the cross-worker staleness problem: in a multi-worker uvicorn deployment, PUT /admin/rulebook in worker A previously left workers B, C, D holding stale engines.
class RulebookProvider:
def get(self) -> RulebookEngine
def bump(self) -> int
get() reads the shared sentinel counter (read_version_counter). If the in-memory _version matches, the cached engine is returned immediately. If the sentinel has advanced (another worker wrote), the engine is reloaded from disk and _version is updated.
bump() advances the sentinel (bump_version_counter), signalling all workers to reload on their next get() call.
The sentinel is a dedicated counter file (separate from the overlay cache sentinel), so rulebook reloads and graph cache invalidations remain independent.
The server exposes the provider as RULEBOOK — all rule evaluation in server.py goes through RULEBOOK.get().
Rule Migration¶
Two independent, sequenced startup migrations keep the rulebook file in the one shape the engine reads, both invoked from server.py before RULEBOOK.load() (mutation-layer startup):
rulebook_schema.migrate_rulebook_file(schema shape — see Schema v2 migration) — the v1→v2 envelope upgrade. Runs first.rulebook_migration.migrate_legacy_propagation(rulebook_migration.py) — folds the separate, per-domainpropagation_rules.jsonlegacy store into the shared rulebook'spropertyPropagationarray as v2 envelopes, then renames the legacy file to.migratedso it is never re-applied. Idempotent and cross-process safe. Runs second, and depends on the rulebook already being v2 — the fold writes v2 envelopes directly, which a subsequent v1→v2 pass would misread as flat v1 bodies.
Both are best-effort at startup: a schema-migration failure is logged as an error (with RulebookEngine.load's loud v1-rejection as the real backstop, turning an unmigrated file into a failed/health-gated deploy); a legacy-propagation-fold failure is logged as a warning and does not block startup.
Rule Catalog — rule_catalog.py¶
The rule catalog provides the stable-id scheme and serialization helpers for the admin UI. It requires a v2 rulebook — RuleCatalog.from_rulebook calls rulebook_schema.is_v2 and raises ValueError("rulebook not migrated to v2") on a v1 file, the same no-legacy-runtime-mode guard RulebookEngine.load applies.
_derive_rule_id(category, body) generates <PREFIX>-<sha1(_identifying_content(category, body))[:10]> — a deterministic id from the rule's identifying content only (property / predicate+appliesTo / scope+via+domain+properties, per category — see _identifying_content), using a prefix table keyed by category (PI, RI, PR, CA, DP, DC). This is the same content-addressing principle as derive_entity_uuid in the mutation engine. In v2 this function derives an id only at creation (or to check for a content collision) — it is never used to re-derive an existing rule's identity on read; the stored id in the envelope is authoritative once assigned (§ Rule Schema above).
RuleCatalog.from_rulebook(rulebook, legacy_propagation_rules=None) builds the catalog: for each of the three v2 category arrays it wraps every enveloped rule object into a CatalogRule carrying the envelope's id, enabled, effect, appliesTo (as applies_to), and name straight through — no re-derivation. It also appends any still-unfolded legacy_propagation_rules (provenance legacy-propagation) and the code-implemented documentPropagation builtins (provenance builtin), so the catalog stays the exhaustive single registry (Contract 02.01 §3.6) even for rules that don't live in project.rulebook.json.
RuleCatalog provides:
- rules() — every CatalogRule in the catalog
- get(rule_id) — retrieve a rule by id, raising RuleNotFoundError if absent
- referencing_objects(rule_id) — ids of objects referencing this rule (reverse index; Phase 1+, currently always empty)
- serialize_catalog_for_audit() (module function) — produces the exhaustive JSON-serializable audit summary consumed by GET /admin/rule-catalog and GET /admin/rulebook/for-node (the latter builds its catalog_rules input from this same serialization, then filters/annotates it via rules_for_node — see below). Per rule it returns id, category, area, scope, provenance, legacy_id, reference_count, summary, plus (additive) body, appliesTo, enabled, effect, name — the full shape both the SSOT audit view and the Rulebook editor's rendering (ruleToSentence, ruleDirection, ruleType, ruleExampleModel — see Frontend Integration) need, so neither consumer has to re-fetch or reshape rows from a second endpoint.
Mutation (add/update/delete) is not a RuleCatalog responsibility — it lives in rulebook_store.py's pure mutators (add_movement_rule, update_movement_rule, delete_movement_rule), which operate on the raw rulebook dict and rebuild the catalog to read back the result.
Frontend Integration — rule-sentence.js¶
rule-sentence.js renders human-readable sentences from rule data structures for the admin UI (the Rulebook editor list) and the generic Add Information dialog. ruleToSentence(rule) returns a token model {glyph, kind, enabled, effect, tokens} in a controlled Contract 14 vocabulary — no raw category/scope/predicate jargon is ever emitted.
It reads the v2 envelope, not body. Scope, on/off state, and direction all come from the enveloped fields the admin API serves (appliesTo, enabled, effect), so the list presents exactly what the rulebook holds:
appliesToscope —{kind:"subtree", node}adds an "under <node>" clause (the node'slabelwhen the resolver has supplied one, else its UUID);{kind:"domain", source, target}renders "from <source> to <target>";{kind:"global"}adds no scope clause. Scope is taken from the top-level envelope only — a stray v1body.applies_tois ignored, so a subtree-scoped rule is never misread as global.enabled:falsesetsmodel.enabled = false; the list dims the row and appends a "disabled" badge so a dormant rule never reads as active.effect:"stop"flips the verb ("stops flowing down" rather than "flows down") and setsmodel.effect, making a cancelling rule visibly distinct from an applying one.
The readers default so a bare compose-preview payload ({category, body}, no envelope) still renders as an enabled, applying, global rule. composeToPayload/ruleToCompose round-trip a subtree scope through body.applies_to (the shape the create/update API lifts into appliesTo), so editing a scoped rule does not silently drop it to global; the dropped v1 overridable field is no longer emitted. Node-UUID → human-name resolution and full in-editor scope authoring are not yet built (a subtree scope currently shows the raw UUID).
ruleToSentence also handles a fourth, read-only builtin category, documentPropagation (backend/domain/rule_catalog.py's _BUILTIN_DOCUMENT_PROPAGATION): its body shape is {rule, via, scope: "inter"|"intra", condition?, description} — no propagatedProperties/sourceDomain/targetDomain, because it moves documents themselves along a relation rather than a chosen set of properties. It renders with the same token-span discipline as propagation ("Documents follow the <relation> link" / "…stop following…" for effect:"stop", plus "across domains" or "within the tree" for inter/intra scope) — no raw category vocabulary leaks into the sentence.
Direction, type, and example model. Three further pure helpers classify and visualize a catalog row for the editor's grouped overview and node-focused mode:
ruleDirection(rule)→"vertical"forpropertyInheritance/relationInheritance(the rule flows data down the node hierarchy, ancestor → descendant) or"horizontal"forpropagation/documentPropagation(the rule follows a named relation across nodes, not down an ancestry chain). Unknown categories default to"horizontal".ruleType(rule)→{key, label}naming the rule's category for grouping/sorting:prop-inherit("Property inheritance"),rel-inherit("Relation inheritance"),prop-propagate("Property propagation"),doc-propagate("Document propagation"); unknown categories fall back to{key:"unknown", label:"Rule"}.ruleExampleModel(rule)→ a small{direction, boxes:[{id,title,line}], arrows:[{from,to,label,style}]}diagram model for the per-rule visual example (rendered by the editor as boxes-and-arrows, not prose): a property-inheritance rule shows a Parent/Child pair with a downward "inherits" arrow carrying the same property value on both; a relation-inheritance rule shows Ancestor/Descendant/target-node boxes with the named relation from the ancestor to the target, an "inherits" arrow down to the descendant, and a dashed echo of the same relation from descendant to target; a propagation (or documentPropagation) rule shows two domain-labelled boxes connected by a solid arrow carrying the propagated relation and (for property propagation) a shared property value. This is a rendering aid only — it does not read live graph data; the values shown (Alice,Room 5,Zone 2) are illustrative placeholders, not the node's actual property values.
Rule Book Editor (frontend, app.js)¶
The admin Rule Book editor (opened from the admin menu) is the single UI surface for viewing every rule in the exhaustive catalog. It replaced the older flat down-chevron list and the separate, now-retired Governance tab — that tab's completeness guarantee (every rule visible, nothing hidden) is folded into this editor's overview, which loads from GET /admin/rule-catalog (not the movement-only /admin/rulebook/rules) so builtins like documentPropagation are present alongside user-authored rules.
The editor has two modes, both rendering through the same grouped-list function (_renderRuleGroups) so the structure never reshapes between them — only which rows are shown, and whether each row carries a derivation tag, changes:
- Overview mode (default, and reached via the Clear control) — shows all rules matching the current inheritance/propagation rail filter, grouped direction → type: a "Vertical ↓ — flows down the hierarchy" section (property inheritance, then relation inheritance) followed by a "Horizontal → — follows a relation across" section (property propagation, then document propagation), each type subsection in a fixed order so the layout does not reflow between reloads. This is the exhaustive list — every enabled and disabled rule the catalog holds, including the read-only
documentPropagationbuiltin. - Node-focused mode — a node search field (
slug, deep-link URL, or UUID) triggered on Enter callsGET /admin/rulebook/for-node?ref=; the same direction→type structure is kept, but now shows only the rules returned byrules_for_nodefor that node, each row carrying a derivation tag: "Applies here" (here), "Inherited from<ancestor>" (inherited), "Cancelled here (from<ancestor>)" (cancelled), "Via<source>→<target>" (propagation), or "Global" (global/fallback). A scope heading ("Rules affecting<node label>") replaces "All rules", and a Clear button appears to return to overview mode (also invalidating any still-in-flight search so a slow response cannot clobber a faster Clear). An unresolvable ref surfaces the backend's 404 as a user-terms inline message ("No node found for '<ref>'. Check the slug, URL, or ID and try again.") and falls back to re-rendering the already-loaded overview rather than stranding the panel empty.
Each row can reveal a small visual example in place (a "Example" button, aria-expanded toggled, inserting/removing a sibling .re-example diagram built from ruleExampleModel — never replacing or restructuring the row itself). A row for a rule that is not user-editable (provenance === "builtin", i.e. the documentPropagation builtin) shows a "🔒 read-only" marker instead of Edit/Delete controls, with a title explaining it is defined in code; ordinary movement rules (area === "movement") keep the existing Edit/Delete wiring unchanged.
Integration¶
Read path only. The rulebook engine never writes overlay nodes. It is called by resolver functions in server.py when assembling graph responses — rule outputs are computed views, not stored mutations.
Mutation engine boundary. mutation_engine.py validation functions (validate_add_node, validate_update_node) do not call the rulebook engine — they validate the overlay write only. Rule effects appear post-write on the next read.
Admin API routes (backend/api/admin_rulebook_router.py) round-trip the v2 enveloped shape end to end: GET/POST /admin/rulebook/rules and PUT/DELETE /admin/rulebook/rules/{rule_id} are the uniform per-rule CRUD over all three movement categories (PUT can change body, enabled, effect, name, description while keeping the stored id — see Rule Storage above); POST /admin/rulebook/validate and POST /admin/rulebook/preview are no-write dry-runs (duplicate/tier check, and affected-node preview); GET/PUT /admin/rulebook round-trips the whole rulebook dict; GET /admin/rule-catalog is the exhaustive SSOT audit view. All writes go through rulebook_store.update_rulebook + RULEBOOK.bump().
GET /admin/rulebook/for-node?ref=<slug|url|uuid> (additive, admin-only, read-only — BL-FE Rulebook Editor Plan (2)) answers "which rules govern this node, and why": it resolves ref via key_derivation.resolve_ref against the current ref_index, computes the node's ancestor chain, builds the exhaustive catalog (RuleCatalog.from_rulebook → serialize_catalog_for_audit), and hands it to RulebookEngine.rules_for_node (see Core Operations above) together with the server's own domain.
| Input | ref query param — a slug, deep-link URL, or UUID |
| 400 | ref missing/blank or over 512 chars — "A node reference is required." |
| 404 | ref does not resolve to any node — "No node found for '<ref>'." |
| 200 | {"node": {"uuid", "ref", "label", "domain"}, "rules": [<catalog row + "derivation">, …]} — node.label is the resolved node's name/label, falling back to the raw ref if neither is set; rules is exactly rules_for_node's output (each row is a full catalog row from serialize_catalog_for_audit, plus derivation) |
This is the backend counterpart to the Rule Book editor's node-focused mode (see Rule Book Editor below) — the editor's node-search field calls this endpoint directly.