Skip to content

Metrics Engine

Verified against the code · 9e119ef0950aa1f8c8f87a640132addf0109872c · 2026-10-20

956 D1b-3 (2026-10-20): 1 finding(s) fixed — GET /activity/heatmap success responses carry no errors key; the audit-unavailable branch adds errors, degraded: true and changesOnRemovedNodes null (server.py:4433-4442). Re-verified against main 9e119ef0.

Layer: backend

Status — Wave 1 (2026-09-15, #625): every backend read-endpoint that used to hand-roll its own counts now reads from the catalog through metrics.engine. /info, /admin/model-stats, /admin/export/full-model/info, /activity/heatmap and /metrics/batch all delegate to the engine; the Wave 0 shim backend/domain/node_metrics.py is gone, and the three named _get_all_descendants walkers plus the tombstone disk-glob and heatmap audit fold are retired. Two AST/behavior ratchets in tests/test_metrics_ratchets.py defend the invariant.

Wave 1 semantic changes: - Delete-preview descendantCount is now exclusive of the anchor (matches the catalog's descendants metric and the detail-panel semantics — the frontend one-liner that used to subtract 1 was retired in the same commit). - relations_by_predicate now maps each predicate to explicit relations only (was blended with inherited expansions). Use relations_inherited for expansion counts. - active_users (window-aware) added alongside the existing active_users_30d (hard-wired 30-day window). The /activity/heatmap endpoint uses active_users. - GET /metrics/batch uses an explicit opt-in allowlist (_BATCH_SAFE in backend/server.py) — new node-scope metrics are excluded by default until IAM-reviewed and explicitly added. See the dev reference for the security invariant. - /info.relations is unchanged from pre-W1: explicit-only count (backward compat). /info.relations_total is the W1 additive field (explicit + inherited). The server-info popup's "Assoc. edges" figure renders relations (explicit), matching its pre-W1 meaning. Consumers include the admin cross-domain aggregation and eidosdash. - /admin/export/full-model/info now includes a top-level relations field (Σ explicit across domains, matches view-mode download content) alongside relations_total (Σ explicit + inherited, matches archive-mode content). The export dialog renders both: "N relations (M incl. inherited)".

Detail panel and tree chips move onto the engine in Wave 3; EIDOS Dash in Wave 4.

Wave 3 / 4.2a (#665, tree chips): there is now ONE path to a tree-chip refresh — a mutation dispatches EidosCore.dispatchMutation({kinds:['property-value'…]}) and the tree-chips reconciler drives the refresh; the old direct EidosCore.refreshTreeChips() poke is retired (ratchet: tests/test_frontend_chip_refresh_single_path.py). At startup the chip registry (TREE_CHIP_DEFS) is validated against GET /metrics/catalog (through the canonical EidosApi layer): every chip key must be a registered catalog metric id, or it is hidden (unselectable) and, on a dev host, logged — a chip can never reference a metric that no longer exists. The one exception is my notes, which is a per-user count served by a dedicated batch path, not a shared model metric, and is marked userScoped so the catalog check exempts it. Chips never count anything themselves — the catalog is the single source of truth.

Wave 3 / 4.2b (#665, detail-panel headers): three of the four zone headers (What's inside / Specifications / Documents) now take their count from the metrics catalog (subitems / specifications / attached_documents) instead of counting rows themselves. #700 (connectivity-scoped metric, resolved): ConnectionsZone is now wired through the catalog like the other zone headers (#665). The panel is an isConnectedTo allowlist (_relConnection(), a getter that reads window.EidosCore.connectivityPredicates, populated by app.js from the catalog's connectivity.predicates field; there is no in-panel literal fallback — before the catalog arrives the getter returns an empty Set, so ConnectionsZone shows "Nothing connected yet" until _validateTreeChipDefs() runs). The zone header count now reads the catalog's connectivity metric (via the shared /metrics/batch page cache + _headerCount), and the tree chip maps to the same metric. Because chip, header and panel body all derive from the same CONNECTIVITY_PREDICATES set, they agree on every node — on DEV they used to coincide only because isConnectedTo is the sole internal predicate. Dedupe: connections dedupes outgoing by (predicate, target) per its catalog definition; connectivity and the ConnectionsZone panel body both count rows directly (no dedupe), so they stay aligned. The single authoritative definition of the allowlist is CONNECTIVITY_PREDICATES = frozenset({'isConnectedTo'}) in backend/metrics/catalog_node.py; the catalog exposes it via connectivity.predicates; no second literal lives in the frontend. If a user's saved tree chip pick was the old connections id, _loadChipsForCurrentTree() migrates it to connectivity on first read (one-shot, persisted). The older connections metric is unchanged (still in the catalog for other consumers). Ratchet: tests/test_connectivity_chip_panel_parity.py. DocumentsZone wiring (#665) is complete: the zone now reads attached_documents via _headerCount through the shared page-lifetime /metrics/batch cache. The mismatched documents catalog metric (which counts doc-URL engineering properties, not the attached document nodes the zone shows — 19/21 DEV doc-bearing nodes get their docs via rulebook channels, not direct hasDocument) is NOT used. attached_documents was added in #698 and gives the IAM-correct per-caller count (option a: per-caller filtered count, identical to what GET /node/{uid}/documents returns — a cheap unfiltered len(merged) is forbidden because it would leak restricted-doc existence, same reason descendants is kept out of _BATCH_SAFE). NodeDetailPanel fetches subitems / specifications / attached_documents / connectivity ONCE per node view through a page-lifetime /metrics/batch cache shared with the tree chips (EidosCore.getNodeMetrics, invalidated by the tree-chips reconciler on every mutation), so at most one request per node view. Each header runs through _headerCount(metricValue, rowCount, zone): it shows the engine value; if that disagrees with the local row count it still shows the engine value and warns on a dev host (a disagreement means panel and catalog count differently — surfaced, not hidden); and if the metric is absent (partial response) it falls back to the local row count rather than a false 0. Ratchet: tests/test_frontend_panel_headers_from_catalog.py. Sanctioned local exceptions: (1) the BL-FE-095 heatmap keeps its LOCAL _computeDescendantCounts — descendants is deliberately held out of _BATCH_SAFE for IAM reasons, and the local walk over the tree the user actually sees is IAM-correct by construction (owner-decided deviation from spec §6, because the engine cannot give an IAM-correct number cheaply). ConnectionsZone no longer counts rows for its header: it reads connectivity through the same page-lifetime cache (#700 resolved). DocumentsZone reads attached_documents (#665 resolved). #645: /admin/model-stats now exposes two additive fields — relations_all_domains and tree_edges_all_domains (the cross-domain sums the aggregation loop already accumulates; not a re-count). The "Edges total" line renders edges_total (relations_all_domains assoc. + tree_edges_all_domains hier. + relations_inherited inherited), e.g. 17435 (10495 assoc. + 6940 hier.). The inherited summand sits inside the parenthesis and is omitted when 0. Inherited expansions are part of edges_total (unchanged); whether derived expansions should count as edges at all is an open owner question (#695).

#696/#697: relations_inherited and relations_by_predicate are now all-domain on /admin/model-stats. The route no longer carries its own fan-out: it delegates to the ONE canonical cross-domain collector metrics.collection.collect_model (02.01 §3.6), which hits each secondary's /metrics/model?domain=self and merges contributions by each metric's declared rule (sum for inherited, dict_sum for predicates). The merge rule and the partial state have exactly one owner; /info no longer carries these fields. The three terms of edges_total are at the same all-domain scope — edges_total = relations_all_domains + tree_edges_all_domains + relations_inherited — and relations_explicit, relations_inherited and relations_total share that consistent scope so relations_total = relations_explicit + relations_inherited holds in the response. Regression: tests/test_admin_misc_router_wiring.py::test_scope_agreement_inherited_all_domain, revert-red per Contract 04 §3.8 R49.

Partial scope — one flag per field. /admin/model-stats emits relations_inherited_scope AND relations_by_predicate_scope separately, so a secondary whose engine fails on one metric but not the other flags only the affected line.

Named per-domain state. Every per-domain row carries a state field — one of ok, errored, older, unreachable — so the UI can distinguish an older secondary (reachable but missing a wanted metric id in its 200) from one whose own engine self-reports an upstream fault for that metric (errored) from a failing one (5xx / timeout / garbage body / invalid JSON — unreachable). The states, derived from the HTTP response alone:

  • ok — status 200, body is a well-formed dict, every wanted id is present with a typed value. The answer enters the totals.
  • errored — status 200 with one or more wanted ids missing from metrics AND every missing id explained in the body's errors dict. The secondary IS current (not an older build), it just has an upstream fault for that metric. Values are atomically dropped and the per-metric error message is preserved in the merged response's errors.
  • older — status 404 or 400, OR status 200 with one or more wanted ids missing AND at least one missing id NOT in errors. The secondary genuinely doesn't know what we asked for (an older build).
  • unreachable — anything else: 5xx, timeout, network error, non-dict body, non-numeric scalar, dict payload that isn't str → number.

All three non-ok states drop out of the totals and flip the top-level scope flags to partial; the row renders differently so a user can tell "upstream fault" (errored) from "deploy the newer secondary" (older) from "the secondary is down" (unreachable). The states are also emitted on the /metrics/model response as domain_states (a declared field on contract.model_response, so the archive and the HTTP response share one shape) — domain_states: {<domain>: "ok"|"errored"|"older"|"unreachable", ...}.

Atomic + typed body validation. The collector strictly validates every value in a 200 answer before taking it, by each metric's declared shape: a scalar must be a number, a unit="dict" payload (e.g. relations_by_predicate) must be str → number, and a unit="id_count" max payload (max_container, max_relations_node) must be {id: str, iri: str|null, count: number}. A null, string, list, or any other malformed dict value marks the whole domain unreachable, so an earlier-parsed count can never leak into the all-domain sum AND /admin/model-stats and /metrics/model?domain=all cannot 500 on a malformed secondary response. A 200 answer missing one or more wanted ids marks the domain errored (every missing id self-explained) or older (at least one unexplained) — a missing id makes the whole domain partial, not just that one metric. Non-200 responses are logged with status AND body (truncated) so a failure is diagnosable.

Atomic drop on wide-request paths. errored, older and unreachable all drop the whole secondary's values atomically — including on wide-request paths like /metrics/model?domain=all and the FME archive snapshot. On an errored secondary the numbers it did validate (e.g. five of six wanted ids) are therefore NOT folded into the all-domain sum: they appear as null in metrics.*.domains.<name>, the response is partial: true, and the row's domain_states[name] is "errored". The upstream error message is still preserved in the merged response's errors dict, so the operator sees the real signal and can tell errored from the other two kinds of partial. The reason for atomic drop (rather than dropping only the explained ids) is spec §3.6 — "validate the whole answer, then take it, or take none of it" — and so that relations_total = relations_explicit + relations_inherited holds unconditionally in the response.

Why an engine

Before Wave 0, metrics were computed per feature, on the fly, with divergent definitions: connections meant one thing in the tree chip and another in the detail panel; "changes" had two audit folds (chip vs heatmap); sub-items, descendants and parts were four counters over the same hierarchy; and EIDOS Dash re-implemented ~25 numbers from the export archive. Users could see numbers that disagreed, and nobody could look up what a number meant.

The Metrics Engine makes the definition the unit of ownership. A metric exists only if it is in the catalog; the engine computes only what the catalog declares; this page's table is generated from the catalog at build time.

Facts, not scores

The engine owns facts: counts and ratios of structural things that can be verified against disk. Weighted or composite scores (completeness, readiness, health, maturity) are dashboard policy and stay in EIDOS Dash — but must be defined purely over catalog ids. Instance-administration figures (users, snapshots, tier) are not metrics and remain in /admin/model-stats.

Scope, source, merge

  • scope node — computed for a list of node ids (compute_node); model — computed for one domain and merged across domains.
  • source — the only thing a compute function may read: graph (effective graph via the loader), audit (committed events via audit_service, read at most once per window — contract 13), iis (iis.storage). No direct disk or SQL (contract 01.01).
  • merge — how per-domain values become the model value: sum, max, mean (weighted by the declared inputs), dict_sum, or none (primary only). An unreachable secondary yields domains.<name>: null and partial: true; a merged number is never presented as complete when a contribution is missing.

Freshness

Model-scope results are memoised per (graph_version, date, days, ids). graph_version is the mutation store's cross-worker sentinel counter — every commit bumps it, so the cache dies by itself. There is no separate invalidation mechanism to keep in sync.

IIS-sourced metrics (interfaces_*) are the one exception: the IIS store has its own write path outside the graph sentinel, so they are computed fresh on every request rather than served from the memo cache. The same holds for any metric the catalog marks cache_exempt — today document_coverage, whose value also depends on rulebook edits and on other domains, neither of which bumps the sentinel.

Document coverage

document_coverage (#1142, W4 E1) is the share of product nodes (productType not null) with at least one document reached through the same hasDocument channel resolution the node's Documents zone uses: direct, then hierarchy, then relation (the #508 documentPropagation rules). Each product's effective relations come from the canonical engine and are routed by documents_engine.route_document_rows, the single router shared with the per-node resolver — there is no second resolution.

  • No per-node HTTP. The relation channel's documents live on a target in another domain. Before the synchronous engine runs, the collector resolves each distinct target once (bounded concurrency, the client's FAST timeout) and hands the answers to the engine; ten products of one type cost one fetch.
  • Admin scope. It is a model metric, served only under the model-metrics gate (admin_or_service) and not filtered by the caller's permissions — unlike the per-caller node metric attached_documents.
  • Never a fake number. A domain without product nodes reports null (never 0), and contributes nothing to the merged share. If a relation-channel target could not be resolved for a product that no other channel covers, the value carries partial: true instead of silently reporting a lower share.

Response shape

GET /metrics/model returns {scope, graph_version, computed_at, days, partial, metrics: {id: {value, unit, inputs?, findings?, partial?, domains}}, errors, domain_states}. inputs carries denominators so a consumer can show the arithmetic; findings (capped at 200) lists the offending ids for quality facts; a per-metric partial (only on metrics that report it, e.g. document_coverage) marks a value that could not be fully computed even though every domain answered. domain_states is a declared {<domain>: "ok"|"errored"|"older"|"unreachable", ...} dict (one entry per fan-out target) so a client can tell apart the three kinds of partial. Formula text is in the catalog, not in the response.

GET /metrics/catalog returns {metrics: [...]} — an object with a single metrics array (not a bare array). Each entry shape: {id, scope, label, definition, unit, source, merge, inputs: string[], predicates?: string[]}. The optional predicates key is present and non-empty only for connectivity-scoped metrics (currently only connectivity, with predicates: ["isConnectedTo"]); all other entries omit the key entirely (the serialiser in backend/metrics/catalog.dump() conditionally emits it only when the registered metric declares predicates). Frontend consumers must treat m.predicates as undefined for non-connectivity metrics, not [].

GET /activity/heatmap returns (W1, #625):

{
  "window":                {"from": "<ISO date>", "to": "<ISO date>"},
  "totalChanges":          42,
  "changesOnRemovedNodes": 5,
  "activeUsers":           3,
  "nodes":                 [...]
}

A success response carries no errors key at all. When the audit source is unavailable the endpoint returns the header envelope with errors populated, a degraded flag, and changesOnRemovedNodes set to null (not computable while the audit source is down) — backend/server.py:4433-4442 (success branches at :4418-4425 and :4490-4496):

{
  "window":                {"from": "<ISO date>", "to": "<ISO date>"},
  "totalChanges":          42,
  "changesOnRemovedNodes": null,
  "activeUsers":           3,
  "nodes":                 [],
  "errors":                {"<metric_id>": "<error string>"},
  "degraded":              true
}
totalChanges is sourced from changes_total and activeUsers from active_users (window-aware) — the same catalog metrics /metrics/model uses. The heatmap, /metrics/model and the node chips (/metrics/batch) all agree because every changes*/active_users* metric is computed by the single canonical projection metrics._audit.committed_events — there is no second, private reader (owner decision 2026-09-17 extended the config/attribution exclusion from the user-facing readers to every change metric; #632/#648). The exact wording of each metric's definition is generated from the catalog in the table below — this page never restates it. changesOnRemovedNodes is the subset of totalChanges attributed to nodes that no longer exist in the graph (e.g. a delete-heavy window): those changes are real and stay counted (a deletion is a historically stable change) but have no live node to render in nodes, so totalChanges − changesOnRemovedNodes is the part shown on live nodes — a caller can explain a totalChanges > 0 / empty-nodes result instead of a blank chart. errors (present only when the audit source is unavailable) is a {metric_id: error_string} dict; nodes is empty, degraded is true and changesOnRemovedNodes is null in that case.

Catalog

Relation-based node metrics (connections, connectivity, type, location, unresolved and the *_label / *_uuid companions) read a node's effective outgoing relations — own plus inherited — from the canonical resolver domain/effective_relations.py, the same engine the detail panel renders from. Inheritance is decided there by the rulebook (relationInheritance, most-specific-wins, tier-gated) or a relation's inherit_source flag; the metric catalog itself never walks ancestors. GET /metrics/batch injects the live rulebook and tier gate into the engine (rulebook=, tier_can=), so a tree chip and the panel header always show the same location and type.

Batch security invariant

GET /metrics/batch exposes per-node metrics to any authenticated caller who can read the node. New node-scope metrics are excluded from the batch endpoint by default and require explicit addition to _BATCH_SAFE in backend/server.py only after confirming the metric cannot leak IAM-restricted subtree information (for example, a descendant count or ancestor-scoped metric would allow any anchor-readable caller to infer the size of IAM-gated subtrees). tests/test_metrics_batch_security.py::test_batch_safe_is_explicit_allowlist pins the exact allowlist and will fail if _BATCH_SAFE is silently expanded.

IAM-asynchronous metrics are a sub-category of _BATCH_SAFE for metrics that require per-caller identity forwarding via an async cross-domain fetch — something the synchronous compute_node engine cannot do. The pattern (established #698):

  1. Register the metric with @metric(…, async_only=True) in catalog_node.py so it appears in GET /metrics/catalog and the chip-registry validator. The async_only=True flag is the explicit exclusion gate: compute_node filters out any metric whose catalog entry has async_only=True and never invokes its fn. The catalog fn is a defensive sentinel that raises metrics.catalog.AsyncOnlyMetric (a typed ValueError subclass) if anything bypasses the engine and calls it directly — so an accidental sync invocation surfaces as a typed domain error, not an opaque crash.
  2. Implement an async resolver (e.g. _compute_<id>_batch in the relevant route module) with bounded fan-out — one upstream fetch per unique id across the batch, concurrency- AND count-capped — under one asyncio.timeout (Contract 13), returning {node_id: count}. Nodes whose ids would spill past the per-request cap drop their key the same way a timeout would, so a partially-filtered count is never emitted. The count cap (phase-3 doc fetches) is configurable via the config register; see infra/config.attached_documents_max_fetches (env ATTACHED_DOCUMENTS_MAX_FETCHES, default 200).
  3. Add the metric id to _BATCH_SAFE after confirming the async resolver is IAM-correct (never returns a count the caller cannot independently obtain).
  4. In the metrics_batch handler, call the async resolver and merge its result dict into the engine response after compute_node returns.

The async_only=True catalog flag is the gate; AsyncOnlyMetric is the typed fallback — the sync engine must never be asked to compute an IAM-async metric.

The metric catalog

Generated from backend/metrics/catalog.py — the canonical registry. This page never hand-lists a metric; the table is the single source for every id, scope, unit, source, merge rule and definition string.

id scope unit source merge definition
subitems node count graph — Direct children of the node (children_of[id]).
connections node count graph — Effective (own + rulebook-inherited) outgoing internal relations deduped by (predicate, target) plus incoming relations; cross-domain references are excluded (see type/location).
connectivity node count graph — Effective (own + rulebook-inherited) outgoing relations whose predicate is in the connectivity aspect (isConnectedTo) plus incoming relations with the same predicate. Matches the ConnectionsZone panel header count. Cross-domain isConnectedTo is included for completeness (panel parity) but does not occur in practice. Do NOT confuse with connections (all internal rels, cross-domain excluded) — see #700.
type node count graph — Effective outgoing cross-domain Type references (% aspect → 'type' domain): own, plus inherited where the rulebook (or the relation's inherit_source flag) says so; deduped on cross-domain target ref.
location node count graph — Effective outgoing Location references (+ aspect): own, plus inherited where the rulebook (or the relation's inherit_source flag) says so — the detail panel's number; deduped on cross-domain target ref.
documents node count graph — Engineering entries whose value matches ^(https?|file):// (case-insensitive).
specifications node count graph — Engineering entries whose value is NOT such a URL.
unresolved node count graph — Effective outgoing relations that cannot bind: an internal unresolved_ref that key_derivation.resolve_slug cannot bind, or a cross-domain ref whose domain has no public URL.
changes node count audit — Committed model-data audit events for the node in the last days days (configuration events excluded); relation create/delete/tombstone events are attributed to their source node (payload.sourceId, else payload.from) so a relation change lands on the node it hangs off, not the relation's own id.
location_label node label graph — Cross-domain target ref of the first effective location, '' if none (BL-FE-129).
location_uuid node label graph — UUID ('to') of the first effective location node, '' if none (BL-FE-129).
type_label node label graph — Cross-domain target ref of the first effective type relation, '' if none (BL-FE-162).
type_uuid node label graph — UUID ('to') of the first effective type node, '' if none (BL-FE-162).
descendants node count graph — Size of the recursive subtree below the node, excluding the node itself.
changes_subtree node count audit — changes summed over the node and every descendant (the activity heatmap number; configuration events excluded).
attached_documents node count graph — IAM-filtered count of attached document nodes for the authenticated caller: exactly the number GET /node/{uid}/documents returns to that caller. Resolved via the canonical hasDocument channel path (direct → hierarchy → relation, #508 rulebook priority) with per-caller identity forwarding. A cheap unfiltered count is not allowed — it would leak restricted doc existence (IAM option a, #698).
nodes_total model count graph sum Number of effective nodes (base + overlay − tombstoned) in the domain, excluding the structural World node; nodes_by_class still lists World.
nodes_by_class model dict graph dict_sum Effective nodes grouped by class.
nodes_by_product_type model dict graph dict_sum Nodes grouped by their productType field (Functional System / Technical System / Component); nodes without one are excluded, never fabricated.
roots model count graph sum Nodes with no parent.
tree_edges model count graph sum Sum of direct children over all nodes (= Σ subitems).
relations_explicit model count graph sum Effective explicit relations (base + overlay − tombstoned).
relations_inherited model count graph sum Relations materialised by inheritance: each inherit_source relation counts once per descendant of its source node; tombstoned sources excluded.
relations_by_predicate model dict graph dict_sum relations_explicit grouped by predicate.
relations_cross_domain model count graph sum Relations whose target lies in another domain (the relation records a target domain).
relations_total model count graph sum relations_explicit + relations_inherited.
overlay_nodes model count graph sum Nodes added by the mutation overlay.
overlay_relations model count graph sum Relations added by the mutation overlay.
tombstones_nodes model count graph sum Soft-deleted nodes (node tombstones).
tombstones_relations model count graph sum Soft-deleted relations (relation tombstones).
hierarchy_depth_max model count graph max Maximum root-to-node depth (root = 0).
hierarchy_depth_avg model ratio graph mean Mean root-to-leaf depth over all leaves (nodes without children).
max_container model id_count graph max Node with the most direct children.
max_relations_node model id_count graph max Node with the most effective relations (in + out).
orphan_nodes model id_count graph sum Isolated nodes: no parent, no children and no effective relations.
unresolved_refs model id_count graph sum Nodes with ≥1 unresolved outgoing relation (node unresolved > 0).
broken_relations model id_count graph sum Relations whose from is not an effective node, or whose to is set, internal, and not an effective node.
duplicate_designations model id_count graph sum Reference designations shared by ≥2 effective nodes in the domain.
predicate_coverage model dict graph mean {predicate: share of product nodes with ≥1 outgoing relation of that predicate} for every predicate in the relation catalog; a product node is a node whose productType is not null.
document_coverage model ratio graph mean Share of product nodes (productType not null) with ≥1 document resolved through the canonical hasDocument channel resolution — direct, then hierarchy, then relation (the #508 documentPropagation rules, the same routing the node's Documents zone uses). Admin-scope: served only under the model-metrics gate (admin_or_service) and not filtered by the caller's permissions. null when the domain has no product nodes (never 0). partial=true when a relation-channel target in another domain could not be resolved, so the share may be low.
changes_total model count audit sum Committed model-data audit events in the requested days window with a non-empty entity_id. Configuration events (IAM / rulebook / propagation admin actions) and events with no attributable entity are excluded — this counts attributable changes to the model, not configuration churn. Note: an event attributed to a since-deleted node is still counted (a deletion is a real, historically stable change); see /activity/heatmap changesOnRemovedNodes.
changes_7d model count audit sum Committed model-data audit events in the last 7 days (configuration events excluded).
changes_30d model count audit sum Committed model-data audit events in the last 30 days (configuration events excluded).
changes_by_day model dict audit dict_sum Committed model-data audit events per calendar day, last 30 days (configuration events excluded).
active_users_30d model count audit sum Distinct actors with ≥1 committed model-data audit event in the last 30 days (configuration-only actors excluded).
active_users model count audit sum Distinct actors with ≥1 committed model-data audit event in the requested days window (configuration-only actors excluded).
interfaces_total model count iis none Detected interfaces (IIS store total).
interfaces_by_status model dict iis none Detected interfaces grouped by status.
interfaces_by_criticality model dict iis none Detected interfaces grouped by criticality class.

Frozen dash contract (eidosdash)

The eidosdash dashboard consumes a frozen subset of the model-scope metrics, read straight from the FME archive's eidos:metrics (W2, #656). The current definition-meaning version is m2 (metrics.catalog.DEFINITION_VERSION, stamped as eidos:metricsDefinitionVersion); it is bumped whenever a frozen metric's meaning changes. The 27 frozen ids:

  • nodes_total
  • nodes_by_class
  • nodes_by_product_type
  • relations_explicit
  • relations_inherited
  • relations_total
  • relations_by_predicate
  • relations_cross_domain
  • tombstones_nodes
  • tombstones_relations
  • hierarchy_depth_max
  • hierarchy_depth_avg
  • max_container
  • max_relations_node
  • orphan_nodes
  • unresolved_refs
  • broken_relations
  • duplicate_designations
  • predicate_coverage
  • changes_total
  • changes_7d
  • changes_30d
  • changes_by_day
  • active_users_30d
  • interfaces_total
  • interfaces_by_status
  • interfaces_by_criticality

Appended to the contract after the 27 ids were frozen (additive — an appended id changes no existing number, so the append bumps no version; pinned by their own digest):

  • document_coverage

What the definition version means

This is the change log for eidos:metricsDefinitionVersion (the contract's honesty clause, W2 addendum b): what each version's frozen numbers mean, so eidosdash never compares numbers that were computed under different definitions. It is a change log, not a restatement of the definitions — the exact wording lives in the catalog table above. When catalog.DEFINITION_VERSION is bumped, a new entry MUST be added here in the same change — a test fails otherwise.

m1

The first frozen version (W2, #656). What changed in the model-scope numbers going into m1, relative to pre-W2:

  • changes_total, changes_7d/30d, changes_by_day, active_users*: configuration events (IAM, rulebook, propagation admin actions) are excluded (#652), and events with no attributable entity_id are not counted (#654). DEV example: changes_total went 1997 → 1972.
  • Relation-change attribution: a relation create / delete / tombstone is charged to its source node (#654). This shifts node-scope changes and the /activity/heatmap — not the frozen model-scope ids. Dash should not look for an effect on the 27 frozen ids from this change.
  • relations_by_predicate counts explicit relations only; the inherited expansions are carried by relations_inherited (#626).

Added within m1 (no bump): document_coverage (#1142) is appended to the dash contract. It is a new id, so no existing number changes meaning; the 27 frozen definitions and their digest are unchanged, and the new id is pinned by its own digest.

Consequence for Dash: an archive whose metrics came from Dash's own collector-fallback (metrics_source: "collector-fallback", i.e. no eidos:metricsDefinitionVersion) was computed under the pre-m1 definitions and must not be placed in the same time series as m1 numbers.

m2

nodes_total no longer counts the structural World node (#1152). Every domain's node map carries one World node: the parent of the domain root, not a model object. m1 counted it, so the catalog was one higher than the count of the domain's model nodes. The node is matched by its fixed UID, not by its class. Only the number changes: nodes_by_class, roots and tree_edges still include World, so the excluded node stays visible. A parent-less node that is not World, such as a stray second root, is still counted. EIDOS's own UI (the server-info panel and the per-domain rows) reads nodes_total from the catalog, so it shows the same number. DEV example: location went 4915 → 4914.

Consequence for Dash: an m1 archive's nodes_total is one higher per domain than an m2 archive's (the all-domain sum is higher by the number of domains). Do not place nodes_total from both versions in the same time series.