CLI entrypoint: mix ggen_igniter.sync --ontology path.ttl --query name=path.rq (repeatable) --template path.eex --out path.ex.
Wires Ontology.load!/1 -> Query.run/2 (once per --query) -> Render.render/2 ->
Actuate.write_new_file!/2 in sequence.
Each --query name=path.rq result is bound in the EEx template under name as the
full list of result rows (string-keyed maps). Additionally, mirroring ggen's own
single-row-flattening convention (found live this session working with the Rust
ggen tool): a query whose result has EXACTLY ONE row also has that row's own
columns merged directly into the top-level bindings, atom-keyed, so a single-row
query like spec can be referenced as bare module_name/package_name instead of
hd(spec)["module_name"]. Later --query flags win on key collisions in the
flattened namespace -- pass queries in the order you want that tie-break to resolve.
Multi-row fan-out (--for-each NAME)
Mirroring the real Rust ggen's for_each: frontmatter field
(ggen-engine/src/template.rs's Frontmatter.for_each, ggen-engine/src/sync.rs's
ProjectionMode::Row fan-out): pass --for-each NAME where NAME is one of the
named --query results to render the template once PER ROW of that query, each
render getting that row's own columns merged into the top-level bindings (same
atom-keyed flattening convention as the existing single-row case -- so row-local
fields are bare module_name etc., not hd(modules)["module_name"]), and to write
each rendering to its own output file.
Because there is no longer one static output path, --out is itself rendered
through GgenIgniter.Render.render/2 using each row's bindings, so it must be an
EEx-renderable path template, e.g.:
mix ggen_igniter.sync \
--ontology test/fixtures/for_each_ontology.ttl \
--query modules=test/fixtures/modules.rq \
--for-each modules \
--template test/fixtures/for_each_module.ex.eex \
--out "lib/generated/<%= module_name %>.ex"With three rows in the modules query result (Multi.Alpha, Multi.Beta,
Multi.Gamma), this writes three separate files:
lib/generated/Multi.Alpha.ex, lib/generated/Multi.Beta.ex,
lib/generated/Multi.Gamma.ex.
If --for-each is not given, behavior is unchanged: --out is a single static
path, written once, with the existing single-row-flatten convention.
Engines
--engine oxigraph (default, since v26.8.27) runs every query in-process
against the loaded %RDF.Graph{} via a real, native oxigraph engine (a
Rustler NIF over ~/ggen/crates/ggen-graph-wasm's OxigraphEngine,
GgenIgniter.Query.Oxigraph.run/2) instead of the pure-Elixir sparql hex
package. This became the default because of a real, empirically confirmed
data-corruption bug in the previous default: GgenIgniter.Query.run/2 (the
sparql hex package, v0.3.12) does not correctly honor ORDER BY -- a
join-shaped query mirroring the real gate fixtures (?field ex:fieldOf ?entity ; ex:fieldOrder ?field_order . ?entity ex:entityStruct ?entity_struct . with ORDER BY ?field_order over 10 rows) came back in
reverse order ([9, 8, ..., 0] instead of the requested ascending [0, 1, ..., 9]) -- see GgenIgniter.Query's moduledoc for the full writeup. The
same query run through oxigraph (a real, independent, spec-conformant
SPARQL 1.1 engine) returned the correct ascending order. Silent row-order
reversal is a real corruption risk for any --for-each fan-out template
that assumes row order (e.g. numbering, positional joins), so the engine
that gets that right is now the one that runs unless --engine says
otherwise.
Two real, disclosed trade-offs from this default change, not silently accepted:
- Row-value shape differs from
sparql.--engine sparql's rows are plain unwrapped Elixir strings (RDF.IRI.to_string/1/RDF.Literal.value/1).--engine oxigraph's rows are the real, unprocessed N-Triples-style term strings oxigraph itself returns -- IRIs come back angle-bracket-wrapped (<https://example.org/...>) and literals come back quoted (and datatype/language-tagged when applicable, e.g."42"^^<http://www.w3.org/2001/XMLSchema#integer>), not bare values. A template that renders a query column directly (<%= module_name %>) will see this real shape difference if it switches fromsparqltooxigraph. - A working Rust toolchain is required to compile this library at
all, regardless of which
--enginea consumer ever actually invokes at runtime --lib/ggen_igniter/native/graph_nif.ex'suse Rustlercompilesnative/ggen_graph_nifvia a realcargosubprocess as part of that module's own compilation (confirmed by readingRustler.__using__/1/Rustler.Compiler.compile_crate/3in therustler0.38 hex package -- there is no separatemix compilers:entry gating this; it runs whenevergraph_nif.exitself is compiled). This requirement already existed before this default changed (that loader module has been unconditionally part of this library'slib/since--engine oxigraphwas first added as an opt-in engine) -- changing the default--enginestring here is a runtime-only behavior change and adds no new compile-time requirement beyond what already existed. A consumer withoutcargoon$PATHalready could notmix compilethis library before this change, opt-in or not.
--engine sparql runs every query in-process against the loaded
%RDF.Graph{} via GgenIgniter.Query.run/2 (the sparql hex package) --
still available, useful for a query shape known to depend on sparql
hex's specific (non-ORDER-BY) behavior, or to A/B a result against the new
default.
--engine qlever runs every query instead against a real, already-running
QLever SPARQL endpoint via GgenIgniter.Query.Qlever.run/2 (gno + real
HTTP, no in-process SPARQL evaluation). --ontology is then still read as a
%RDF.Graph{} (via the same Ontology.load!/1), but only to look up the
gnoa:Qlever-typed store resource named by --store-id -- the query text
itself never touches this graph's data, it runs on the remote QLever store.
--store-id is required when --engine qlever is given.
Comparison mode (--engine oxigraph,sparql / --engine all)
Per ADR-0008 (docs/architecture/adr/0008-evidence-ranked-multi-engine-registry.md):
--engine also accepts a comma-separated list (--engine oxigraph,sparql)
or the literal --engine all, parsed via GgenIgniter.EngineRegistry.resolve/2.
Resolving to more than one engine flips this task into comparison mode:
every named --query runs against EVERY resolved engine concurrently
(GgenIgniter.EngineRegistry.run_all/4), producing a
GgenIgniter.EngineComparisonReport.t() per named query. This is strictly
diagnostic-additive -- rendering/actuation still use only ONE primary
engine's rows (the first engine named, or oxigraph for --engine all),
so comparison mode changes no actuation, admission, receipt, or manifest
behavior at all.
--engine all expands to every engine GgenIgniter.Engine.valid_names/0
names whose preconditions are met: qlever is included only when
--store-id was given AND a real reachability probe against it succeeds
(mirroring mix ggen_igniter.doctor's check 8) -- otherwise it is silently
excluded with a logged warning, never included-then-errored.
--engine-report PATH writes the report(s) to disk -- .json (via
EngineComparisonReport.to_json/1) or anything else (Markdown, via
to_markdown/1), chosen by PATH's extension. Without --engine-report,
a compact summary (row count/elapsed time per engine, pairwise
row-set-agreement, per-engine errors) prints to stdout via
Mix.shell().info/1 after the run.
Example (default oxigraph engine)
mix ggen_igniter.sync \
--ontology test/fixtures/audit_trail_ontology.ttl \
--query spec=test/fixtures/spec.rq \
--template test/fixtures/extension.ex.eex \
--out tmp_out/probe.exExample (sparql engine)
mix ggen_igniter.sync \
--engine sparql \
--ontology test/fixtures/audit_trail_ontology.ttl \
--query spec=test/fixtures/spec.rq \
--query sections=test/fixtures/sections.rq \
--query entities=test/fixtures/entities.rq \
--query fields=test/fixtures/fields.rq \
--template test/fixtures/extension.ex.eex \
--out tmp_out/probe.exExample (qlever engine)
mix ggen_igniter.sync \
--engine qlever \
--ontology config/gno/test/store.ttl \
--store-id http://example.com/Qlever \
--query spec=priv/ggen/some-pack/gates/010.rq \
--template priv/ggen/some-pack/templates/out.ex.eex \
--out lib/generated.exExample (--pack convention)
Given priv/ggen/audit-trail-pack/{ontology.ttl,gates/*.rq,templates/extension.ex.eex}:
mix ggen_igniter.sync --pack audit-trail-pack --out lib/generated.ex--ontology/--query/--template are all still overridable explicitly; an
explicit flag always wins over the pack-derived default. --pack-dir DIR uses
DIR directly instead of resolving priv/ggen/<pack>/.
Example (--pack NAME:TEMPLATE -- selecting one of several templates)
A pack with more than one file under templates/ is normally ambiguous --
--pack NAME alone raises rather than guessing which one is "the"
template. Append :TEMPLATE_STEM (the template's filename up to its
first .) to --pack to select one explicitly, bypassing the ambiguity
error entirely for that pack:
mix ggen_igniter.sync --pack ash-lifecycle-pack:resource --out lib/generated/resource.exGiven priv/ggen/ash-lifecycle-pack/templates/{resource.ex.eex,domain.ex.eex},
this selects resource.ex.eex specifically (resource.ex.eex's stem is
"resource"); --pack ash-lifecycle-pack:domain selects domain.ex.eex
instead. Plain --pack ash-lifecycle-pack (no :TEMPLATE_STEM) keeps
today's behavior unchanged: auto-select when exactly one template exists,
raise the same "multiple templates found" error when there is more than
one. --pack-dir does not take this :TEMPLATE_STEM suffix -- pass
--template explicitly instead when using --pack-dir against a
multi-template pack.
Summary
Functions
Reads --ontology/--query(N)/--template/--out/--engine/--store-id/--pack(-dir) options and runs the pipeline.
Template frontmatter (hygen/ggen parity)
Mirroring real hygen and real Rust ggen's own ---\n...\n---\n header
convention (ggen-engine/src/template.rs's Frontmatter, mirrored 1:1 by
GgenIgniter.Frontmatter): if --template's file starts with a --- fence
on its first line, everything between that fence and the closing --- is
parsed as YAML frontmatter, and the remainder is the actual template body.
A template's own header supplies defaults for to (--out), for_each,
unless_exists, skip_if (literal-string form only), and sparql (named
queries given as inline query text, not file paths) -- so a self-contained
template can be rendered with just --template/--ontology, no repeated
--out/--for-each/--query flags, exactly like hygen generate <name>
needs no routing flags because the template's own header carries them.
Any explicit CLI flag always overrides the same-named frontmatter field.
--query name=path.rq and frontmatter sparql: inline queries can both be
present; an explicit --query with the same name overrides the
frontmatter's inline query text for that name. A template with no ---
header behaves exactly as before this feature existed -- every routing
option must then come from the CLI/pack convention.
Injection mode (inject: true)
Mirroring the real Rust ggen's own injection frontmatter fields
(ggen-engine/src/template.rs's Frontmatter.inject/before/after/
at_line, mirrored 1:1 by GgenIgniter.Frontmatter): when a mode: file
template's frontmatter has inject: true, the rendered body is spliced
into the resolved output path's EXISTING content via
GgenIgniter.Actuate.inject_content!/5, instead of being written whole via
GgenIgniter.Actuate.write_file!/3. Exactly one of before:, after:, or
at_line: must be set as the anchor -- zero or more than one raises a
clear ArgumentError (an ambiguous or missing anchor is a template-authoring
error, never a silent pick, never a best-effort partial match):
before: "marker"/after: "marker"-- a literal string frontmatter value maps directly ontoinject_content!/5's own literal-marker "contains" match; splices the rendered body immediately before/after the single matched line.before:/after:as a structured map (pattern:,matcher:,case_sensitive:,scope:,occurrence:,trim:-- the realGgenIgniter.Frontmatter.MatchRuleshape) is converted intoinject_content!/5'sString.t() | Regex.t()marker arg by this module's privatematch_spec_to_marker!/2:matcher: "contains"(default) -- a plain string marker (case_sensitive: true, the default) or a case-insensitive unanchoredRegex(case_sensitive: false).matcher: "exact"-- a^...$-anchoredRegex(escaped pattern), with\s*padding on both sides instead of bare anchors whentrim: true.matcher: "regex"-- the pattern string compiled directly as aRegex(never escaped -- it IS a regex),case_sensitive: falseadding theiflag.scope: "file", anyoccurrence:other than the default"first", andtrim: truepaired with amatcherother than"exact"have no equivalent ininject_content!/5's real anchor-resolution behavior (always-exactly-one-line, no whole-file mode, no pick-a-specific-occurrence logic) -- setting one of those raises a clear error naming the exact unsupported combination, rather than silently proceeding as if it had been honored.
at_line: N-- an anchor-free alternative: splices the rendered body at the 1-based line numberN(markeris not used;before/aftermust both be unset).
--dry-run previews an injection the same honest way it previews a write:
the real anchor-resolution and idempotency check both run for real (via
inject_content!/5's own :dry_run option), nothing is written, and the
notice line reads "planned: inject #{out_path}" (or "planned: skip #{out_path} (unchanged)" when the content is already spliced in at that
exact position). A real, non-dry-run injection reports "injected #{out_path}" on the first run and "unchanged (skipped, identical content): #{out_path}" on an idempotent re-run -- distinct from
write_file!/3's "wrote #{out_path}", since the two are different
actuation paths.
Injection always requires the target file to ALREADY exist (mirroring
inject_content!/5's own fail-closed gate) -- it is not a substitute for
file creation; a first-run template should not set inject: true against a
path nothing has created yet.
mix ggen_igniter.sync \
--ontology test/fixtures/audit_trail_ontology.ttl \
--query spec=test/fixtures/spec.rq \
--template test/fixtures/inject_before_marker.ex.eex \
--out lib/existing_module.exExecution mode (mode: eval)
mode: (frontmatter, or --mode on the CLI -- explicit CLI wins) selects
what happens to the rendered template body: mode: file (the default,
unchanged from every example above) writes it to disk via the write-safety
guards described elsewhere in this doc. mode: eval instead treats the
rendered body as real Elixir source and evaluates it in-process via
GgenIgniter.Actuate.eval_code!/2, using the exact same bindings the
template body renders with (so eval'd code can reference module_name,
a single-row query's flattened columns, a --for-each row's columns, etc.,
exactly like the template body itself can) -- nothing is ever written to
disk under this mode, so --out/to: is not required, and
--unless-exists/--skip-if are not applicable and are ignored.
mix ggen_igniter.sync \
--ontology test/fixtures/audit_trail_ontology.ttl \
--query spec=test/fixtures/spec.rq \
--template test/fixtures/eval_mode_module.exs.eex \
--mode evalWith --for-each, the eval'd body runs once per row (same fan-out as file
mode, just evaluated instead of written). --dry-run shows a "planned: evaluate ..." notice and does not actually evaluate anything.
This is a deliberate, disclosed arbitrary-code-execution capability:
ontology/RDF-driven query results become literally-executed Elixir code
under mode: eval. Threading an eval result into a later query/render/
write stage is not implemented in this pass -- each named query's results
and each eval's return value are independent of one another within a single
sync run.
sh_before:/sh_after: shell hooks (frontmatter-only, gated by --allow-sh)
Mirroring the real Rust ggen's own Frontmatter.sh_before/sh_after
fields (GgenIgniter.Frontmatter's own moduledoc, field-by-field
provenance): a template's frontmatter may declare a real shell command to
run before (sh_before:) and/or after (sh_after:) that row's real
write_file!/3/inject_content!/5 call, executed via
GgenIgniter.ShellHook.run/3 (System.cmd("sh", ["-c", cmd], cd: --manifest-dir/File.cwd!(), stderr_to_stdout: true), real timeout,
default 60s).
--allow-sh is required (default false) whenever ANY resolved
template's frontmatter sets sh_before:/sh_after: -- absent it, the
WHOLE run refuses before any actuation happens at all (fail-closed,
matching --on-stale refuse's own default posture), naming the exact
template and field(s) that triggered the refusal. This refusal is checked
BEFORE run_via_reactor/3's own Reactor dispatch AND before
run_pipeline!/3's own inline actuation loop -- both are genuinely
separate call paths (see the ## Reactor dispatch /
GgenIgniter.Reactors.ReconcileReactor sections), and
ReconcileReactor.run/1 independently re-checks the same
allow_sh/sh_before/sh_after combination for ITS OWN direct callers
(not only calls arriving through this task) -- see that module's
moduledoc.
DISCLOSED, INTENTIONAL LIMITATION (mirrors ADR-0006's disclosure
style for inject_content!/5's own scope, and the v26.8.30 CHANGELOG's
":run_queries concurrency: investigated, NOT changed" entry): a
sh_before:/sh_after: command's real side effects are NOT
integrated into GgenIgniter.PendingActuation's operation() type,
NOT inspected by :admit's guards (duplicate-path refusal,
path-escape refusal, unowned-delete refusal), and NOT tracked by
undo/4's compensation/revert machinery -- a template author declaring
sh_before:/sh_after: is trusted the same way this repo already
trusts a frontmatter to: path (an existing, accepted trust boundary,
not a new one). --allow-sh is the one new, deliberately small
admission-adjacent check this pass adds to mitigate the highest-severity
real finding here (a destructive command bypassing admission entirely) --
it is a single explicit opt-in flag, not a new operation-type/IR change.
Failure semantics differ from every other row-level failure in this
module. A nonzero exit or a real timeout from sh_before:/sh_after:
does NOT abort the whole run -- this is a genuinely new
failure-tolerance pattern for sync.ex (today, a raised exception from
any other row aborts the entire run). It produces a new per-row outcome
atom instead, extending the existing :written/:injected/:unchanged/
:skipped_exists/:skipped_match vocabulary:
sh_before:fails (nonzero exit or timeout) -- the row's realwrite_file!/3/inject_content!/5call is SKIPPED entirely (treated as a failed precondition), outcome:sh_before_failed.sh_after:fails AFTER a real:written/:injectedoutcome -- the write/inject already genuinely happened and is NOT reverted (no compensation exists for this, per the disclosed limitation above); outcome:sh_after_failed.
See outcome_summary_suffix/2/summary_bucket/1 for how these two new
atoms are counted and reported in the final run summary, alongside every
other outcome.
--dry-run previews a shell hook exactly like every other actuation
decision in this module: "planned: run sh_before: <cmd>" /
"planned: run sh_after: <cmd>" is printed, and GgenIgniter.ShellHook.run/3
is never called at all (the real subprocess never starts under
--dry-run, matching this whole module's "zero real side effects" dry-run
contract).
Every real sh_before:/sh_after: invocation (success, nonzero exit, or
timeout) is appended to GgenIgniter.Receipt.commands -- see that
module's moduledoc for the entry shape. sync.ex's inline pipeline does
not otherwise construct a GgenIgniter.Receipt at all (verified: no
Receipt.new/1/Receipt.append!/2 call existed anywhere in this file
before this feature); a minimal receipt (standing: :alive -- this
module's inline pipeline has no compensation/verification step of its own
to fail, so :alive here describes "an attempt was made and files were
actuated via the normal write-safety guards," not "every shell hook
succeeded" -- any hook failure is named explicitly in reason/commands
instead) is constructed and appended ONLY for a real (non---dry-run) run
that actually declared sh_before:/sh_after:, so a run with no shell
hooks at all produces no new receipt traffic.
Reconciliation manifest (stale-output detection, --on-stale)
Every real, disk-written mode: file output (whole-file write_file!/3
writes; NOT inject: true splices, NOT mode: eval) is recorded in a
RECONCILIATION MANIFEST at <manifest_dir>/.ggen_igniter/manifest.json
(manifest_dir defaults to File.cwd!() -- the consumer project's own
directory, i.e. wherever mix ggen_igniter.sync is actually invoked from;
override with --manifest-dir DIR), keyed by the (--template, --out/to:) "recipe" pair (GgenIgniter.Manifest.recipe_key/2 -- see that
module's moduledoc for the full, grounded reasoning for why THIS pair, and
not ontology path or pack name alone, is the real reconciliation identity).
Before writing anything, the manifest's EXISTING entry for this run's
recipe (if any) is read; this run's own real output-path set is computed
(every row's rendered --out, whether from --for-each fan-out or the
single static case); stale = old_paths - new_paths -- paths a PRIOR run
of this exact recipe wrote that this run does NOT write (the mechanical
signature of a rename or removal upstream in the ontology).
--on-stale (default refuse -- the safest default; silent orphaning
is never the default, and silent deletion is never the default either)
decides what happens when stale is non-empty:
refuse(default) -- ifstaleis non-empty, raises a clearArgumentErrornaming every exact stale path, BEFORE writing anything at all this run (not even the non-stale outputs) -- complete reconciliation or a refusal before any partial actuation, never a silent orphan. Fix by re-running with--on-stale pruneor--on-stale preserve.prune-- proceeds with this run's writes, then really deletes (File.rm/1) every stale path, reporting each real deletion ("pruned: PATH", or"pruned (already absent): PATH"if it was already gone).preserve-- proceeds with this run's writes, leaves every stale path untouched on disk, and prints a clear warning naming each one every time (they are also dropped from the manifest's tracked output set for this recipe -- this pack no longer claims ownership of a path it isn't producing this run).
The manifest is only ever persisted AFTER this run's own writes (and, for
prune, the real deletions) fully succeed -- a raised exception mid-run
(a failed write, a refuse refusal) never touches the manifest file, so it
always reflects the last KNOWN-GOOD run, never a partial one. A run whose
real output-path-plus-content-hash set is IDENTICAL to what the manifest
already recorded (a true no-op re-run) does not rewrite the manifest file
at all -- not even its timestamp.
--dry-run previews reconciliation the same honest way it previews every
other actuation: a refuse-triggering stale set still raises (a dry run
is a real preview of what WOULD happen, and "this run would be refused" is
exactly that); prune/preserve print "planned: prune PATH" /
"planned: preserve PATH" lines instead of touching disk; the manifest
file itself is never written under --dry-run.
mix ggen_igniter.sync --pack-dir priv/ggen/ash-lifecycle-pack \
--ontology priv/ggen/ash-lifecycle-pack/ontology.ttl \
--template priv/ggen/ash-lifecycle-pack/templates/resource.ex.eex \
--for-each resource \
--out "lib/support_desk/support/<%= String.downcase(resource_name) %>.ex" \
--on-stale prune--verify-cwd DIR (Reactor pipeline only, use_reactor: true)
When the opt-in Reactor pipeline is active (see "Reactor dispatch" below),
its terminal :verify step runs a REAL mix compile --warnings-as-errors
subprocess to confirm the just-actuated project still builds
(GgenIgniter.Reactors.ReconcileReactor's :verify step). That subprocess
needs a real Mix project directory (one containing mix.exs) to cd:
into. By default it uses --manifest-dir (falling back to File.cwd!())
for this -- correct whenever the reconciliation manifest and the actual
Mix project live in the same directory, which is the common case.
--verify-cwd DIR overrides just this one directory, independently of
--manifest-dir, for the one real scenario where the two differ: writing
actuated output into an ISOLATED directory (e.g. a throwaway tmp dir used
as --manifest-dir so the reconciliation manifest and path-escape
boundary don't touch the real project at all) while still wanting
:verify to run its mix compile against the REAL project root. Concrete
worked example -- generating into an isolated tmp dir, verifying against
this repo itself:
mkdir -p /tmp/ggen_verify_cwd_demo
mix ggen_igniter.sync \
--pack-dir priv/ggen/adr-index-pack \
--out /tmp/ggen_verify_cwd_demo/out.md \
--manifest-dir /tmp/ggen_verify_cwd_demo \
--verify-cwd /Users/sac/ggen_igniter \
--engine oxigraphWithout --verify-cwd in this exact scenario (--manifest-dir pointing
outside any Mix project), :verify's mix compile subprocess runs cd:
into that same non-project tmp dir, Mix itself raises ** (Mix) Could not find a Mix.Project..., and this task's :verify-failure path
(maybe_add_verify_cwd_hint/3 in ReconcileReactor) detects that exact
Mix error text and a nil --verify-cwd and prepends a concrete pointer at
this flag to the raised RuntimeError, rather than surfacing the bare Mix
crash text alone.
Controller delegation (opt-in, thin-adapter mode)
When a real GgenIgniter.Controller GenServer is already running,
registered under the name GgenIgniter.Controller (Process.whereis/1 --
the same registration idiom this codebase's own GgenIgniter.Engine.Qlever
already uses for GgenIgniter.Finch), THIS run's reconciliation work is
delegated to it (GgenIgniter.Controller.reconcile/3, wrapping the shared
GgenIgniter.Reconcile.run/1 pipeline) instead of running the pipeline
inline -- giving this one invocation access to the controller's real,
in-process reconciliation history (reconciliation_count, surfaced in the
notice line below) instead of a fresh, state-free OS process. When no such
process is registered (the common case today -- the controller is
opt-in and off by default), behavior is EXACTLY the pre-existing inline
pipeline, unchanged.
Delegation only ever applies to a call within GgenIgniter.Reconcile.run/1's
own deliberately bounded scope (see that module's moduledoc): the resolved
template must have NO frontmatter header at all, and --for-each must not
be requested (by flag or by frontmatter -- moot here since frontmatter is
required absent). Any call using frontmatter, --for-each, inject: true,
or mode: eval's frontmatter defaults, or a Controller.reconcile/3 whose
real arity/behavior no longer matches what this module was written against
(function_exported?/3, checked every call -- a live defensive guard, not a
one-time check, since this integration point was wired against a
concurrently-developed module), transparently falls back to the exact same
inline pipeline used when no controller is running at all -- never a silent
behavior change for a feature the controller's bounded pipeline does not
yet implement.
One real, disclosed trade-off of controller-mode delegation specifically:
the RECONCILIATION MANIFEST (--on-stale/manifest.json, described above)
is a property of the inline pipeline's own bookkeeping and is NOT consulted
or updated on the delegated path -- the controller's own in-process state
(keyed on {template_path, --out}) is the reconciliation record for that
call instead. This is intentional: controller mode exists precisely to
replace disk-based manifest tracking with in-process tracking for whichever
recipes it is enabled for, not to duplicate both.