The RECONCILIATION MANIFEST: turns mix ggen_igniter.sync from a stateless
generator into a stateful reconciler that knows what it previously wrote,
so a rename/removal in the ontology produces a mechanically DETECTABLE
stale output instead of a silently orphaned file on disk.
Written to <base_dir>/.ggen_igniter/manifest.json -- base_dir is the
CONSUMER project's own directory (whatever directory mix ggen_igniter.sync
is actually invoked from, i.e. File.cwd!() by default, overridable via
--manifest-dir -- see Mix.Tasks.GgenIgniter.Sync), never this
(ggen_igniter) repo's own tree.
Why this gap is real (grounded in the actual code, not invented)
Before this module, nothing in lib/mix/tasks/ggen_igniter.sync.ex or
lib/ggen_igniter/actuate.ex ever recorded what a PRIOR sync run wrote:
Actuate.write_file!/3/inject_content!/5/eval_code!/2 only ever look at
the ONE path a CURRENT run's --out/to:/--for-each resolves to. A
resource rename (ontology_v9_rename_resource.ttl's Ticket -> Case) or
removal (ontology_v10_remove_resource.ttl) produces a clean NEW/updated
file but leaves the OLD file (ticket.ex) sitting on disk untouched --
reproduced directly by
test/ggen_igniter_destructive_change_agent3_test.exs's cases 7/8 and
documented as a real, disclosed gap in .ggen_igniter_factory/ADVERSARIAL.md
("MUST FIX #3: Destructive ontology evolution: no orphan-file reconciliation
on rename or removal").
The manifest's key: (template, out_template) -- a "recipe" identity, NOT
ontology/pack/timestamp
Each manifest entry is keyed by recipe_key/2: the RESOLVED --template
path plus the RAW (unrendered) --out/to: string, e.g.
"test/fixtures/ash-lifecycle-pack/templates/resource.ex.eex=>lib/support_desk/support/<%= String.downcase(resource_name) %>.ex".
This is deliberately NOT keyed by ontology path, pack name alone, or a per-run timestamp:
- Not ontology path -- a real developer re-syncs the SAME
--ontology ontology.ttlrepeatedly as they edit its CONTENT in place; the ontology PATH stays constant across a rename. (The fixture pack's ownontology_v9_rename_resource.ttl/ontology.ttlpair are two DIFFERENT files only because this is a test fixture simulating "before" and "after" states without mutating one file in place --real usage has one evolvingontology.ttl.) Keying by ontology path would make the SAME recipe (same template, same--out) against the SAME evolving ontology register as a brand-new, unrelated key on every content edit, defeating reconciliation entirely. - Not pack name alone -- a plain
--ontology/--template/--outinvocation (no--pack/--pack-dirat all) is fully supported by this whole pipeline (seeMix.Tasks.GgenIgniter.Sync's own moduledoc examples) and has no pack name to key by.(template, out_template)is the one identity present in EVERY invocation shape (pack-based or not). - The (template, out_template) pair IS the stable "recipe" across a
rename/removal:
--for-each's per-rowout_templatestring ("lib/support_desk/support/<%= String.downcase(resource_name) %>.ex") does not itself change when a row'sresource_namevalue changes -- only the RENDERED path per row changes, which is exactly the thing reconciliation needs to diff old-vs-new.
pack_dir (when resolvable) is recorded in each entry as informational
metadata only -- never part of the key -- so a human reading the manifest
can see which pack produced it without it affecting reconciliation
identity.
What is (and is NOT) tracked
Only mode: file outputs actuated via Actuate.write_file!/3 (full
ownership: this pack creates AND can safely recreate/delete the file) are
ever recorded here. Deliberately excluded:
mode: eval-- nothing is ever written to disk under this mode (Actuate.eval_code!/2), so there is nothing to reconcile.inject: truetargets --Actuate.inject_content!/5requires (and NEVER creates) a pre-existing file this pack does not own; it only splices a fragment into someone else's file. Treating an inject target as "manufactured by this pack" would let--on-stale prunedelete a file this pack never created in the first place -- a real destructive- action risk this module refuses to take on.Mix.Tasks.GgenIgniter.Syncgates reconciliation toinject_spec == nilfor exactly this reason.
Schema versioning (schema_version) and corruption handling
Alongside the pre-existing integer "version" field (kept, unchanged, for
backward compat with every prior manifest.json and every existing reader
of it -- see test/ggen_igniter_reconciliation_manifest_test.exs's
manifest["version"] == 1 assertion), every manifest this module WRITES
now also carries a string "schema_version" field (currently "1"),
the real migration/compatibility signal this module checks on load/1:
- Absent
schema_version(every manifest.json written before this change) is treated as"1"for backward compatibility -- AR-9: an old manifest with noschema_versionkey is NOT a corrupt manifest and is NOT silently reinterpreted as some other version; it is explicitly the same schema"1"this module already understands, so it loads and reconciles exactly as before. schema_version == "1"(present or defaulted) loads normally.- Any other
schema_version(e.g."2", written by some future version of this module this code has never been taught to read) is refused outright with a clear, named error rather than being loaded and silently misread as schema"1"-- a manifest written by newer code may have added/renamed/repurposed fields this code has no knowledge of, and guessing would risk corrupting real reconciliation state. - Invalid JSON, or valid JSON missing the required
"entries"map is refused as a corrupt manifest -- same refusal class as an unknown future schema version, distinguished only bydetail.
load_safe/1 exposes this as a real {:ok, manifest} | {:error, :corrupt_manifest, detail} result for callers that want to handle a bad
manifest without an exception; load/1 (unchanged call signature, used
by every existing caller in this codebase) is a thin wrapper that raises
ArgumentError with detail as the message on the error branch --
preserving every existing caller's raise-on-corruption contract while
giving new callers a non-raising path.
Format
{
"version": 1,
"schema_version": "1",
"entries": {
"<template_path>=><out_template>": {
"template": "test/fixtures/ash-lifecycle-pack/templates/resource.ex.eex",
"out_template": "lib/support_desk/support/<%= String.downcase(resource_name) %>.ex",
"pack_dir": "test/fixtures/ash-lifecycle-pack",
"updated_at": "2026-08-27T12:34:56.789012Z",
"outputs": {
"lib/support_desk/support/ticket.ex": "sha256:<hex>",
"lib/support_desk/support/customer.ex": "sha256:<hex>"
}
}
}
}outputs maps every real path this recipe wrote on its MOST RECENT
successful run to a "sha256:" <> hex digest of the real content
Actuate.write_file!/3 wrote there (computed by re-reading the file back
off disk after the write, not derived from the in-memory rendered string --
a real content hash of what is actually on disk).
outputs' keys: real canonical identity, not a raw path string
This module's own functions (output_paths/1, stale_paths/2,
build_entry/4) treat outputs' keys as opaque strings -- they never
parse, expand, or otherwise interpret a path themselves. The CALLER
decides what identity those keys represent. GgenIgniter.Reactors. ReconcileReactor (commit_recipe/5, finalize_evidence/1) builds every
outputs map it persists from GgenIgniter.ArtifactIdentity.canonicalize/2's
real result for each output (via GgenIgniter.PendingActuation's
canonical_target field), NEVER the raw, un-normalized path string --
closing the real, confirmed adversarial finding in
.ggen_igniter_factory/redteam-concurrency-nondeterminism.md (two
differently-spelled aliases of the SAME real output previously compared
as different manifest keys). stale_paths/2's new_paths argument is
likewise always the current run's own canonical identities from that same
caller, so old-vs-new comparisons stay apples-to-apples.
Summary
Types
One recipe's manifest entry, JSON-decoded (string keys, matching Jason's default map shape).
The whole manifest file, JSON-decoded.
Functions
Builds a fresh entry for recipe_key/2's (template_path, out_template) pair, stamped with the current UTC time.
The current schema version this module writes and understands. See moduledoc's "Schema versioning" section.
Looks up one recipe's entry in a loaded manifest, or nil if this key has never been recorded.
A "sha256:" <> hex digest of real binary content -- the real, written-to-disk bytes, not the in-memory rendered string.
Loads the manifest at path(base_dir).
Non-raising equivalent of load/1: {:ok, manifest} on success (including
the honest "no manifest file yet" first-run state), or {:error, :corrupt_manifest, detail} (a human-readable binary) when the file exists
but is invalid JSON, has an unexpected shape (missing/non-map "entries"),
or declares an unsupported future "schema_version".
The set of output paths a manifest entry last recorded (empty for nil, i.e. no prior entry).
The manifest's on-disk path for a given consumer-project base_dir: <base_dir>/.ggen_igniter/manifest.json.
Atomically persists manifest to path(base_dir): writes the real JSON to
a sibling temp file first, then File.rename!/2s it into place (an atomic
rename on the same POSIX filesystem) -- a crash/failure mid-write can never
leave a half-written, corrupt manifest.json behind; the prior file (the
last KNOWN-GOOD state) survives untouched until the new content is fully
flushed to disk under a different name.
Really deletes each path in paths (File.rm/1 for real -- this is the
--on-stale prune policy's actual destructive action). Returns
[{path, :pruned | :absent}] -- :absent when the path was already gone
(not an error; nothing left to prune). Any OTHER File.rm/1 failure (e.g.
a permissions error) raises RuntimeError naming the exact path and reason
rather than silently continuing past a real, unexpected filesystem
failure.
Returns a new manifest map with entry stored under key (every other entry untouched).
The stable "recipe" key for one (template, out_template) pair -- see this
module's moduledoc for why this pair (and not ontology path or pack name
alone) is the real reconciliation identity.
Whether outputs (a fresh %{path => hash} map for this run) is
IDENTICAL to what entry already recorded -- the real test for "nothing
changed, don't touch the manifest file at all" (so a no-op re-run leaves
the manifest file byte-for-byte unchanged, not merely logically
equivalent-with-a-bumped-timestamp).
stale = old_paths - new_paths: paths the entry previously recorded that
are NOT among new_paths (this run's real, freshly-rendered output-path
set). new_paths may be any Enum.t() (converted to a MapSet here).
Types
Functions
@spec build_entry(String.t(), String.t(), String.t() | nil, %{ required(String.t()) => String.t() }) :: entry()
Builds a fresh entry for recipe_key/2's (template_path, out_template) pair, stamped with the current UTC time.
@spec current_schema_version() :: String.t()
The current schema version this module writes and understands. See moduledoc's "Schema versioning" section.
Looks up one recipe's entry in a loaded manifest, or nil if this key has never been recorded.
A "sha256:" <> hex digest of real binary content -- the real, written-to-disk bytes, not the in-memory rendered string.
Loads the manifest at path(base_dir).
Returns a fresh %{"version" => 1, "schema_version" => "1", "entries" => %{}} when no manifest file exists yet (the real, honest "first run" state
-- not an error). Raises a clear ArgumentError (message == detail from
load_safe/1's {:error, :corrupt_manifest, detail}) if the file exists
but is not valid JSON, is valid JSON with an unexpected shape (missing/
non-map "entries"), or carries an unsupported future "schema_version"
-- a corrupt or unrecognized-future manifest is a real data-integrity
problem this module refuses to silently paper over by pretending there is
no prior state, or by guessing at an unknown future shape (that would
silently defeat reconciliation for exactly the runs that need it most).
See load_safe/1 for the non-raising equivalent.
Non-raising equivalent of load/1: {:ok, manifest} on success (including
the honest "no manifest file yet" first-run state), or {:error, :corrupt_manifest, detail} (a human-readable binary) when the file exists
but is invalid JSON, has an unexpected shape (missing/non-map "entries"),
or declares an unsupported future "schema_version".
An ABSENT "schema_version" key (every manifest.json written before this
field existed) is treated as "1" -- not an error, not a guess at some
other version -- per this module's moduledoc "Schema versioning" section
(AR-9 backward-compat rule).
The set of output paths a manifest entry last recorded (empty for nil, i.e. no prior entry).
The manifest's on-disk path for a given consumer-project base_dir: <base_dir>/.ggen_igniter/manifest.json.
Atomically persists manifest to path(base_dir): writes the real JSON to
a sibling temp file first, then File.rename!/2s it into place (an atomic
rename on the same POSIX filesystem) -- a crash/failure mid-write can never
leave a half-written, corrupt manifest.json behind; the prior file (the
last KNOWN-GOOD state) survives untouched until the new content is fully
flushed to disk under a different name.
Callers decide WHETHER to call this at all (see same_outputs?/2) -- this
function itself unconditionally writes when called, matching
Mix.Tasks.GgenIgniter.Sync's own "only persist after this run's own
actuation fully succeeds" partial-run-safety requirement (a raised
exception mid-run never reaches this call).
Really deletes each path in paths (File.rm/1 for real -- this is the
--on-stale prune policy's actual destructive action). Returns
[{path, :pruned | :absent}] -- :absent when the path was already gone
(not an error; nothing left to prune). Any OTHER File.rm/1 failure (e.g.
a permissions error) raises RuntimeError naming the exact path and reason
rather than silently continuing past a real, unexpected filesystem
failure.
Returns a new manifest map with entry stored under key (every other entry untouched).
The stable "recipe" key for one (template, out_template) pair -- see this
module's moduledoc for why this pair (and not ontology path or pack name
alone) is the real reconciliation identity.
Whether outputs (a fresh %{path => hash} map for this run) is
IDENTICAL to what entry already recorded -- the real test for "nothing
changed, don't touch the manifest file at all" (so a no-op re-run leaves
the manifest file byte-for-byte unchanged, not merely logically
equivalent-with-a-bumped-timestamp).
stale = old_paths - new_paths: paths the entry previously recorded that
are NOT among new_paths (this run's real, freshly-rendered output-path
set). new_paths may be any Enum.t() (converted to a MapSet here).