# `GgenIgniter.Manifest`
[🔗](https://github.com/seanchatmangpt/ggen_igniter/blob/v26.9.8/lib/ggen_igniter/manifest.ex#L1)

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.ttl` repeatedly as they edit its CONTENT in place;
    the ontology PATH stays constant across a rename. (The fixture pack's
    own `ontology_v9_rename_resource.ttl`/`ontology.ttl` pair 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 evolving `ontology.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`/`--out`
    invocation (no `--pack`/`--pack-dir` at all) is fully supported by this
    whole pipeline (see `Mix.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-row `out_template` string
    (`"lib/support_desk/support/<%= String.downcase(resource_name) %>.ex"`)
    does not itself change when a row's `resource_name` value 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: true` targets -- `Actuate.inject_content!/5` requires (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 prune` delete a
    file this pack never created in the first place -- a real destructive-
    action risk this module refuses to take on. `Mix.Tasks.GgenIgniter.Sync`
    gates reconciliation to `inject_spec == nil` for 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 no `schema_version` key 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 by `detail`.

`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.

# `entry`

```elixir
@type entry() :: %{optional(String.t()) =&gt; term(), required(String.t()) =&gt; term()}
```

One recipe's manifest entry, JSON-decoded (string keys, matching Jason's default map shape).

# `t`

```elixir
@type t() :: %{required(String.t()) =&gt; term()}
```

The whole manifest file, JSON-decoded.

# `build_entry`

```elixir
@spec build_entry(String.t(), String.t(), String.t() | nil, %{
  required(String.t()) =&gt; String.t()
}) ::
  entry()
```

Builds a fresh entry for `recipe_key/2`'s `(template_path, out_template)` pair, stamped with the current UTC time.

# `current_schema_version`

```elixir
@spec current_schema_version() :: String.t()
```

The current schema version this module writes and understands. See moduledoc's "Schema versioning" section.

# `get_entry`

```elixir
@spec get_entry(t(), String.t()) :: entry() | nil
```

Looks up one recipe's entry in a loaded manifest, or `nil` if this key has never been recorded.

# `hash_content`

```elixir
@spec hash_content(binary()) :: String.t()
```

A `"sha256:" <> hex` digest of real binary content -- the real, written-to-disk bytes, not the in-memory rendered string.

# `load`

```elixir
@spec load(String.t()) :: t()
```

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.

# `load_safe`

```elixir
@spec load_safe(String.t()) :: {:ok, t()} | {:error, :corrupt_manifest, String.t()}
```

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).

# `output_paths`

```elixir
@spec output_paths(entry() | nil) :: MapSet.t(String.t())
```

The set of output paths a manifest entry last recorded (empty for `nil`, i.e. no prior entry).

# `path`

```elixir
@spec path(String.t()) :: String.t()
```

The manifest's on-disk path for a given consumer-project `base_dir`: `<base_dir>/.ggen_igniter/manifest.json`.

# `persist!`

```elixir
@spec persist!(t(), String.t()) :: :ok
```

Atomically persists `manifest` to `path(base_dir)`: writes the real JSON to
a sibling temp file first, then `File.rename!/2`s 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).

# `prune!`

```elixir
@spec prune!([String.t()]) :: [{String.t(), :pruned | :absent}]
```

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.

# `put`

```elixir
@spec put(t(), String.t(), entry()) :: t()
```

Returns a new manifest map with `entry` stored under `key` (every other entry untouched).

# `recipe_key`

```elixir
@spec recipe_key(String.t(), String.t()) :: String.t()
```

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.

# `same_outputs?`

```elixir
@spec same_outputs?(entry() | nil, %{required(String.t()) =&gt; String.t()}) :: boolean()
```

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_paths`

```elixir
@spec stale_paths(entry() | nil, Enum.t()) :: MapSet.t(String.t())
```

`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).

---

*Consult [api-reference.md](api-reference.md) for complete listing*
