The reconciliation pipeline's real intermediate representation: what the
:render/:plan phase of GgenIgniter.Reactors.ReconcileReactor actually
produces is NOT a bare rendered-text string -- it is a %PendingActuation{}
per intended output, describing the FULL intended delta (identity, prior
vs. desired content hash, the exact operation, ownership, provenance, and
how to revert it) so the :admit step can reason about (and refuse) the
entire planned change set BEFORE a single byte is written, and the
:actuate step can execute each item by its operation field directly
instead of re-deriving "create vs. replace vs. delete" from scratch.
This mirrors the real Rust ggen's own PendingWrite/SyncReport IR
(~/ggen/crates/ggen-engine/src/sync.rs:167-171): the render phase there
is likewise a real deferred-actuation planner, not merely a text
templater. Same discipline, ported to this pipeline's own vocabulary
(GgenIgniter.Actuate's real outcome() values: :written/:unchanged/
:skipped_exists/:skipped_match/:injected, and
GgenIgniter.Manifest's real (template, out_template) recipe/ownership
model).
canonical_target -- the real artifact identity, not just the raw string
GgenIgniter.ArtifactIdentity.canonicalize/2 (base_dir + the raw
target string) is what this module's own real, confirmed adversarial
finding (.ggen_igniter_factory/redteam-concurrency-nondeterminism.md)
requires: two target strings that are lexically different (a redundant
/./ segment, ..-traversal, a relative-vs-absolute spelling, a
symlinked alias) but resolve to the SAME real on-disk location used to
silently defeat :admit's duplicate-output-path guard, which used to
group pending writes by the raw target STRING alone. Every real
planned actuation now carries its OWN real canonical identity alongside
the raw target it was built from -- :admit's guard
(GgenIgniter.Reactors.ReconcileReactor.admit_pending/2) groups by
canonical_target, never target, and this same identity is what
GgenIgniter.Manifest's outputs keys and GgenIgniter.Receipt's
files entries are built from downstream (see those modules' call
sites in ReconcileReactor). target itself is UNCHANGED and remains
the literal string every real File.read!/write!/exists? call actually
uses -- canonical_target is purely an identity/comparison value,
never substituted for target in real I/O.
Fields (conceptual IR -- what :admit reasons about)
logical_id-- a stable identity for this ONE intended output across runs. Composed by reusingGgenIgniter.Manifest.recipe_key/2(the(template_path, out_template)"recipe" identity Manifest already uses) plus the run's own resolvedtargetpath -- seelogical_id/3. For the SAME row across successive runs (the common, unchanged-identity case), this is stable; a real ontology rename that changes the resolved path produces a genuinely newlogical_id, exactly mirroring the honest limitGgenIgniter.Manifest's own moduledoc already discloses for itsoutputsmap (a rename is a new path, not a mutation of the old one).target-- the real resolved output path this run intends to create/replace/inject/delete.nilonly foroperation: :eval(perGgenIgniter.Manifest's own moduledoc: nothing is ever written to disk undermode: eval, so there is no path to reconcile).canonical_target--GgenIgniter.ArtifactIdentity.canonicalize/2's real result fortarget(resolved against this plan'sbase_dir), ornilunder the exact same conditiontargetisnil(operation: :eval). See "canonical_target" above.previous_hash--GgenIgniter.Manifest.hash_content/1oftarget's CURRENT real on-disk content, orniliftargetdoes not exist (or isnil, i.e.:eval).desired_hash--GgenIgniter.Manifest.hash_content/1of the content this run wants attarget(the freshly-rendered/eval'd string), ornilforoperation: :delete(nothing is desired there any more).operation-- one of:create | :replace | :inject | :delete | :eval, DERIVED (never guessed) from real existence + the frontmatter/mode that produced this item -- seefor_file/7,for_inject/9, andfor_delete/5.:injectis built byfor_inject/9when amode: filetarget's frontmatter hasinject: true;GgenIgniter.Reactors.ReconcileReactor's render step constructs one per such target, and its:actuatestep dispatches it toGgenIgniter.Actuate.inject_content!/5(neverwrite_file!/3).ownership-- whether THIS pack's manifest entry currently (i.e. BEFORE this run's actuation) already liststarget's realcanonical_targetidentity as one of its own outputs, straight fromGgenIgniter.Manifest.output_paths/1-- never re-derived by guesswork, and compared by real canonical identity (never the rawtargetstring -- see "canonical_target" above) so a prior run's recorded output is recognized even when THIS run'stargetreaches the same real file via a differently-spelled alias. Alwaysfalseforoperation: :eval(never tracked, per Manifest's own documented exclusion) and alwaystruefor a realoperation: :deletestale-prune candidate (only paths a recipe's OWN manifest entry previously recorded are ever stale-prune candidates -- seeGgenIgniter.Manifest.stale_paths/2).semantic_source-- a plain map naming the real ontology/query/ template identity that produced this item (ontology_path,template_path,out_template,recipe_key, and, for:eval, the real EExbindingsthe eval'd code may reference -- seeGgenIgniter.Actuate.eval_code!/2).compensation_data-- exactly what:actuate's real revert logic needs to undo THIS ONE item:{:previous_content, bytes}(the real bytestargetheld before this run, re-writable verbatim) whentargetexisted, or the literal atom:did_not_existwhen it did not (revert = delete it back out).
One field beyond the conceptual list, disclosed here rather than smuggled in
desired_content (real binary()) also rides on this struct. The four
conceptual fields above (previous_hash/desired_hash/operation/
ownership) are what ADMISSION reasons about; but a hash is one-way --
:actuate cannot recover the actual bytes to write from desired_hash
alone, and re-rendering at actuate-time would be exactly the kind of
re-derivation this refactor exists to eliminate (:actuate must consume
the plan directly, per the architectural request this module implements).
So the real rendered/eval'd content this run wants travels with the item
it belongs to, same struct, same plan, admitted as one unit.
Summary
Functions
Builds the real %PendingActuation{} for a stale-output prune candidate
(--on-stale prune's real deletion target): target is a path THIS
recipe's manifest entry previously recorded but no longer produces this
run (GgenIgniter.Manifest.stale_paths/2), so ownership is always
true (only previously-owned paths are ever stale-prune candidates) and
desired_hash is nil (nothing is desired there any more).
previous_hash/compensation_data are read from target's REAL current
content, same as for_file/7.
Builds the real %PendingActuation{} for a mode: eval target: nothing
is ever written to disk (mirrors GgenIgniter.Actuate.eval_code!/2 and
GgenIgniter.Manifest's own documented exclusion of mode: eval from
reconciliation), so target/previous_hash are nil and ownership is
always false. desired_content is the real Elixir source about to be
evaluated; semantic_source should carry the real bindings keyword
list GgenIgniter.Actuate.eval_code!/2 needs at actuate time.
Builds the real %PendingActuation{} for a mode: file (whole-file,
non-inject) target: reads target's REAL current on-disk content (if
any) to compute previous_hash/compensation_data, hashes the real
desired_content, and DERIVES operation from real existence alone
(:create when target does not yet exist, :replace when it does --
regardless of whether the content actually differs; that is exactly what
lets an unchanged re-run still carry the intended :create/:replace
operation type while :actuate's real outcome comes back :unchanged).
Builds the real %PendingActuation{} for a mode: file target whose
frontmatter has inject: true: mirrors for_file/7's shape (reads
target's REAL current content for previous_hash/compensation_data,
hashes desired_content, canonicalizes target against base_dir), but
desired_content here is the real rendered INJECTION BODY (the snippet to
be spliced in), never the whole intended file -- :actuate's :inject-
typed dispatch calls GgenIgniter.Actuate.inject_content!/5 with this
exact body, marker, and insert_mode, which computes the real final
on-disk content itself (anchor resolution + splice), not this constructor.
The stable identity for one resolved output of one (template_path, out_template) recipe -- reuses GgenIgniter.Manifest.recipe_key/2 (the
SAME key Manifest itself uses to look up prior-run entries) so this
module never invents a second, parallel identity scheme.
Whether this item's plan already reflects "nothing will really change" --
previous_hash == desired_hash (and both non-nil, so a fresh :create
with nothing on disk yet is correctly NOT unchanged). Convenience for
callers/tests reasoning about the plan before :actuate runs; does not
itself touch disk.
Types
@type compensation_data() :: {:previous_content, binary()} | :did_not_exist
@type operation() :: :create | :replace | :inject | :delete | :eval
@type t() :: %GgenIgniter.PendingActuation{ canonical_target: String.t() | nil, compensation_data: compensation_data(), desired_content: binary() | nil, desired_hash: String.t() | nil, logical_id: String.t(), operation: operation(), ownership: boolean(), previous_hash: String.t() | nil, semantic_source: map(), target: String.t() | nil }
Functions
Builds the real %PendingActuation{} for a stale-output prune candidate
(--on-stale prune's real deletion target): target is a path THIS
recipe's manifest entry previously recorded but no longer produces this
run (GgenIgniter.Manifest.stale_paths/2), so ownership is always
true (only previously-owned paths are ever stale-prune candidates) and
desired_hash is nil (nothing is desired there any more).
previous_hash/compensation_data are read from target's REAL current
content, same as for_file/7.
base_dir is passed straight to GgenIgniter.ArtifactIdentity.canonicalize/2
to build the real canonical_target identity, same real primitive
for_file/7 uses -- re-canonicalized here rather than trusted verbatim,
so a target sourced from an OLDER manifest entry (written before this
identity primitive existed, potentially still a raw, non-canonical
string) is still resolved to a genuine canonical identity now.
Builds the real %PendingActuation{} for a mode: eval target: nothing
is ever written to disk (mirrors GgenIgniter.Actuate.eval_code!/2 and
GgenIgniter.Manifest's own documented exclusion of mode: eval from
reconciliation), so target/previous_hash are nil and ownership is
always false. desired_content is the real Elixir source about to be
evaluated; semantic_source should carry the real bindings keyword
list GgenIgniter.Actuate.eval_code!/2 needs at actuate time.
@spec for_file( String.t(), String.t(), binary(), String.t(), String.t(), GgenIgniter.Manifest.entry() | nil, map() ) :: t()
Builds the real %PendingActuation{} for a mode: file (whole-file,
non-inject) target: reads target's REAL current on-disk content (if
any) to compute previous_hash/compensation_data, hashes the real
desired_content, and DERIVES operation from real existence alone
(:create when target does not yet exist, :replace when it does --
regardless of whether the content actually differs; that is exactly what
lets an unchanged re-run still carry the intended :create/:replace
operation type while :actuate's real outcome comes back :unchanged).
base_dir is the authorized project root this plan is running against
(the same base_dir GgenIgniter.Manifest.load/1/persist!/2 use) --
passed straight to GgenIgniter.ArtifactIdentity.canonicalize/2 to build
the real canonical_target identity (see this module's moduledoc).
target itself is untouched by this and remains the literal string real
I/O below (File.exists?/1/File.read!/1) actually uses.
ownership is read straight from old_entry (the recipe's manifest entry
as it stood BEFORE this run), via GgenIgniter.Manifest.output_paths/1,
compared against the real canonical_target identity -- never the raw
target string.
@spec for_inject( String.t(), String.t(), binary(), String.t(), String.t(), GgenIgniter.Manifest.entry() | nil, map(), {String.t() | Regex.t() | nil, :before | :after | :at_line} ) :: t()
Builds the real %PendingActuation{} for a mode: file target whose
frontmatter has inject: true: mirrors for_file/7's shape (reads
target's REAL current content for previous_hash/compensation_data,
hashes desired_content, canonicalizes target against base_dir), but
desired_content here is the real rendered INJECTION BODY (the snippet to
be spliced in), never the whole intended file -- :actuate's :inject-
typed dispatch calls GgenIgniter.Actuate.inject_content!/5 with this
exact body, marker, and insert_mode, which computes the real final
on-disk content itself (anchor resolution + splice), not this constructor.
operation is always :inject (never derived from existence the way
for_file/7 derives :create/:replace -- inject_content!/5 itself is
the real fail-closed gate for "target must already exist", enforced at
:actuate time so this constructor never duplicates that check).
marker/insert_mode -- Actuate.inject_content!/5's own real
marker/insert_mode args, produced by
GgenIgniter.Injection.resolve_injection!/1 from the template's
before:/after:/at_line: frontmatter -- ride on semantic_source
(merged in here under :marker/:insert_mode) rather than as new struct
fields, so :actuate can dispatch Actuate.inject_content!/5 directly
without re-deriving them. Callers that also need at_line:'s numeric
line argument should put it under semantic_source[:insert_opts]
(Actuate.inject_content!/5's own opts keyword list) before calling.
marker/insert_mode are passed together as one {marker, insert_mode}
tuple (credo Refactor.FunctionArity: this constructor's real arity was
9, one over the configured max of 8; the two args are already merged as
one unit into semantic_source in this function's own body, so bundling
them at the call boundary too is a real shape match, not an arbitrary
options-map wrapper). The one real call site
(GgenIgniter.Reactors.ReconcileReactor.render_inject_target/8) is
updated accordingly -- confirmed via grep -rn "for_inject(" that no
other caller exists.
The stable identity for one resolved output of one (template_path, out_template) recipe -- reuses GgenIgniter.Manifest.recipe_key/2 (the
SAME key Manifest itself uses to look up prior-run entries) so this
module never invents a second, parallel identity scheme.
Whether this item's plan already reflects "nothing will really change" --
previous_hash == desired_hash (and both non-nil, so a fresh :create
with nothing on disk yet is correctly NOT unchanged). Convenience for
callers/tests reasoning about the plan before :actuate runs; does not
itself touch disk.