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
- A real
:telemetry.execute/3call under[:ggen_igniter, :reconcile, :ocel]-- so any process that wants to observe reconciliation activity (logging, metrics, a future dashboard) can attach a real:telemetryhandler and receive these events live, without this module knowing or caring whether anyone is listening. - If a
sink(a real, runningAgentpid fromnew_sink/0) is given, the event is ALSO appended to that agent's real in-memory list -- this is howGgenIgniter.Reactors.ReconcileReactoraccumulates one attempt's full event log across multiple Reactor steps (:actuate'srun/3AND itsundo/3,:verify,:admit, ...) into a single ordered list it can embed in aGgenIgniter.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 noocp:analogue;ocp:'socp:failureLifecyclerunsTestFailed -> RepairApplied(repair forward), never restore-to-prior-state.ADMITTED/STANDING_SET-- this repo's own admission/standing vocabulary (.claude/rules/standing-law.mdinautofde-lab) has noocp:EventTypecounterpart;ocp:recordsocp: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
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
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.
activity-- the event's OCEL "activity" name, e.g."ACTUATION_STARTED". A plain string (not an atom) so it round-trips throughJason.encode!/1and a receipt's persisted JSONL unchanged.objects-- the OCEL objects this event is about (build withfile_object/1/run_object/1).attributes-- a plain, JSON-encodable map of this event's own payload (e.g.%{"paths" => [...], "pre_run_hash" => "sha256:..."}).
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.
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.
@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).
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.
@spec telemetry_event() :: [atom(), ...]
The :telemetry event name every emit/4 call executes under.