Server-side observers
See also: Events (the client-side
event_manager) · SQO · search · tm_record · component_info · component_info cookbook R6 · component_portal · System hub
Some component values are not typed by anyone — they are derived. A type
record's Coins portal lists every coin that points at it. A thesaurus term's
Library portal lists every publication that cites it. A component_state
turns a handful of check boxes into one "finished" flag.
Nothing recomputes those values when you edit the record they derive from, because that record is somewhere else entirely. That is what this subsystem is for: when a component saves, every component that declared an interest in it recomputes, server side, after the save commits — whichever door the save came through (an editor panel, a CSV import, an API tool, a duplicate).
This page is the configurator's and integrator's reference for that machinery.
Examples are real nodes on the monedaiberica install (dedalo_mib_v7) and are
labelled with their models; verify tipos against your own ontology before
copying anything.
The model in one breath
An observer declares, in its own ontology node, which components it watches. That declaration alone is the whole registration — the watched component is not asked and does not need to know. On every save the engine looks up the watchers of the saved component, works out which records each of them has to recompute, runs what it computes, and then lets the watchers of those components fire in turn — bounded, and never inside the caller's transaction.
| Module | Owns |
|---|---|
src/core/section/record/observer_subscriptions.ts |
Edge discovery: the ontology-wide subscription registry, host-section resolution, the contract validator. |
src/core/section/record/observers.ts |
Dispatch and the recompute laws: relay, set_dato_external, info recompute, the safety guards. |
src/core/section/record/observer_reconcile.ts + scripts/observer_reconcile.ts |
The operator sweep that heals mirrors left stale by bulk doors. |
Propagation is fired from the component-save chokepoint, so it is not dispatch-only: imports, tools and record duplication propagate identically.
Declaring an edge: observe is enough
An observer declares what it watches in its own properties.observe. Each entry
names one observed component and may carry two independent halves:
client— drives the browser event manager: live UI reactivity inside the open tab.server— drives this subsystem: the recompute on save.
An edge dispatches if — and only if — the observer's
observeentry carries aserverobject. The observed component does not declare anything.
This is ordinary observer semantics: the subscriber registers itself. The
canonical single-declaration case, live on this install, is oh28
(component_state, section oh1 — Oral History), which recomputes whenever
rsc19 (component_state) saves. rsc19 says nothing about oh28. From
oh28's properties.observe:
{
"server": {
"filter": {
"$and": [
{
"q": null,
"path": [
{
"name": "digitization",
"model": "component_portal",
"section_tipo": "oh1",
"component_tipo": "oh25"
}
],
"q_operator": null
}
]
}
},
"component_tipo": "rsc19"
}
On every rsc19 save the engine searches section oh1 for the interviews that
reference the saved record through the oh25 portal (Audiovisual), and
recomputes oh28 on each of them.
An entry with no server key is a client-only observer: the browser
subscribes locally and the server does nothing. Both kinds are common — measured
on this install, 60 of the 136 observe entries are client-only, and 39 of the
67 nodes that carry observe declare at least one server half. A server
value that is present but is not an object ("server": null, a string, a
number) is malformed authoring — it never dispatches, and it is reported as a
contract violation.
First match wins
When an observer declares several entries, the first entry in array
order whose component_tipo equals the observed tipo (or is "all") is the
one that dispatches. A client-only entry placed before a server entry for
the same tipo shadows it.
The legacy forward observers array
An edge used to require a second, mirror declaration: a
properties.observers spec ([{section_tipo, component_tipo}]) on the
observed component, naming its watcher. Half-declared edges were therefore
silent dead configuration, and dropping that requirement is the point of the
2026-08-02 rebuild. Measured on this install: 73 dispatchable edges, of which
9 had never dispatched at all despite a perfectly correct observe
declaration. Six of those nine do real server-side work; the remaining three
resolve to the terminal no-op described further down, so they now dispatch but
still compute nothing.
The forward array survives for exactly two jobs:
- Scoping the
"all"wildcard. Anobserveentry withcomponent_tipo: "all"is a matching rule with no intrinsic scope — read literally it would mean "every save in the system". The forward specs naming the observer are the only thing that bounds it: the wildcard compiles to exactly that edge set, and a wildcard nobody forward-declares admits zero edges. Live wildcard observers here:numisdata250,numisdata257andtch557(allcomponent_portal, all in sectiontchi1), compiled from one forward spec each —numisdata282,numisdata1451andtch555. -
Host targeting for a reused component. A component declared once can be reused across several sections, and something has to say whose records the edge covers. Live case:
rsc387(component_autocomplete_hi— Descriptors) carries three forward specs naming the same observerhierarchy93, withsection_tipoon1,ts1anddc1— three virtual faces of sectionhierarchy20(Thesaurus), wherehierarchy93itself is defined.On that edge the scope is not what the save-time recompute searches — it is a
set_dato_externaledge, and those take their targets from the saved data (see the next section). What the scope buys there is the operator sweep: it is the set of sectionsscripts/observer_reconcile.tswalks when it rebuilds the mirror offline. A scope that drives dispatch directly is one on an SQO-filter edge.
A forward spec with no observe half is dead configuration
A properties.observers spec whose named observer carries no matching
observe entry dispatches nothing, on either side. The engine reports every
such edge at every boot and counts it
(observers_registry_contract_violations). Four exist on this install —
rsc1139, rsc1140, rsc1401 and rsc1403, each naming rsc19 — and
they had been dead for this install's entire life without a single symptom.
Fix the ontology: add the observe half on the observer, or delete the
stale spec.
Discovery: the subscription registry
Edges are discovered by a subscription registry built over the whole
ontology — every node carrying observe or observers — not by reading the
saved node alone. The registry is cached process-wide and invalidated
automatically after every ontology write (deferred to the write's commit), and
it is warmed at boot, so each deploy or restart re-validates the real ontology
and prints every violation it finds. Its state is also exposed operationally:
the observers_registry gauge and the observers_* counters on
GET /api/v1/counters.
Out-of-band ontology surgery needs a restart
Invalidation is wired to ontology writes through the engine. If you edit
dd_ontology directly in the database, restart the server or the registry
keeps serving the pre-edit picture.
What runs: targets × performs
Two orthogonal questions decide what a save does: which records an observer recomputes on, and what is computed on each.
Which records (target resolution)
| Targets | Declared as |
|---|---|
| The saved record | server.filter: false, no perform (only an info-model observer recomputes on it; anything else is a no-op — see the perform table) |
| Everything the just-saved data points at | server.config.use_observable_dato: true |
| Both of the above | use_observable_dato and use_self_section true |
| Every record referencing the saved one | server.filter is an SQO object: a search over the host section in which every clause's q becomes the saved record's locator, with from_component_tipo taken from the first clause's last path step |
The observable-data targets are the locators in the payload the save door just handed over, not a re-read of the record — they are never found by searching a section. Only the SQO-filter shape needs a host section.
A save also visits what it stopped pointing at
Every save door reports two things: the value the component now holds, and the locators the save removed. Both are targets. This matters because a record you just unlinked still lists you in its mirror, and visiting it is the only way that entry can go away at the moment you caused it — the recompute reads current truth, so a record that turns out to be unaffected simply computes the same value and writes nothing.
Removed locators participate in the external-mirror and relay branches. They are deliberately not used by component_info observers: those select their targets by searching for the saved record, so a removed locator is not an input to that search at all.
An equivalence edge recomputes the whole class
When the saved component is one of the observer's own equivalents peers
(named in source.data_from_field), the save does not change one link — it
re-partitions an equivalence group, and multidirectional equivalence is
transitive. If A ≡ B and B ≡ C, then A ≡ C follows; break the B–C link and A's
group changes too, even though the save never mentions A.
So for these edges the targets are the whole equivalence group, computed before and after: the group the saved record is in now, plus the group of each record it was just unlinked from. This is needed in both directions — merging two groups reaches the far members exactly as splitting one does. A two-member group hides the distinction, which is why the gap went unnoticed: with only A and B there is no third member to miss.
Edges without a data_from_field peer are unaffected and pay nothing extra.
On a set_dato_external edge, server.filter is ignored
An external-mirror edge takes its targets from the observable data whatever
the filter says. Only a
component_info observer (or a
component_state / component_calculation alias) uses the filter to
select targets. For every other observer the filter only picks a branch:
false is the terminal no-op, absent-plus-use_observable_dato is the
relay. One playground edge declares a filter and set_dato_external —
test199 (component_portal, section test183) observing test188, a
box elements node — and the filter has no effect there. Read it as a
demonstration of the rule, not as authoring to copy.
Host-section resolution
The host section is "whose records do I search / sweep". It is resolved in this order:
- the
observeentry's ownsection_tipo— the authoring home for new edges. No shipped entry uses it yet; it is the fix when step 3 fails; - a forward spec's
section_tipo— the reused-component targeting described above. The literal"self"names no foreign scope and falls through to step 3, which is what "self" means; - the observer's own section (its ontology ancestor), refined by the SQO filter path — see the virtual-section rule below;
- unresolved → the recompute is refused loudly, naming the edge, and
counted as
observers_host_section_unresolved. Nothing reaches this on the current ontology.
Step 3 treats virtual and real sections as equivalent. A
virtual section names its real section in its first
relation, and stored records carry the virtual tipo — so when the filter path
names a virtual face of the observer's own real section, the path's face
wins. Live example: numisdata1478 and numisdata1479 (both
component_info — Composition by period / Composition by Era) observe
numisdata1373 (component_autocomplete_hi — Period) through a filter path
naming section numisdata5 (Complex), while their own ontology section is
numisdata276 (Location). numisdata5 is a virtual face of numisdata276,
so the two agree and the search runs against numisdata5, where the records
actually live. A path naming a non-equivalent section resolves to nothing —
the engine refuses rather than silently retargeting.
What is computed (the perform)
| Perform | Declared as | Effect |
|---|---|---|
| Info recompute | observer is a component_info (or a component_state / component_calculation alias) with no perform |
Recomputes the widgets on each target and writes one matrix_time_machine row per target (lang lg-nolan). It deliberately never touches the live stored value — live reads compute. Targets equal to the saved record additionally ride the save response, so the open editing panel refreshes. |
set_dato_external |
perform: {function: "set_dato_external"} and config.use_observable_dato: true |
Recomputes the observer's external mirror on each target — the value law below. |
| Relay | no perform, no filter, config.use_observable_dato: true, observer is not an info model |
A pure trigger. It writes nothing at all: no value change, no Time Machine row, no modified stamps. It exists only to re-enter propagation so a dependency chain can continue. |
| Terminal no-op | filter: false, no perform, observer is not an info model |
Nothing runs server-side; the client half is the whole point of the entry. |
Measured coverage of the 73 dispatchable edges on this install:
| Edges | Shape |
|---|---|
| 33 | info / state / calculation recompute on the saved record (filter: false) |
| 20 | set_dato_external external-mirror recompute |
| 7 | info recompute through an SQO filter |
| 3 | terminal no-op (filter: false on a non-info observer) |
| 2 | relay |
| 8 | declared but not covered — see below |
Eight declared edges are skipped loudly, not run
The engine never guesses an unimplemented shape: it logs
server shape not covered and does nothing. Seven of the eight declare
perform: set_dato_external without config.use_observable_dato, which is
the key that names the targets — so the entry has nothing to recompute on:
- six carry no
configblock at all —numisdata563,numisdata574,numisdata575,numisdata993,numisdata1227andnumisdata1229, allcomponent_autocomplete_hi; - one carries a
configblock that sets only the inertuse_inverse_relations:numisdata965(component_portal) observingnumisdata11(component_portal— Ordered coins).
The eighth is different: rsc1214 (component_select) observing rsc1156
declares a perfectly well-formed target config and
perform: {"function": "refresh_data"}. That perform is not
implemented — a real, dated gap, stated rather than approximated.
None of the eight is fixable by adding use_observable_dato alone
Adding "config": {"use_observable_dato": true} is necessary for those
seven, but on none of these nodes is it sufficient — the edge stays dead
and simply fails one step later:
- the six
component_autocomplete_hinodes havesource.mode: "external"and asource.section_to_search, but nosource.component_to_search— without it the value law is undefined, and the recompute skips withobservers_component_to_search_missing; numisdata965carriessource.source_overwrite, so it is refused wholesale by the unrecognized-derivation-rule guard below, before any compute;rsc1214already has the target config; what it needs is therefresh_dataperform, which is code, not configuration.
Wire the missing source keys first, and treat the source_overwrite case
as blocked until that rule is ported.
use_inverse_relations is INERT
One live entry declares config: {use_inverse_relations: true} — the
numisdata965 ← numisdata11 edge above. Nothing reads that key, so it
resolves no targets and the edge falls into the not-covered skip. Do not
copy it expecting an inverse-reference resolver. (numisdata965's other
edge, ← numisdata656, is a covered set_dato_external shape; that one
reaches the recompute and is refused there for source_overwrite.)
The value law of set_dato_external
An external mirror is a relation component — a component_portal (or one of
its component_autocomplete aliases) with properties.source.mode: "external" —
whose stored value is derived: the list of every record that references its
host record. set_dato_external is the perform that recomputes it:
The observer's value becomes every record referencing the target record — or any equivalent of the target — through
source.component_to_search, limited tosource.section_to_search. Existing entries keep their stored order; new references are appended with the next item id. The search is uncapped and ordered bysection_tipo,section_id.
The running example is numisdata77 (component_portal — Coins, in section
numisdata3, Type): "which coins are of this type". Its
properties.source, display keys omitted:
{
"mode": "external",
"data_from_field": ["numisdata36"],
"section_to_search": ["numisdata4"],
"component_to_search": ["numisdata161"]
}
numisdata161 (component_autocomplete — Type) is the coin's link to a
type, in section numisdata4 (Numismatic object). The mirror is the inverse of
that link, and it is stored as ordinary relation data — relation is singular
and keyed by component tipo, one locator per referencing record. Type record 16
on this install:
{
"relation": {
"numisdata77": [
{
"id": 1,
"type": "dd151",
"section_id": 356,
"section_tipo": "numisdata4",
"from_component_tipo": "numisdata77"
},
{
"id": 2,
"type": "dd151",
"section_id": 61153,
"section_tipo": "numisdata4",
"from_component_tipo": "numisdata77"
}
]
}
}
A mirror does not have to have equivalents. hierarchy93 (component_autocomplete
— Library, in section hierarchy20) declares only section_to_search
rsc205 and component_to_search rsc387: it lists the publications citing
the term, and nothing more.
Equivalents — source.data_from_field
The inverse-reference search is seeded with more than the target record. For
each component tipo listed in source.data_from_field, the target's value in
that peer component — plus that peer's relation-type closure — joins the seed,
and every seed entry is re-stamped with from_component_tipo =
component_to_search so it matches the referencing component's locators.
Semantically:
The mirror lists the records referencing this record or any of its equivalents.
A closure is computed only when the peer is a
component_relation_related. A
peer of any other relation model contributes its stored bag alone, whatever its
relation_type_rel says — that is a model-level rule, not a bug, and a peer of
the wrong model is the quiet way to end up with a short mirror. For a related
peer, how far the closure reaches depends on its
config_relation.relation_type_rel:
relation_type_rel |
Closure |
|---|---|
dd620 (unidirectional) |
none — the stored bag only |
dd467 (bidirectional) |
one inverse hop |
dd621 (multidirectional) |
the full symmetric transitive closure, in both recursion directions |
numisdata77's peer is numisdata36
(component_relation_related —
Equivalent terms) with relation_type_rel: dd621. So a coin that references
any type in an equivalence group appears in the mirror of every type in that
group.
The closure is the law — measured
Recomputing all 19,908 numisdata3 records that hold a numisdata77 mirror
and comparing against the stored, migrated truth: seeding with the target
alone loses 318,122 locators; adding the peer's stored bag (one hop)
still loses 247,933; the full dd621 transitive closure is exact —
19,885 of 19,908 records byte-identical, 13 locators residual. A one-hop
implementation is not an approximation, it is wrong by a quarter of a
million locators.
A declared peer whose ontology node is missing (a partial install) degrades the
seed to the stored bag — loudly, and counted as
observers_seed_peer_node_missing, never silently.
references_limit is never honoured
perform.params.references_limit is inert on the write path — deliberately
Shipped configs declare it, mostly as 0 (the "no limit" sentinel), a few as
200. Honouring a finite cap on a write path would persist a truncated
mirror: a capped result set is indistinguishable from "these records stopped
referencing you", and the merge removes by omission. Measured: numisdata250
on record 162 of section tchi1 stores 1,023 locators against its own
declared limit of 200 — honouring the cap would destroy 823 of them at the
next save on that record. The recompute always searches
uncapped, and a finite non-zero limit is refused outright and counted as
observers_references_limit_refused, never applied. The divergence is
recorded in the wire-contract ledger under engineering/wire_contract/.
The 2,000-reference freeze
A recompute whose search returns more than 2,000 referencing records computes
the diff for honest reporting but refuses to persist it, logged and counted
as observers_big_result_refused — a retained safeguard against very expensive
saves. It is reachable here: the widest real case holds 4,547 referencers.
Safety laws
All four are unconditional. None of them can be switched off.
Removals are mirrored — and the one case where they are not
The recompute writes the full law: entries that no longer match are dropped, new ones are appended. Removing a link updates every affected mirror on the spot, the same way adding one does. The decision is membership-based, not length-based, so a recompute that wants to drop one entry and add another commits both.
There is no setting for this. Nothing a caller passes can grant or deny a drop, which is deliberate: a switch that can mean "allow the deletion" is always one mistake away from meaning it by accident.
Exactly one situation withholds the drop half — a degraded seed. If this
record's own equivalents could not be resolved (a peer named in
data_from_field has no ontology node, an entry there is not a tipo, or a
peer locator is malformed), the computed reference set is known to be
incomplete, so removing anything on the strength of it would be a guess. The
additions still apply; the drops are held, logged, and counted as
observers_shrink_refused_degraded_seed, and the outcome names the defect so
you know which ontology node to fix. Fix it, then re-run the reconcile script.
This replaced a blanket rule
Between 2026-08-02 and 2026-08-06 the engine dropped nothing, ever, without an operator flag — a temporary guard from when the equivalents law was still incomplete and every drop was suspect. Once that law landed the guard became the problem: a mirror that can only grow is not a mirror, and a legitimate removal stayed visible in the public portal indefinitely. Retiring it corrected 1,673 stale references across 22 records on this install.
Unrecognized derivation rules are refused
A node whose properties.source carries source_overwrite or
set_observed_data follows a derivation rule this engine has not
implemented. Running the default law on such a node is provably wrong — up to
computing an empty mirror against a large stored one — so the recompute refuses
these nodes before any write, counted as
observers_unported_sublaw_refused.
This refusal is load-bearing
Live instances on this install: numisdata679 and numisdata965 (both
component_portal, in section numisdata651 — Catalog), which carry
source_overwrite. Dry-run against real records measured stored mirrors of
1,077, 959 and 766 entries all recomputing to zero under the default
law. Between them the two nodes hold 131,806 locators across 4,688
records, and the refusal is what stands between those and a full wipe. Do
not "unblock" a node by deleting the key; port the rule.
The bounded cascade
An observer the engine has just acted on is, from the graph's point of view,
itself a save — so its observers fire in turn, and a dependency chain
continues. Re-entry is bounded by a visited set shared across the whole
cascade (one dispatch per observer, per hop kind, per record, per propagation)
and a depth budget of 8 hops. A true cycle or a budget overrun is a loud,
counted refusal naming the full chain (observers_cascade_cycle_refused,
observers_cascade_depth_exceeded) — never a silent truncation, never a hang.
The same node reached along two different branches is a benign diamond,
deduplicated and counted as observers_cascade_converged_skipped. The real
graph on this install is depth 2 with zero cycles; the budget is a backstop.
Which branches emit a hop is not uniform, and the asymmetry is deliberate:
- a
set_dato_externalrecompute emits one only when it actually persisted something — a refusal, a withheld shrink or a no-drift result ends the branch; - a relay always emits one. Emitting the hop is the relay's entire purpose; it writes nothing else;
- an info recompute always emits one, per target.
A hop into an observer that has no subscriptions of its own is a leaf and is dropped before it is scheduled, so it never appears in the guard or the logs.
Cascade hops run after the enclosing save commits. Inside a transaction — a CSV import row, for instance — the hop is queued on a commit-only lane that fires on commit and is discarded on rollback, so a rolled-back save never propagates.
There is no configuration switch
The cascade is unconditional: the ontology decides, not the deployment. Mirrors are stored data, so a deployment flag would let two installs with the same ontology store different values. (A rollout flag existed briefly during the rebuild and was removed the day the benchmark cleared it.)
Measured cost: one external-mirror hop p50 1.3 ms, p90 3.1 ms; the widest records — mirrors up to 1,189 entries — p50 10.6 ms; the worst real case, 4,547 referencers, 22 ms. Cost per entry falls as fan-out grows (61.8 ms down to 5.2 ms per 100 entries), so the recompute is sub-linear in practice.
A real chain, end to end
The intent is documented in the ontology itself — numisdata161's own forward
spec narrates it. Two hops, the deepest chain on this install:
numisdata161(component_autocomplete— a coin's Type link, in sectionnumisdata4) saves.-
numisdata36(component_relation_related— Equivalent terms, in sectionnumisdata3) observes it as a relay: noperform, targets taken from the saved locators.{ "info": "Numismatic object update his type data. It's fired to save his data by numisdata161 because it's necesary update all equivalent types", "server": { "config": { "use_self_section": false, "use_observable_dato": true } }, "component_tipo": "numisdata161" }The relay writes nothing. It exists so that step 3 fires for every equivalent type, not only the one the coin now points at.
-
numisdata77(the Coins mirror above) observesnumisdata36withperform: set_dato_externalanduse_self_section: true— so each type in the equivalence group recomputes its own mirror.
The same three-stage shape exists for objects: tch241 → tch40
(component_relation_related) → tch33 (component_portal).
Why didn't my observer fire?
Work the list top-down.
- Is the edge declared where the engine looks? The observer's
properties.observemust contain an entry whosecomponent_tipois the observed tipo (or"all"plus a forward spec naming the observer), and that entry must carry aserverobject.server: null, a scalar, or a client-only entry never dispatches. Remember first-match: an earlier entry for the same tipo shadows a later one. - Read the boot log. Every restart validates the whole ontology and prints
one
observer subscription contract violation: …line per problem — forward-only dead specs, dead wildcards, unresolved host sections, malformedservervalues — each naming the exact edge and the fix. - Is the shape covered? A
serverblock whoseperformor target config the engine does not implement is skipped with aserver shape not coveredlog. The eight live cases are enumerated above; the usual cause is aset_dato_externalperform with noconfig.use_observable_dato. - Check the counters.
GET /api/v1/counterscarries theobservers_registrygauge and the refusal counters. Configuration problems:observers_registry_contract_violations,observers_host_section_unresolved,observers_component_to_search_missing,observers_unported_sublaw_refused. Runtime refusals:observers_shrink_refused_degraded_seed,observers_big_result_refused,observers_references_limit_refused, and theobservers_cascade_*family. Volume signals:observers_removed_targets_visited,observers_equivalence_class_wide. - Mirror short by thousands? Look at the
data_from_fieldpeer: it must be acomponent_relation_related(no other model computes a closure at all), itsrelation_type_relmust bedd621for the full closure, and its ontology node must exist — a missing one degrades the seed to the stored bag and countsobservers_seed_peer_node_missing. - Edited the ontology out-of-band? Restart the server; the registry only invalidates on writes made through the engine.
- A removal did not propagate? It should have. Check whether the outcome
was a degraded-seed refusal (
observers_shrink_refused_degraded_seed— the log names the peer node to fix), and if the stale entry is on a record the save never mentioned, confirm the observer really declares the observed component insource.data_from_field— that is what makes a save an equivalence-group event rather than a single-link one. - Auditing drift across the whole install?
scripts/observer_reconcile.tsis a census as well as a repair.--jsonemits one record per drifted mirror (before,after,dropped,added, plus any refusal and its defects) so drop volume can be reviewed by cause;--budgetcompares the run againstengineering/observer_shrink_budget.jsonand exits non-zero if the install wants to delete more than the declared ceiling. Always run the census before--apply.