CONSTRUCTION NOTE (2026-08-27): at the time this module was written, a
concurrent workflow was tasked with building THIS file plus
lib/ggen_igniter/reactors/reconcile_reactor.ex and
lib/ggen_igniter/telemetry/ocel_emitter.ex. This repo was polled for
their existence 6 times (~90 seconds, per the concurrency protocol given)
and none had appeared yet -- only a forward-referencing comment in
mix.exs evidenced 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 (do not silently prefer either) --
standing's four required atoms and the append-only-jsonl-per-admitted-
attempt contract below are the two load-bearing requirements a merged
version must keep.
The RUN RECEIPT: a durable, append-only record of ONE admitted
reconciliation ATTEMPT -- written to
<base_dir>/.ggen_igniter/receipts/<yyyy-mm-dd>.jsonl, one JSON object per
line, one line per attempt.
Receipt vs. manifest -- two different durable records, on purpose
GgenIgniter.Manifest (.ggen_igniter/manifest.json) is the CURRENT-STATE
cache: what does this recipe's most recent SUCCESSFUL run actually write,
right now. It only ever advances on a real :alive standing -- that
behavior is unchanged by this module.
This module is the HISTORY: what was ATTEMPTED, every time, regardless of outcome. The concrete reason both are needed, in the user's own words:
If files were actually changed -- even temporarily -- then a consequential physical actuation occurred... the run receipt should record ACTUATION_STARTED -> files A,B changed -> verification failed -> compensation started -> A,B restored -> resulting project hash == pre-run hash -> standing = COMPENSATED.
A manifest-only world has NO record of that attempt at all once undo restores the pre-run bytes (the manifest never moved, and the files are back to their old content) -- yet a real, consequential actuation happened: disk was written to, twice. Losing that history is losing real operational evidence (what keeps failing verification here? how often? what does the failure loop look like?). This module is what keeps it.
The five real standings
standing/0 MUST be one of:
:alive-- the attempt succeeded: files were written, verification passed, the change was admitted, and (GgenIgniter.Manifest) advanced.:refused-- a fail-closed refusal BEFORE any actuation: nothing was ever written to disk (a path-safety guard, a missing precondition, a bad input). The receipt exists to record that an attempt was MADE and WHY it was refused, even though disk state never moved.:compensated-- files WERE written, verification then failed for a reason other than a build/syntax break, undo restored the prior on-disk bytes, and the resulting hash was confirmed to match the pre-run hash.:build_broken-- the same shape as:compensated(files written, then restored), but the specific reason verification failed was that the generated content itself does not parse/compile -- a genuine "the pack produced broken code" case, distinguished from a semantic verification failure so the two failure modes don't get conflated in the receipt history.:compensation_failed-- CATASTROPHIC: files WERE written, verification then failed, and the attempt to UNDO those writes (restore the prior on-disk bytes) itself failed for one or more of the touched paths (a realFile.write!/File.rmraising -- e.g. a target that became read-only, or was deleted out from under the process, between:actuateand its own revert). Unlike:compensated/:build_broken, this standing does NOT claimpre_run_hash == post_run_hash-- it cannot, because compensation genuinely did not fully succeed. The receipt'smetadatanames, explicitly: that a real mutation occurred, that verification failed, that restoration (compensation) ALSO failed, which exact paths could not be restored and why, which paths (if any) WERE successfully restored, and that manual repair may be required. This is the one standing this pipeline treats as an operator-facing incident, not a routine, self-healed failure -- seeGgenIgniter.Reactors.ReconcileReactor's moduledoc andtest/ggen_igniter_compensation_failure_test.exsfor the real, no-mock proof (a realFile.chmod!/2makes a real revert write fail).
Every one of the five is a real, distinct, intentional call site --
standing is a closed set (new/1 raises on anything else) precisely so
a future caller cannot silently invent a sixth meaning.
Format (one line of .../receipts/<yyyy-mm-dd>.jsonl)
{
"id": "rcpt_...",
"recipe_key": "templates/resource.ex.eex=>lib/.../<%= ... %>.ex",
"standing": "compensated",
"started_at": "2026-08-27T12:00:00.000000Z",
"finished_at": "2026-08-27T12:00:00.050000Z",
"pre_run_hash": "sha256:...",
"post_run_hash": "sha256:...",
"files": ["lib/support_desk/support/ticket.ex"],
"events": [ {"id": "ev_...", "activity": "ACTUATION_STARTED", ...}, ... ],
"reason": "verification failed: ...",
"metadata": {}
}pre_run_hash/post_run_hash are built by hash_files/1 /
hash_entries/1 -- a single digest over the EXACT set of files this
attempt touched (not a whole-repository hash, which would be both
impractical to compute per-attempt and would falsely flag as "changed"
every unrelated file in a real project). On a :compensated or
:build_broken receipt, pre_run_hash == post_run_hash is the real,
checkable claim that compensation genuinely restored prior state -- see
test/ggen_igniter_receipt_compensated_test.exs.
files' entries: real canonical identity, not a raw path string
On a real :alive receipt, files (this module's own moduledoc example
above) is built by the caller (GgenIgniter.Reactors.ReconcileReactor. finalize_evidence/1) from GgenIgniter.ArtifactIdentity.canonicalize/2's
real result for each admitted output -- via GgenIgniter.PendingActuation's
canonical_target field -- never the raw, un-normalized target string,
same real identity GgenIgniter.Manifest's own outputs keys are built
from (see that module's moduledoc). This module itself never parses or
interprets a path; it is the caller's job to decide, and pass in, a real
identity.
commands' entries (first real production call site)
commands was schema-ready (PRD v2, additive field, defaulting to [])
but never actually populated by any real call site until a template's
sh_before:/sh_after: frontmatter fields (GgenIgniter.Frontmatter)
gained real subprocess execution (GgenIgniter.ShellHook.run/3) -- see
Mix.Tasks.GgenIgniter.Sync's and GgenIgniter.Reactors.ReconcileReactor's
own moduledocs ("sh_before:/sh_after: shell hooks") for the full,
disclosed admission-gate/compensation exclusion this field's presence
documents. Each entry is a plain, Jason-encodable, string-keyed map:
%{
"kind" => "sh_before" | "sh_after",
"cmd" => "mix compile",
"template_path" => "priv/ggen/some-pack/templates/resource.ex.eex",
"target" => "lib/generated/resource.ex" | nil,
"exit_code" => 0 | non_neg_integer() | nil,
"output" => "...combined stdout+stderr...",
"duration_ms" => non_neg_integer(),
"status" => "ok" | "failed" | "timeout"
}"exit_code"/"output" are nil only for "status" => "timeout" (the
real command was killed via Task.shutdown/2's :brutal_kill before it
ever produced a real exit status). Mix.Tasks.GgenIgniter.Sync's own
inline (--for-each-aware) pipeline appends an entry for EVERY real
invocation, success or failure (its own new :sh_before_failed/
:sh_after_failed per-row outcome atoms carry the failure without
aborting the whole run -- see that module's moduledoc). GgenIgniter. Reactors.ReconcileReactor's atomic all-or-nothing pipeline only ever
reaches finalize_evidence/1 (where commands is attached to the
receipt) on its own success path, so its commands entries are always
"status" => "ok" -- a failed hook there raises, is caught by the SAME
rescue clause that already catches a real Actuate.write_file!/3/
inject_content!/5 failure, and flows through the existing self-heal/
undo/4 machinery (the failure's exit code/output land in the resulting
receipt's reason/metadata["raw_error"] text instead of a structured
commands entry -- a smaller, honest scope than the inline pipeline's,
disclosed in ReconcileReactor's own moduledoc rather than forced into a
shape its atomic architecture does not naturally support).
Summary
Types
One of the five real, closed-set standings a receipt may record.
One receipt: one admitted reconciliation attempt, whatever its outcome.
Functions
REAL append-only persistence: encodes receipt as one JSON line and
appends it (File.write!/3 with [:append]) to path(base_dir, at) --
never truncates, never rewrites a prior line. Creates the receipts
directory if needed. This is deliberately NOT atomic-rename like
GgenIgniter.Manifest.persist!/2 -- an append-only log's failure mode
(a torn last line on a real crash mid-write) is recoverable by discarding
an incomplete final line, whereas manifest.json is a single
point-in-time snapshot that atomic rename protects from ever being
partially overwritten. See GgenIgniter.Reactors.ReconcileReactor's
moduledoc for why this receipt append happens BEFORE, and independent of,
any manifest promotion.
A "sha256:" <> hex digest over receipt's own content (its
to_json_map/1 form, MINUS the "receipt_hash" key itself -- a hash
cannot cover its own output without being circular). This is what proves
chain integrity at the single-receipt level: any bit of a persisted
receipt (its standing, its files, its metadata, any PRD v2 field)
changing after the fact changes this digest, exactly the same
tamper-evidence property hash_entries/1/hash_files/1 already give the
FILES a receipt describes, now given to the RECEIPT RECORD describing
them.
The receipts directory for consumer project base_dir: <base_dir>/.ggen_igniter/receipts.
A "sha256:" <> hex digest over entries -- a list of
{path, content_or_nil} pairs, nil meaning the path was absent. Sorted
by path first so the digest is order-independent (the same file set
always hashes the same way regardless of enumeration order). This is the
primitive both hash_files/1 (reads current disk state) and
GgenIgniter.Reactors.ReconcileReactor (hashing an already-captured
pre-image, without re-reading disk) build on.
A "sha256:" <> hex digest over the REAL, CURRENT on-disk content of
paths (missing files hash as :absent, matching hash_entries/1). This
is the real "project hash" (scoped to the exact files one attempt
touches, not a whole-repository hash) the user's compensation narrative
refers to as "resulting project hash".
Builds a new receipt struct. attrs is a plain map/keyword list; :standing
is required and MUST be one of standings/0 (raises ArgumentError
otherwise -- a receipt with an invented standing is a real correctness bug,
refused loudly rather than silently persisted). :id is generated if not
given; :started_at/:finished_at default to DateTime.utc_now/0
(ISO8601-encoded) if not given.
Same as new/1, plus an opts keyword list for PRD v2 chain-linking.
The date-partitioned JSONL path a receipt stamped at (default: now) is
appended to -- <base_dir>/.ggen_igniter/receipts/<yyyy-mm-dd>.jsonl. Date
partitioning keeps any single file bounded (an append-only log with no
rotation would grow forever) while read_all!/1 transparently reads every
partition back in order.
Reads every receipt line back from EVERY .jsonl partition under
dir(base_dir), in file-then-line order (oldest date partition first;
each partition is itself append-ordered, so this is the real
chronological attempt history). Returns [] (not an error) when no
receipts directory exists yet -- the honest "no attempts recorded yet"
state, matching GgenIgniter.Manifest.load/1's same "first run" honesty.
Reconstructs the CURRENT standing for one recipe (recipe_key) by
re-reading the real, on-disk receipt chain under base_dir -- ZERO
in-process state is consulted. This simulates exactly what a genuinely
fresh BEAM process (no live GgenIgniter.Controller, no prior GenServer
state -- e.g. after a real restart) would see: GgenIgniter.Controller's
reconciliation_count/last_run_at are real, process-only knowledge
(see that module's moduledoc) that this function makes NO attempt to
recover; what IS durable, and what this function reconstructs, is the
chain of admitted attempts and the standing the last one left behind.
The five real, closed-set standing atoms a receipt may carry.
Converts a receipt struct to its plain, Jason-encodable, string-keyed map form.
Maps a receipt's real standing (standing/0) onto the PRD's status
vocabulary (this repo's own no-overclaiming floor:
ALIVE/PARTIAL_ALIVE/BLOCKED/BUILD_BROKEN/UNSUPPORTED/UNKNOWN), so a
PRD-facing report can cite one receipt's standing without re-deriving
the mapping ad hoc at every call site.
This application's own release version (Mix.Project.config()[:version]),
used as t/0's tool_version default so every receipt records exactly
which build of ggen_igniter produced it.
Types
@type standing() ::
:alive | :refused | :compensated | :build_broken | :compensation_failed
One of the five real, closed-set standings a receipt may record.
@type t() :: %GgenIgniter.Receipt{ commands: list(), completed_at: String.t() | nil, engine: String.t() | nil, events: [map()], files: [String.t()], finished_at: String.t(), id: String.t(), inputs: list(), metadata: map(), operation: String.t() | nil, outputs: list(), parent_hash: String.t() | nil, plan_hash: String.t() | nil, post_run_hash: String.t() | nil, pre_run_hash: String.t() | nil, pre_state_hash: String.t() | nil, queries: list(), reason: String.t() | nil, receipt_hash: String.t() | nil, recipe_key: String.t() | nil, result_hash: String.t() | nil, schema_version: String.t(), skipped_outputs: list(), source_hash: String.t() | nil, standing: standing(), started_at: String.t(), tool_version: String.t() | nil }
One receipt: one admitted reconciliation attempt, whatever its outcome.
Functions
REAL append-only persistence: encodes receipt as one JSON line and
appends it (File.write!/3 with [:append]) to path(base_dir, at) --
never truncates, never rewrites a prior line. Creates the receipts
directory if needed. This is deliberately NOT atomic-rename like
GgenIgniter.Manifest.persist!/2 -- an append-only log's failure mode
(a torn last line on a real crash mid-write) is recoverable by discarding
an incomplete final line, whereas manifest.json is a single
point-in-time snapshot that atomic rename protects from ever being
partially overwritten. See GgenIgniter.Reactors.ReconcileReactor's
moduledoc for why this receipt append happens BEFORE, and independent of,
any manifest promotion.
A "sha256:" <> hex digest over receipt's own content (its
to_json_map/1 form, MINUS the "receipt_hash" key itself -- a hash
cannot cover its own output without being circular). This is what proves
chain integrity at the single-receipt level: any bit of a persisted
receipt (its standing, its files, its metadata, any PRD v2 field)
changing after the fact changes this digest, exactly the same
tamper-evidence property hash_entries/1/hash_files/1 already give the
FILES a receipt describes, now given to the RECEIPT RECORD describing
them.
new/2 calls this automatically to populate receipt_hash unless the
caller explicitly supplied one (see new/2). Deterministic and
order-independent: Jason.encode!/1 is called on the map sorted by key,
so field-insertion order in the caller never affects the digest.
The receipts directory for consumer project base_dir: <base_dir>/.ggen_igniter/receipts.
A "sha256:" <> hex digest over entries -- a list of
{path, content_or_nil} pairs, nil meaning the path was absent. Sorted
by path first so the digest is order-independent (the same file set
always hashes the same way regardless of enumeration order). This is the
primitive both hash_files/1 (reads current disk state) and
GgenIgniter.Reactors.ReconcileReactor (hashing an already-captured
pre-image, without re-reading disk) build on.
A "sha256:" <> hex digest over the REAL, CURRENT on-disk content of
paths (missing files hash as :absent, matching hash_entries/1). This
is the real "project hash" (scoped to the exact files one attempt
touches, not a whole-repository hash) the user's compensation narrative
refers to as "resulting project hash".
Builds a new receipt struct. attrs is a plain map/keyword list; :standing
is required and MUST be one of standings/0 (raises ArgumentError
otherwise -- a receipt with an invented standing is a real correctness bug,
refused loudly rather than silently persisted). :id is generated if not
given; :started_at/:finished_at default to DateTime.utc_now/0
(ISO8601-encoded) if not given.
Same as new/1, plus an opts keyword list for PRD v2 chain-linking.
opts[:base_dir], when given together with a :recipe_key in attrs and
no explicit :parent_hash in attrs, auto-derives parent_hash by
reusing reconstruct_standing/2's own on-disk chain walk: the LAST
receipt already persisted for this recipe_key (if any) has its
receipt_hash copied into the new receipt's parent_hash, giving the
same append-only, disk-verified chain-of-custody reconstruct_standing/2
already checks for pre_run_hash/post_run_hash, extended to the whole
receipt content. No prior receipt ({:error, :no_receipts}, or the chain
itself is broken) leaves parent_hash nil -- the honest "first/rootless
receipt in this chain" state, never a raised error, since new/2 builds a
struct and must not fail just because history-lookup found nothing.
tool_version defaults to this application's own Mix.Project version
(tool_version/0) unless explicitly given. schema_version defaults to
"1" (already the struct default) unless explicitly given.
receipt_hash is always (re)computed by compute_receipt_hash/1 over the
fully-built struct UNLESS the caller explicitly supplied :receipt_hash
in attrs (e.g. a receipt round-tripped from JSON that already carries
its original hash) -- see compute_receipt_hash/1 for exactly what the
hash covers.
@spec path(String.t(), DateTime.t()) :: String.t()
The date-partitioned JSONL path a receipt stamped at (default: now) is
appended to -- <base_dir>/.ggen_igniter/receipts/<yyyy-mm-dd>.jsonl. Date
partitioning keeps any single file bounded (an append-only log with no
rotation would grow forever) while read_all!/1 transparently reads every
partition back in order.
Reads every receipt line back from EVERY .jsonl partition under
dir(base_dir), in file-then-line order (oldest date partition first;
each partition is itself append-ordered, so this is the real
chronological attempt history). Returns [] (not an error) when no
receipts directory exists yet -- the honest "no attempts recorded yet"
state, matching GgenIgniter.Manifest.load/1's same "first run" honesty.
@spec reconstruct_standing(String.t(), String.t()) :: {:ok, %{standing: standing(), receipt: map(), receipt_count: pos_integer()}} | {:error, :no_receipts} | {:error, {:chain_broken, map()}}
Reconstructs the CURRENT standing for one recipe (recipe_key) by
re-reading the real, on-disk receipt chain under base_dir -- ZERO
in-process state is consulted. This simulates exactly what a genuinely
fresh BEAM process (no live GgenIgniter.Controller, no prior GenServer
state -- e.g. after a real restart) would see: GgenIgniter.Controller's
reconciliation_count/last_run_at are real, process-only knowledge
(see that module's moduledoc) that this function makes NO attempt to
recover; what IS durable, and what this function reconstructs, is the
chain of admitted attempts and the standing the last one left behind.
recipe_key is the SAME (template_path, out_template) identity
GgenIgniter.Manifest.recipe_key/2 builds and every GgenIgniter.Receipt
persists verbatim in its "recipe_key" field (see
GgenIgniter.Reactors.ReconcileReactor's render_target/2) -- the real,
durable identity a receipt chain is keyed by on disk, in contrast to
GgenIgniter.Controller's pack_key, which is an arbitrary, PURELY
in-process caller-supplied term with no on-disk representation at all.
read_all!/1's full chronological history is filtered down to
recipe_key's own receipts, then walked in order verifying REAL chain
continuity: receipt N's pre_run_hash must equal the most recent prior
receipt's post_run_hash whenever both sides recorded a hash. A
:refused receipt records neither hash (nothing was ever actuated), so
it is skipped as a continuity checkpoint -- it neither breaks nor extends
the chain, it is simply not evidence about file content either way. A
receipt whose pre_run_hash does NOT match the expected prior hash is
real, located evidence that the on-disk history being trusted here does
not check out -- either the stored receipt lines were tampered with, or
some other, out-of-band write touched this recipe's target between two
admitted attempts.
Returns:
{:ok, %{standing: standing(), receipt: map(), receipt_count: pos_integer()}}-- chain verified intact end to end;standing/receiptdescribe the LAST receipt forrecipe_key.{:error, :no_receipts}--recipe_keyhas no receipts at all underbase_dir(the honest "nothing to reconstruct" case, matchingread_all!/1's own "no directory yet" honesty).{:error, {:chain_broken, %{at_index: non_neg_integer(), receipt_id: String.t(), expected_pre_run_hash: String.t() | nil, actual_pre_run_hash: String.t()}}}-- a real, located break:at_indexis the 0-based position (withinrecipe_key's OWN filtered history, not the whole-directory history) of the FIRST receipt whosepre_run_hashfails to match the expected prior hash.
@spec standings() :: [standing(), ...]
The five real, closed-set standing atoms a receipt may carry.
Converts a receipt struct to its plain, Jason-encodable, string-keyed map form.
Maps a receipt's real standing (standing/0) onto the PRD's status
vocabulary (this repo's own no-overclaiming floor:
ALIVE/PARTIAL_ALIVE/BLOCKED/BUILD_BROKEN/UNSUPPORTED/UNKNOWN), so a
PRD-facing report can cite one receipt's standing without re-deriving
the mapping ad hoc at every call site.
:alive->"ALIVE"-- the attempt fully succeeded.:refused->"BLOCKED"-- nothing was actuated; a real precondition or guard is what's blocking, not a code defect.:compensated->"PARTIAL_ALIVE"-- files were written, then restored after a (non-build) verification failure; the recipe partially executed before self-healing.:build_broken->"BUILD_BROKEN"-- generated content itself did not compile/parse.:compensation_failed->"PARTIAL_ALIVE"-- like:compensated, real actuation partially occurred before failing; PRD status vocabulary has no distinct "compensation itself also failed" category, so this maps to the same PARTIAL_ALIVE bucket as:compensated(both are "some real, incomplete progress occurred"), whilereceipt.metadata/receipt.reasonretain the actual catastrophic detail this collapsed status label does not carry.- Any other value (should be impossible given
new/1's closed-set guard, butto_prd_status/1accepts a bare atom, not just at(), so a hand-built/decoded atom outsidestandings/0is possible) ->"UNKNOWN"-- the honest fallback, never a raised error and never a silently wrong guess.
"UNSUPPORTED" is a real PRD status value with no standing/0
equivalent in this module today (no receipt is ever produced for an
operation this pipeline doesn't support at all -- that's refused earlier,
before any receipt-worthy attempt exists) and is therefore never returned
by this mapping; it is listed above only because the PRD names it as part
of the shared vocabulary this function's return values are drawn from.
@spec tool_version() :: String.t()
This application's own release version (Mix.Project.config()[:version]),
used as t/0's tool_version default so every receipt records exactly
which build of ggen_igniter produced it.