ADR-0014: A RecipeEngine surfaces its produced Transform via a per-workflow TransformRegistry¶
Status: Accepted Date: 2026-05-20 Tags: registry · contract · open-closed · dependency-inversion · engine-layer-unblock Related: 0001, 0009, 0010, 0002
Context¶
S5-01 shipped the RecipeEngine Protocol GREEN on 2026-05-19 at src/codegenie/transforms/recipe_engine.py:67-91:
@runtime_checkable
class RecipeEngine(Protocol):
async def apply(
self, repo: SandboxedPath, plan: ApplicationPlan,
capability: NpmInstallCapability,
) -> RecipeOutcome: ...
apply returns a single RecipeOutcome discriminated-union value. Its Applied variant (outcomes.py:190-199) carries transform_id: TransformId — a BLAKE3-hex digest — and no Transform object. The Applied docstring and the Transform ABC docstring both name "the S1-04 Transform registry" as the lookup mechanism for the produced artifact, but no such registry was ever built — S1-04 shipped only the Transform ABC (transform.py).
This gap blocked the whole engine layer. The phase-story-validator's harden pass on S5-02 (NpmLockfileRecipeEngine) and S5-03 (OpenRewriteRecipeEngine) independently (a) strengthened an AC to require a mypy --strict RecipeEngine-Protocol-conformant apply, and (b) rewrote apply to return a 2-tuple (RecipeOutcome, Transform | None) so the engine could hand the Transform back to the orchestrator. Those two corrections contradict: tuple[RecipeOutcome, Transform | None] is not covariantly assignable to RecipeOutcome, so _engine: RecipeEngine = NpmLockfileRecipeEngine(...) fails mypy --strict. Both stories were marked BLOCKED on 2026-05-20 (_attempts/S5-02.md, _attempts/S5-03.md, cross-story _lessons.md #16/#17). No engine, no orchestrator, no end-to-end path can land until the question is answered: how does a RecipeEngine surface its produced Transform object?
The orchestrator genuinely needs the object: S6-04's Phase-5 wrap seam is async def _validate_stage6(transform, ctx) — it takes the Transform, not the id. Phase 5's GateContext.transform_output: Transform is typed against the ABC.
Options considered¶
- Option A — Change
RecipeEngine.applyto returntuple[RecipeOutcome, Transform | None]. Pattern: none — a return-shape widening. Rejected:RecipeEngineandRecipeOutcomeare the ADR-0001 Phase-5 frozen contract surface (ADR-0009 froze the Protocol). A tuple return is not assignable toRecipeOutcome; every_engine: RecipeEngine = ...site breaksmypy --strict. This is the S5-02/S5-03 contradiction — adopting it would re-freeze a self-inconsistent surface. - Option B — Add a
transform: Transformfield to theAppliedvariant. Pattern: richer sum-type variant. Rejected:Appliedis afrozen=True, extra="forbid"PydanticRecipeOutcomevariant frozen by ADR-0001;Transformis an ABC (deliberately not Pydantic — ADR-0001 §Tradeoffs, Phase-5 ADR-0006) withdiff_bytes: bytes, so it has no clean serialization into aRecipeOutcomethat flows toremediation-report.yamlon disk. The id-as-lookup-key was a deliberate progressive-disclosure choice (production design §"Progressive disclosure for context"). - Option C — Introduce a
TransformRegistry;applyreturns plainRecipeOutcome, the orchestrator looks theTransformup byApplied.transform_id. Pattern: Registry + Dependency inversion. The mechanism theApplied/Transformdocstrings already name; adds capability by addition (new module + new story) with zero edits to the ADR-0001/0009 frozen surface.
Decision¶
Adopt Option C. A new TransformRegistry (src/codegenie/transforms/transform_registry.py, shipped by story S5-01b) is the sanctioned channel by which a RecipeEngine surfaces its produced Transform to the orchestrator.
RecipeEngine.apply(...) -> RecipeOutcomeis unchanged — the ADR-0001/ADR-0009 frozen surface is preserved verbatim.- A
RecipeEngineis constructor-injected with aTransformRegistry. On a successful apply the engine callstransform_registry.register(transform)and returnsApplied(transform_id=transform.transform_id, ...). - The
RemediationOrchestrator(S6-04) constructs oneTransformRegistryper workflow run, injects it into every engine it builds, and afterapplyreturnsAppliedretrieves the object viatransform_registry.get(applied.transform_id)to feed_validate_stage6(transform, ctx). - The registry is keyed by
TransformId(= BLAKE3-hex ofdiff_bytes).registerraisesTransformAlreadyRegisteredon a duplicate id;getraisesTransformNotFoundon a miss — both typed markers subclassingCodegenieError, mirroringRecipeRegistry'sRecipeAlreadyRegistered/RecipeNotFound.
Per-workflow injection, not a process-wide default_* singleton. Unlike @register_probe / @register_recipe, which register at import time into a process-wide default_registry, Transforms are registered at runtime — once per apply call. A process-wide singleton would accumulate every workflow's transforms, leak across runs, and need garbage collection. A per-workflow TransformRegistry, created by the orchestrator and discarded when run() returns, has no global mutable state and no GC surface. The engine module (transforms/engines/*.py) therefore keeps its S5-02 AC-Surface-4 fence — "no module-level mutable state" — intact.
Not a Phase-5 contract symbol. The TransformRegistry is an internal orchestration mechanism, not one of ADR-0001's six named seams. It is not re-exported from codegenie/transforms/__init__.py and is not part of the S6-06 contract snapshot — consumers import it directly (from codegenie.transforms.transform_registry import TransformRegistry), mirroring the sandbox_jail.py / recipe_registry.py precedent for transforms/-resident, non-contract-surface modules.
Tradeoffs¶
| Gain | Cost |
|---|---|
The ADR-0001/0009 frozen RecipeEngine / RecipeOutcome / Applied surface is untouched — Phase 5 still wraps the surface it was promised |
One more object on the engine constructor (jail, transform_registry); S5-02/S5-03's __init__ widens from 1-arg to 2-arg |
Realizes the design intent already written into the Applied and Transform docstrings ("lookup into the S1-04 Transform registry") rather than inventing a parallel shape |
A two-step apply → get dance in the orchestrator instead of a one-line tuple destructure |
| Per-workflow scoping — no global mutable state, no cross-workflow leakage, no GC story; functional-shell-friendly | The orchestrator must thread one more object through engine construction |
| Unblocks S5-02 → S5-03 → S5-04 → S6- by addition* (new module + new story), the codebase's standing extension discipline | A new story (S5-01b) is inserted into Step 5, shifting the backlog count 43 → 44 |
Pattern fit¶
Implements the Registry pattern (toolkit §Registry pattern) — the sixth registry in the codebase, the first keyed by a content digest and the first scoped per-workflow rather than process-wide. Implements Dependency inversion (toolkit §Composition / coupling): the engine depends on the TransformRegistry abstraction passed to its constructor, never on a concrete orchestrator. Preserves Open/Closed at the contract boundary — the RecipeEngine Protocol is closed for modification; the new capability arrives as an added collaborator. The registration site is the engine's apply; the lookup site is the orchestrator — a clean functional-core / imperative-shell seam.
Consequences¶
- New module
src/codegenie/transforms/transform_registry.pyexposingTransformRegistry,TransformAlreadyRegistered,TransformNotFound— shipped by story S5-01b (S5-01b-transform-registry.md), slotted between S5-01 and S5-02 in the Step-5 dependency chain. - S5-02 and S5-03 are de-contradicted:
applyreverts to the as-built-> RecipeOutcome; the harden-pass 2-tuple correction is withdrawn;__init__widens to(jail, transform_registry); AC-Apply-1 / AC-Contract-1 assert the producedTransformis retrievable from the injected registry byApplied.transform_id. Both stories return fromBLOCKEDtoHARDENED. The DAG gainsS5-01bbetweenS5-01andS5-02/S5-03. - S6-04's
RemediationOrchestratorconstructs the per-workflowTransformRegistry, injects it into engines, and looks theTransformup afterapplyfor_validate_stage6. - The
TransformRegistryis not added tocodegenie/transforms/__init__.__all__and not snapshotted by S6-06 — it is not a Phase-5 contract symbol. - New invariant: a
RecipeEngineimplementation never returns aTransformobject across theapplyboundary; the only sanctioned channel istransform_registry.register(...)+ anApplied(transform_id=...)outcome.
Reversibility¶
Medium. The TransformRegistry is an internal mechanism — replacing it (e.g., if a future phase makes Transform Pydantic-serializable and prefers Option B) touches the engine constructors, the orchestrator's lookup, and story S5-01b, but not the ADR-0001 frozen surface and not any persisted artifact. No multi-phase coordination is required; the blast radius is the engine layer plus the orchestrator's apply_recipe node.
Evidence / sources¶
_attempts/S5-02.mdBlocker A — the root contradiction and the three-option analysis this ADR resolves._attempts/S5-03.mdBlocker A /_attempts/_lessons.md#16, #17 — the same contradiction across the engine cone; "the fix for the whole cone is one architecture decision, not N executor retries".src/codegenie/transforms/recipe_engine.py:67-91— the as-builtRecipeEngineProtocol (apply -> RecipeOutcome).src/codegenie/transforms/outcomes.py:190-199— theAppliedvariant carryingtransform_idand naming "the S1-04Transformregistry".src/codegenie/transforms/transform.py:64-96— theTransformABC whose docstring names the registry.src/codegenie/plugins/recipe_registry.py— the typed-error-marker +register/getregistry shape this module mirrors.- ADR-0001 §Decision / §Consequences — the six frozen Phase-5 symbols this decision preserves.
- ADR-0009 — the frozen
RecipeEngineProtocol.