GgenIgniter.Telemetry.OcelEmitter (ggen_igniter v26.9.8)

Copy Markdown View Source

CONSTRUCTION NOTE (2026-08-27): at the time this module was written, a concurrent workflow was tasked with building lib/ggen_igniter/receipt.ex, lib/ggen_igniter/reactors/reconcile_reactor.ex, and this file (lib/ggen_igniter/telemetry/ocel_emitter.ex). This repo was polled for their existence 6 times (roughly 90 seconds, per the concurrency protocol given) and none of the three had appeared -- only a forward-referencing comment in mix.exs ({:reactor, "~> 1.0"}'s dependency note) evidenced that the other workflow had started. This module is therefore this session's own from-scratch, best-effort construction, not a correction to pre-existing code. If the concurrent workflow's real version lands later, reconcile the two rather than silently preferring either.

An OCEL (object-centric event log, https://www.ocel-standard.org/)-SHAPED event emitter for one reconciliation attempt's real lifecycle:

ACTUATION_STARTED -> FILES_CHANGED -> VERIFICATION_FAILED ->
COMPENSATION_STARTED -> FILES_RESTORED -> STANDING_SET

(or, on the happy path: ACTUATION_STARTED -> FILES_CHANGED -> VERIFICATION_SUCCEEDED -> ADMITTED -> STANDING_SET.)

This is the exact stage sequence the user specified for GgenIgniter.Receipt's standing: :compensated case -- this module is what actually PRODUCES that sequence as real, structured data (not prose): each call to emit/4 returns one OCEL-shaped event map, real enough to serialize into a receipt's events list verbatim.

Two real, independent effects per emit/4 call

  1. A real :telemetry.execute/3 call under [:ggen_igniter, :reconcile, :ocel] -- so any process that wants to observe reconciliation activity (logging, metrics, a future dashboard) can attach a real :telemetry handler and receive these events live, without this module knowing or caring whether anyone is listening.
  2. If a sink (a real, running Agent pid from new_sink/0) is given, the event is ALSO appended to that agent's real in-memory list -- this is how GgenIgniter.Reactors.ReconcileReactor accumulates one attempt's full event log across multiple Reactor steps (:actuate's run/3 AND its undo/3, :verify, :admit, ...) into a single ordered list it can embed in a GgenIgniter.Receipt, including on a FAILURE path where the reactor's own return value carries no such accumulated state.

Using a real, separately-started Agent process (rather than, say, the process dictionary or an ETS table) keeps this fully Chicago-style testable: a test starts a real sink, runs real code that emits into it, and asserts on the real list drain_sink/1 returns -- no interaction-based mock of "was emit called" is needed anywhere.

Relationship to autofde-lab's ocp: OCEL vocabulary (DECIDED, 2026-09-04)

~/autofde-lab/ontology/ocel-production.ttl defines a real, separate ocp:EventType vocabulary (ocp:RequirementDiscovered, ocp:OntologyChanged, ocp:SparqlChanged, ocp:TypeManufactured, ocp:ArtifactGenerated, ocp:TestFailed, ocp:RootCauseAnalyzed, ocp:RepairApplied, ocp:PullRequestOpened, ocp:PullRequestReviewed, ocp:CIRan, ocp:Merged, ocp:Released, ocp:ConsumerValidated, ocp:InvariantIntroduced, ocp:InvariantViolated) scoped to a multi-repo manufacturing history: requirement discovery through ontology change, code generation, test/repair cycles, PR review, CI, merge, and release, spanning many consumer repos over the life of a capability.

This module's own stage constants (ACTUATION_STARTED, FILES_CHANGED, VERIFICATION_FAILED, VERIFICATION_SUCCEEDED, COMPENSATION_STARTED, FILES_RESTORED, ADMITTED, STANDING_SET) are a real but narrower vocabulary, scoped to one single reconciliation attempt's transactional lifecycle inside GgenIgniter.Reactors.ReconcileReactor (27 real call sites there): write files, verify, admit-or-compensate, record standing -- seconds, not the days-to-months span an ocp: release lifecycle covers.

A real, exhaustive cross-check of all 8 of this module's stages against all 16 ocp:EventType individuals found exactly 1 real match: VERIFICATION_FAILED ~ ocp:TestFailed (both mark a verification step that did not pass). The other 7 stages have no ocp: counterpart, because they name concepts ocp: has no object type for at all:

  • ACTUATION_STARTED / FILES_CHANGED -- ocp: has no "file write in progress" event; its nearest object types (ocp:GeneratedArtifact, ocp:Commit) are recorded as already-produced, not as write-in-flight.
  • COMPENSATION_STARTED / FILES_RESTORED -- rollback/undo of a transactional write attempt has no ocp: analogue; ocp:'s ocp:failureLifecycle runs TestFailed -> RepairApplied (repair forward), never restore-to-prior-state.
  • ADMITTED / STANDING_SET -- this repo's own admission/standing vocabulary (.claude/rules/standing-law.md in autofde-lab) has no ocp:EventType counterpart; ocp: records ocp:ConsumerValidated, a different, later-stage, cross-repo consumer-acceptance concept.

Decision: cross-reference only -- do NOT unify. This module keeps emitting its own ad hoc string constants unchanged. Unifying onto ocp: would either (a) force 7 of 8 real stages into an ocp:EventType that does not actually mean what they mean, silently widening this module's single-attempt scope into ocp:'s multi-repo manufacturing-history scope it was never designed to carry, or (b) require ocel-production.ttl to grow 7 new EventType individuals for a lifecycle that isn't the one it models, diluting a vocabulary that is otherwise a clean multi-repo manufacturing ontology. Both are worse than the 1-line real overlap this cross-reference documents. If a future caller needs to correlate one reconciliation attempt's events with ocp:'s manufacturing history (e.g. to relate a VERIFICATION_FAILED event to an ocp:TestFailed object in a wider OCEL graph), that correlation belongs in the caller that has both contexts in view, keyed by explicit shared object identity per autofde-lab's .claude/rules/no-dual-bookkeeping.md (never inferred from label similarity) -- not by renaming this module's own vocabulary.

Summary

Types

One OCEL-shaped event.

An OCEL "object" reference -- {type, id} as a JSON-friendly map.

Functions

Whether any event in events has "activity" equal to activity.

Drains sink -- returns every event appended so far, in the real order they were emitted (oldest first) -- and stops the agent. Safe to call at most once per sink (the agent is gone afterward); calling with nil returns [] with no error, so callers that never had a sink can call this unconditionally.

Builds one OCEL-shaped event, executes it as a real :telemetry event, appends it to sink (if not nil), and returns the built event map -- callers that need the event's own generated "id" (e.g. to cross-reference it from a receipt) get it directly from the return value, not by re-reading the sink.

Builds a "file"-typed OCEL object reference for path.

Finds the last event in events (oldest-first, as returned by drain_sink/1/peek_sink/1) whose "activity" is activity, or nil. A small real query helper -- both GgenIgniter.Reactors.ReconcileReactor and its tests use this to pull a specific attribute (e.g. a recorded "pre_run_hash") back out of the real, already-emitted event log instead of threading it through a second, parallel channel.

Starts a fresh, real event sink (a plain Agent holding an ordered list, most-recent-first internally). Returns its pid. Callers own the pid's lifecycle -- pass nil anywhere a sink is accepted to opt out of accumulation entirely (telemetry still fires either way).

Peeks at sink's current events (oldest first) WITHOUT stopping it -- unlike drain_sink/1, the sink stays alive and can receive more events afterward. Used by callers (e.g. GgenIgniter.Reactors.ReconcileReactor's own :actuate step undo/3 callback) that need to read back an already-emitted attribute (like a recorded pre-run hash) mid-attempt. nil returns [].

Builds a "reconcile_run"-typed OCEL object reference for run_id.

The :telemetry event name every emit/4 call executes under.

Types

event()

@type event() :: %{required(String.t()) => String.t() | [object()] | map()}

One OCEL-shaped event.

object()

@type object() :: %{required(String.t()) => String.t()}

An OCEL "object" reference -- {type, id} as a JSON-friendly map.

Functions

any?(events, activity)

@spec any?(list(), String.t()) :: boolean()

Whether any event in events has "activity" equal to activity.

drain_sink(sink)

@spec drain_sink(pid() | nil) :: [event()]

Drains sink -- returns every event appended so far, in the real order they were emitted (oldest first) -- and stops the agent. Safe to call at most once per sink (the agent is gone afterward); calling with nil returns [] with no error, so callers that never had a sink can call this unconditionally.

emit(sink, activity, objects, attributes \\ %{})

@spec emit(pid() | nil, String.t(), [object()], map()) :: event()

Builds one OCEL-shaped event, executes it as a real :telemetry event, appends it to sink (if not nil), and returns the built event map -- callers that need the event's own generated "id" (e.g. to cross-reference it from a receipt) get it directly from the return value, not by re-reading the sink.

  • activity -- the event's OCEL "activity" name, e.g. "ACTUATION_STARTED". A plain string (not an atom) so it round-trips through Jason.encode!/1 and a receipt's persisted JSONL unchanged.
  • objects -- the OCEL objects this event is about (build with file_object/1 / run_object/1).
  • attributes -- a plain, JSON-encodable map of this event's own payload (e.g. %{"paths" => [...], "pre_run_hash" => "sha256:..."}).

file_object(path)

@spec file_object(String.t() | nil) :: object()

Builds a "file"-typed OCEL object reference for path.

A mode: eval PendingActuation genuinely has no file path (PendingActuation.for_eval/3's target field is always nil) -- this is a real, honest sentinel id for that case, not a crash. Fixes AR-9: previously this function had only the is_binary(path) clause below, so ANY :eval target raised FunctionClauseError inside the Reactor's :render step before :admit/:actuate ever ran, independent of frontmatter.

find_last(events, activity)

@spec find_last(list(), String.t()) :: map() | nil

Finds the last event in events (oldest-first, as returned by drain_sink/1/peek_sink/1) whose "activity" is activity, or nil. A small real query helper -- both GgenIgniter.Reactors.ReconcileReactor and its tests use this to pull a specific attribute (e.g. a recorded "pre_run_hash") back out of the real, already-emitted event log instead of threading it through a second, parallel channel.

new_sink()

@spec new_sink() :: pid()

Starts a fresh, real event sink (a plain Agent holding an ordered list, most-recent-first internally). Returns its pid. Callers own the pid's lifecycle -- pass nil anywhere a sink is accepted to opt out of accumulation entirely (telemetry still fires either way).

peek_sink(sink)

@spec peek_sink(pid() | nil) :: [event()]

Peeks at sink's current events (oldest first) WITHOUT stopping it -- unlike drain_sink/1, the sink stays alive and can receive more events afterward. Used by callers (e.g. GgenIgniter.Reactors.ReconcileReactor's own :actuate step undo/3 callback) that need to read back an already-emitted attribute (like a recorded pre-run hash) mid-attempt. nil returns [].

run_object(run_id)

@spec run_object(String.t()) :: object()

Builds a "reconcile_run"-typed OCEL object reference for run_id.

telemetry_event()

@spec telemetry_event() :: [atom(), ...]

The :telemetry event name every emit/4 call executes under.