Operation Record Engine¶
Verified against the code · 4d5c9b5781eaaa62048db19cd10dc7039e402c98 · 2026-10-20
956 D1b-2 (2026-10-20): 1 finding(s) fixed — OperationRecord carries no event-id field; id_from_envelope is used only by record()/record_rejected() to return the appended event's id. Re-verified against main 4d5c9b57.¶
Layer: domain
Overview — one meaning for every audit event¶
Every mutation the system commits (and every one it rejects) is written to an
append-only provenance log — one eidos:AuditEvent per line in
mutations/audit/{date}.jsonl (older days may still be legacy .jsonld
@graph files). Five different features read that log back: the point-in-time
reconstruction engine, a node's history timeline, the changelog, the
activity metrics, and the activity heatmap.
Before trin 2.4 each of those five re-derived the meaning of a record on its own
— the "only count committed events" gate, the rule that a CreateRelation /
DeleteRelation is charged to its source node (not the relation id), and the
dispatch on intentType. Worse, the heatmap hand-parsed only {date}.jsonld
files and never read the .jsonl format that every real mutation now writes — so
on any modern deployment the heatmap silently counted zero activity (a live
bug).
The Operation Record Engine (backend/domain/operation_record.py) is now the
single interpretation of an audit event. It reads through the format-agnostic
accessor audit_service.read_events (both .jsonld and .jsonl) and projects
each raw event into a frozen OperationRecord with the shared accessors:
| Accessor | Meaning |
|---|---|
is_committed |
the record's commit result is "committed" |
attributed_entity_id |
the entity a change is charged to — the source node for a relation create/delete, otherwise the commit's entityId |
day, actor, intent, payload, seq, snapshot_ref |
the record's fields, defaulted so nothing raises |
read_committed(...) returns the committed records sorted by (timestamp, seq) —
the canonical replay/aggregation order. All five readers consume the engine:
reconstruction folds read_committed; changelog, metrics and the heatmap read
through it; node history projects the route's events with from_event. The
heatmap now reads both formats, so .jsonl activity is counted.
Audit event envelope — @id / audit_iri (WP1 #950)¶
Every eidos:AuditEvent record audit_service.append writes carries the
WP1 envelope shape: the event's identity is an @id IRI emitted via
infra.iri.audit_iri(event_uuid) → https://id.ontoteq.com/{event_uuid}
(flat IRI space, shared with nodes and relations; @type:
"eidos:AuditEvent" carries the type distinction). eidos:id is no
longer emitted — the pre-WP1 duplicate was removed. Both
audit_service.make_committed and audit_service.make_rejected build the
envelope via at_id_entry(audit_iri(event_uuid)), so there is one owner
for the shape (R4-writer baseline in
tests/test_rdf_g1_r4_ratchet.py; HTTPS prefix pinned by
tests/test_rdf_wp1_r5_ratchet.py).
OperationRecord (and from_event) expose no event id at all — its
fields are intent, payload, entity_id, result, actor, ip, timestamp, seq,
snapshot_ref, domain_key, raw. infra.iri.id_from_envelope, which keys on
@id and falls back to eidos:id only for pre-WP1 legacy .jsonld day
files, is called only inside record() / record_rejected() on the freshly
built envelope, to return the appended event's id. Legacy urn:eidos:audit:{uuid} @id values written
before WP1 continue to load unchanged via the from_iri fallback (debug
log from_iri_legacy_form); rewrite is deferred to #946. The route layer
(and record() / record_rejected() facades) never hand-build the
envelope, so no caller re-emits eidos:id — the forward-only policy on
WP1 (docs/specs/identity-facade.md §4a; governance exception
.archon/exceptions/06-TEST-ROLLBACK-wp1-forward-only.yaml) depends on
this.
Because the engine depends only on persistence.audit_service and never on
server, it lifts into eidos-core unchanged — it is the P4 provenance engine.
The single provenance sink¶
record(...) and record_rejected(...) are the only way a non-engine surface
records provenance. Graph node/relation mutations are audited inside the
graph CRUD engine itself; everything else — the
Documents admin actions, the Documentation 360° report, the cross-domain
relation heal, and (new in 2.4) IAM AccessRule and Rulebook / propagation-rule
mutations — records through this facade. audit_service.append is the single
writer underneath.
This closes a real provenance gap: before 2.4 the IAM and Rulebook admin routers
never wrote to the audit log at all, so a rule change left no trace
(project_full_provenance requires the state to be reconstructible from disk +
the audit log). A CI guard (tests/test_mutation_transaction_guard.py) now
forbids any route from hand-building an audit event — all provenance must flow
through the facade.
Config events are not model changes — excluded from every change metric¶
The IAM / Rulebook provenance intents (CONFIG_INTENTS —
CreateIAMRule, UpdateIAMRule, DeleteIAMRule, UpsertPropagationRule,
DeletePropagationRule, UpsertMovementRule, DeleteMovementRule,
ReplaceRulebook) are admin plumbing, not model changes. They stay in the
audit log (and are still reconstructable — node history, read_committed, admin
export), but they never count as changes to the model. The changelog and the
activity heatmap have always filtered them out (record.is_config), so a
non-admin never sees that IAM rules are changing (a mild info-disclosure the
owner ruled out). Owner decision 2026-09-17 extended this to every
changes*/active_users* metric — the exclusion now lives in the one
canonical metrics projection (metrics._audit.committed_events), so
/metrics/model, /activity/heatmap and the node chips (/metrics/batch) count
model-data changes only. "How much did the model change" must not be inflated by
configuration churn. Reconstruction and node history are unaffected (they read
the full audit record, config events included).
Unknown intent types never break reconstruction¶
The new IAM / Rulebook provenance events (UpdateIAMRule,
UpsertPropagationRule, …) land in the same append-only log as graph events,
but they have no graph effect. The engine never dispatches on intent — it only
exposes it — and reconstruction's fold treats an unrecognised intentType as a
graceful no-op (skipped, logged at debug, never raised). This is a binding
invariant: a future event type must never be able to break a point-in-time
reconstruction. It is covered by an explicit test.
Rulebook events are reconstructable (#592)¶
Config events are hidden from users, but they must still satisfy the
full-provenance invariant — canonical state = disk + audit log, reconstructable
at any T. Before #592 the rulebook/propagation events (UpsertMovementRule,
DeleteMovementRule, UpsertPropagationRule, DeletePropagationRule,
ReplaceRulebook — the RULEBOOK_INTENTS subset) wrote thin payloads
(category + keys), so the rulebook could not be rebuilt from history. #592 closes
that gap without an envelope change:
- Content-addressed snapshot per mutation. Inside the same locked
read-modify-write that writes
project.rulebook.json, the full post-state book is written to<mutations_dir>/rulebook_history/<sha256>.json(persistence.rulebook_history, idempotent — same content ⇒ same file). The snapshot bytes are the canonical rulebook serialization (rulebook_store.serialize_rulebook, written withnewline="\n"so the file bytes equal the serialization on every OS), so a snapshot is byte-identical to the live rulebook at that state. The snapshot is durable before the audit event is appended, so a committedsnapshotRefcan never dangle. The only possible failure ordering is the harmless one — if the snapshot write itself fails, the mutation is applied but no event is recorded (a snapshot without an event, never an event without a snapshot). - The pointer rides the existing field.
record(..., snapshot_ref="sha256:…")puts it ineidos:snapshot.snapshotRef(already in the envelope;OperationRecordexposes it assnapshot_ref). Granular events also carry a human-readablebefore/afterdiff;ReplaceRulebookcarries{categories, before_hash, after_hash}— never the inline book. - Reconstruction is a lookup, not a fold.
rulebook_history.rulebook_as_of(T)returns the snapshot referenced by the latest committed rulebook event withtimestamp <= T. ATbefore the first snapshot-bearing event raisesRulebookHistoryFloorError(404, canonical envelope) — mirroring the graph'sHistoryFloorError; there is no reconstructable rulebook state before provenance existed. - Actor parity (#601 review). These events record
actor.username= the display name (resolved the same way engine mutations resolve it, viaserver._resolve_actor_display_name), with the stable uuid kept inpayload.actorUserId— so an audit event's actor is a name, uniformly, whether it came from a graph mutation or a rulebook edit.
IAM mutations through /admin/iam/rules* are reconstructable (#605).
Create/Update/DeleteIAMRule events carry the same model: a content-addressed
snapshot of the full post-state rule set in
<mutations_dir>/iam_history/<sha256>.json, referenced via snapshotRef.
persistence.iam_history.iam_rules_as_of(T) is the lookup function;
IAMHistoryFloorError (error_code="iam_before_history_floor", HTTP 404) is
the floor error. The IAM_INTENTS frozenset in operation_record marks the
three intents. Cascade coverage (#912): rules dropped by the user-delete
cascade (admin_users_router.admin_delete_user) now emit one DeleteIAMRule
event per rule — same shape as a direct /admin/iam/rules* delete, with an
additive payload.cascade='user_deleted' + payload.deleted_user_id marker
and a non-empty snapshot_ref. iam_rules_as_of(T) replays the cascade
through the same pointer-lookup path; the pre-#912 orphan gap is closed.
Failure behaviour (#912 round 2): the cascade loop runs BEFORE the user
record is removed, so a mid-cascade exception propagates out of the handler
with the user still on disk; events for rules already committed stay durable
and a second DELETE /admin/users/{user_id} finishes the remaining rules
and finally removes the user — the audit log stays reconstructable across
the retry.
Both snapshot stores share one primitive, persistence.snapshot_store.ContentAddressedSnapshotStore
(#605 r1), parametrised by (directory, serializer, intent set, floor-error
class). rulebook_history and iam_history are thin shims over it.
Derived quantities use the historical rulebook (#604). The reconstruct_as_of
graph fold now feeds rulebook_as_of(T) into the inherited-relation expansion, so
an as_of export is historically faithful in its derived quantities too — the
eidos:delivery.reconstruction_semantic marker on an as_of response is
raw_graph_and_derived_quantities_as_of_T. Pre-#604, reconstruction did NOT wire
an inheritance resolver at all (so a reconstructed as_of archive carried no
inherited rows); fingerprints over the reconstruction delivery path therefore
change for every T whose model has an inheritable relation. IIS criticality is
the one derived quantity still computed by today's engine (no historical IIS
store yet — a scoped simplification tracked for a sibling issue). See
effective-relations-engine for the inheritance expansion itself; the
rulebook_as_of(T) pointer-lookup mechanics (snapshot refs, history floor,
no-history sentinel) are described in the "Rulebook events are reconstructable
(#592)" section ABOVE on this page — the sole owner of the rebuild mechanics,
with persistence.rulebook_history as the implementation module.