mix ggen_igniter.sync (ggen_igniter v26.9.8)

Copy Markdown View Source

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 from sparql to oxigraph.
  • A working Rust toolchain is required to compile this library at all, regardless of which --engine a consumer ever actually invokes at runtime -- lib/ggen_igniter/native/graph_nif.ex's use Rustler compiles native/ggen_graph_nif via a real cargo subprocess as part of that module's own compilation (confirmed by reading Rustler.__using__/1/Rustler.Compiler.compile_crate/3 in the rustler 0.38 hex package -- there is no separate mix compilers: entry gating this; it runs whenever graph_nif.ex itself is compiled). This requirement already existed before this default changed (that loader module has been unconditionally part of this library's lib/ since --engine oxigraph was first added as an opt-in engine) -- changing the default --engine string here is a runtime-only behavior change and adds no new compile-time requirement beyond what already existed. A consumer without cargo on $PATH already could not mix compile this 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.ex

Example (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.ex

Example (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.ex

Example (--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.ex

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

Functions

run(argv)

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 onto inject_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 real GgenIgniter.Frontmatter.MatchRule shape) is converted into inject_content!/5's String.t() | Regex.t() marker arg by this module's private match_spec_to_marker!/2:
    • matcher: "contains" (default) -- a plain string marker (case_sensitive: true, the default) or a case-insensitive unanchored Regex (case_sensitive: false).
    • matcher: "exact" -- a ^...$-anchored Regex (escaped pattern), with \s* padding on both sides instead of bare anchors when trim: true.
    • matcher: "regex" -- the pattern string compiled directly as a Regex (never escaped -- it IS a regex), case_sensitive: false adding the i flag. scope: "file", any occurrence: other than the default "first", and trim: true paired with a matcher other than "exact" have no equivalent in inject_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 number N (marker is not used; before/after must 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.ex

Execution 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 eval

With --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 real write_file!/3/inject_content!/5 call is SKIPPED entirely (treated as a failed precondition), outcome :sh_before_failed.
  • sh_after: fails AFTER a real :written/:injected outcome -- 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) -- if stale is non-empty, raises a clear ArgumentError naming 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 prune or --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 oxigraph

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