ADR-0010: Domain-modeling discipline — PluginScope as Concrete | Wildcard sum type; newtype every domain identifier; tagged-union outcomes everywhere¶
Status: Accepted Date: 2026-05-17 Tags: typing · domain-modeling · sum-type · newtype · illegal-states-unrepresentable Related: 0003, 0009, production ADR-0033
Context¶
Production ADR-0033 commits the system to domain-modeling discipline: newtypes on every domain identifier, smart constructors on external-boundary parsers, tagged-union sum types for state machines, "make illegal states unrepresentable" everywhere it's not aesthetically expensive. Phase 3 is the first phase where this discipline lands across a plugin contract — so the choices made here ossify into every future plugin.
The best-practices lens design explicitly declined the sum type for PluginScope dimensions: it proposed PluginScope.task_class: NewType("TaskClass", str) | Literal["*"] with the rationale "YAML still writes *" (best-practices design §Open questions #1). The critic correctly attacked this in critique.md §Best-practices design — concrete problems: NewType("Language", str) | Literal["*"] collapses to str at runtime — the typechecker sees str and offers zero help; the resolver's if dim == "*" branch is back. ADR-0033 beats YAML aesthetics.
Similarly, the critic flagged missing newtypes on WorkflowId, BundleId, TransformId, EventId across all three lens designs (critique.md §Design-pattern critiques §Missed patterns). A WorkflowId ↔ BundleId swap at any call site is a runtime bug the type checker cannot catch when both are raw str.
And the critic attacked the best-practices' RecipeProtocol.applies(cve, ctx) -> bool (open Q #5): a boolean return for "does this recipe match?" cannot carry the plan that the engine needs to apply the recipe nor the reason the orchestrator needs to escalate — Applies(plan) | NotApplies(reason) sum type is the correct shape.
Options considered¶
- Option A —
PluginScope.task_class: strwith*as a magic string; rawstrforWorkflowId/BundleId/ etc.;boolreturns on state-machine methods (applies, etc.). Pattern: Stringly-typed identifiers + boolean flags on state machines — the toolkit's textbook anti-patterns. - Option B —
PluginScope.task_class: NewType("TaskClass", str) | Literal["*"]; newtypes on some identifiers;boolforapplies(); tagged unions for outcomes only. Best-practices' compromise. Looks safer; collapses tostrat runtime where the safety would matter. Pattern: Newtype, partially applied. - Option C —
PluginScope.task_class: ScopeDim = Concrete | Wildcard(true sum type); newtype every domain primitive (PluginId,RecipeId,WorkflowId,BundleId,EventId,TransformId,CveId,PackageId,BranchName,BlobDigest,RegistryUrl,SignalKind); tagged-union on every state machine (PluginResolution,RecipeOutcome,RemediationOutcome,TrustOutcome,AdapterConfidence,JailedSubprocessResult,Applicability); smart constructor on every external-boundary parser (PluginManifest.from_yaml,PluginScope.parse,CveRecord.parse_{nvd,ghsa,osv},BranchName.parse) returningResult[T, ParseError]. Pattern:** Domain-modeling discipline applied uniformly.
Decision¶
Adopt Option C. Phase 3 ships:
-
PluginScopedimensions as a true sum type:YAML serialization writes@dataclass(frozen=True, slots=True) class Concrete: value: str @dataclass(frozen=True, slots=True) class Wildcard: pass ScopeDim: TypeAlias = Concrete | Wildcard*and<concrete>strings via the smart constructor; runtime never seesstrmasquerading as a dim. -
Newtype every domain identifier under
src/codegenie/types/identifiers.py:PluginId,RecipeId,TransformId,WorkflowId,EventId,CveId,PackageId,BranchName,BlobDigest,RegistryUrl,SignalKind,PrimitiveName,TransformKind,AttemptNumber. -
Tagged-union sum types on every state machine:
PluginResolution,RecipeOutcome(Applied | Skipped | NotApplicable | Failed),RemediationOutcome(Validated | RequiresHumanReview | NotApplicable | Failed),TrustOutcome,AdapterConfidence(Trusted | Degraded(reason) | Unavailable(reason)),JailedSubprocessResult(Completed | TimedOut | OomKilled | NetworkDenied | DiskQuotaExceeded),Applicability(Applies(plan) | NotApplies(reason)),ScopeDim. Every dispatch site usesmatch+assert_never. -
Smart constructors on every external-boundary parser returning
Result[T, ParseError]:PluginManifest.from_yaml,PluginScope.parse,CveRecord.parse_{nvd,ghsa,osv},BranchName.parse(s)enforcing^[a-z0-9/_.-]+$,PackageId.parse.
Tradeoffs¶
| Gain | Cost |
|---|---|
WorkflowId ↔ BundleId swap is a mypy error at the call site; the type checker is doing real work |
~14 newtype declarations + smart constructors — boilerplate. Mitigated by central identifiers.py module |
ScopeDim = Concrete \| Wildcard makes "did the wildcard match?" a match discriminator, not an equality check on a magic string |
YAML readers / writers need a parse(s) smart constructor for the * / <concrete> lift — one helper, low cost |
Tagged unions on every outcome — RecipeOutcome.NotApplicable(reason=PEER_DEP_CONFLICT) carries the reason Phase 4 reads; boolean returns would lose the structured information |
match blocks at every dispatch site — verbose but exhaustive. assert_never catches missed variants at mypy time |
Smart constructors returning Result[T, ParseError] (per Phase 5 ADR-0006 convention) — callers handle parse errors at the boundary, not at every use site |
A small Result type / convention; not in stdlib. Pydantic's model_validate is the idiom for most cases |
Best-practices' open question #5 (RecipeProtocol.applies(...) -> bool) is resolved structurally: applies(cve, bundle) -> Applicability = Applies(plan) \| NotApplies(reason) |
Recipe authors must construct the plan eagerly even if it's discarded — acceptable cost; the plan is cheap |
extra="forbid" + frozen=True on every Pydantic model means any model drift fails CI at the contract layer |
Adding a field is an explicit ADR-worthy change; some changes that would be additive in a loose model become structural changes here. Treated as a feature, not a bug |
| Phase 5 and Phase 7 inherit the discipline mechanically — the patterns are uniform | First-time authors must learn the conventions; documentation in this ADR + design-patterns toolkit reference |
Pattern fit¶
Implements four toolkit patterns simultaneously:
- Newtype pattern (§Structural / typing patterns): every domain identifier wrapped — "swapping a
RepoIdfor aPRNumberbecause both arestr. Type checker can't help. Newtypes make this a compile-time error." - Tagged union / sum type for state (§Structural / typing patterns): every state machine modeled as a discriminated union; rejects "booleans for state" anti-pattern.
- Smart constructor (§Structural / typing patterns): external-boundary parsers return
Result[T, ParseError]; raw constructors private. - Make illegal states unrepresentable (§Structural / typing patterns):
ScopeDim = Concrete | Wildcardinstead ofstr | Literal["*"]— the impossible state ("*AND a concrete value") can't be constructed.
Consequences¶
src/codegenie/types/identifiers.pycentralizes every newtype; a fence test (tests/fence/test_no_raw_str_for_domain_ids.py) AST-walks for rawstrannotations on parameters named*_id/*_digest/*_kind/ etc. and fails CI.src/codegenie/plugins/scope.pyshipsScopeDim,Concrete,Wildcard,PluginScope.parse,PluginScope.matches,PluginScope.specificitywithmatch-based dispatch.tests/unit/plugins/test_scope.pyexercisesConcrete | Wildcardalgebra; property test onspecificitypartial order.- Every Pydantic model in
src/codegenie/{plugins,transforms}/usesmodel_config = ConfigDict(frozen=True, extra="forbid"). dict[str, Any]is banned undersrc/codegenie/{plugins,transforms}/andplugins/bytests/fence/test_no_any_in_plugin_surface.py(S1-05 — landed under the manifest nametest_no_any_in_plugin_surface.py, NOTtest_no_any_in_contract_layer.py).- Phase 4 / 5 / 6 / 7 plugins inherit the discipline — TCCM YAML readers go through smart constructors; recipe
apply()returnsRecipeOutcometagged unions; new identifiers go inidentifiers.py. TrustSignal.details: dict[str, str | int | bool | float]— primitives only; notdict[str, Any].
Reversibility¶
Low. Removing newtypes is mechanical (alias to str), but every callsite that benefited from the type-checker discrimination would silently degrade. Demoting tagged unions to boolean flags would lose the structured information consumers depend on (especially Phase 4 reading NotApplicable(reason)). The chosen discipline is hard to undo cleanly; that's the point.
Evidence / sources¶
../phase-arch-design.md §Component design C3+ §Data model, §Design patterns applied rows 4–5, §Patterns considered and deliberately rejected../final-design.md §Synthesis ledger rows "PluginScope wildcard encoding"(score 14/15), "RecipeProtocol.appliessignature" (score 15/15), §Pattern reconciliation rows (Newtype, Tagged union, Smart constructor, Make illegal states unrepresentable)../critique.md §Best-practices design — concrete problems(Literal["*"]collapse tostr), §Design-pattern critiques §Missed patterns (WorkflowId/BundleIdnewtype gap)- production ADR-0033 — domain modeling discipline
- design-patterns-toolkit.md §Newtype, §Tagged union, §Smart constructor, §Make illegal states unrepresentable
Amendments¶
2026-05-18 — AdapterConfidence canonical home + advisory Literal catalogs¶
Context. Trusted / Degraded / Unavailable plus an AdapterConfidence discriminated union were declared in two locations: src/codegenie/adapters/confidence.py (Phase 2, reason: str, 8 consumers including TCCM loader and the four adapter Protocols) and src/codegenie/transforms/outcomes.py (Phase 3, reason: Literal[DegradationReason] / Literal[UnavailabilityReason], 0 src consumers but 5 test files pinning the contract). The two reason taxonomies were empirically disjoint: Phase 2 callers emit "scip_unavailable", "tool_missing", "self_check", "scip_offline", "stale_scip_acceptable_for_self_check"; Phase 3's Literal set was "timeout" | "partial_results" | "rate_limited" / "binary_missing" | "io_error" | "unsupported_version". Zero overlap. The class-name collision was silent type drift: Phase 3's BundleBuilder (S3-04) was documented to consume transforms.outcomes.AdapterConfidence, while every existing adapter consumed the adapters.confidence version. Once S3-04 landed, the two paths would dispatch through different class objects.
Decision. Single canonical class hierarchy declared in codegenie.transforms.outcomes (kernel-tier, satisfies tests/unit/transforms/test_outcomes_purity.py allowlist). codegenie.adapters.confidence re-exports the same class objects — identity equality across both layers. Degraded.reason and Unavailable.reason widen from Literal[...] to str (Phase 2's empirically-shipped vocabulary is admitted).
DegradationReason and UnavailabilityReason survive as advisory orchestrator-domain Literal catalogs, NOT field types. The aliases continue to be exported and exercised by tests/unit/transforms/test_outcomes.py::test_reason_literal_sets_pinned. Orchestrator consumers (BundleBuilder S3-04 and any later gate) MAY validate degraded.reason in get_args(DegradationReason) and emit a degraded_with_unknown_reason audit event when an out-of-catalog reason arrives — this is the documented migration ramp toward strict reasons if a consumer later demonstrates the cost of free-form strings.
Tradeoffs. Eliminates the duplication bug before S3-04 ships; preserves all 8 Phase-2 consumers unchanged; preserves Phase 3's S1-03 test contract (Literal members still pinned by membership tests). Cost: Phase 3's reason field loses its type-level enforcement — a typo like Degraded(reason="netwrok_denied") now constructs successfully and propagates downstream. Mitigation: advisory-catalog validation at the orchestrator boundary is the explicit migration ramp; the Open/Closed direction (tighten later) remains intact.
Reversibility. High. Re-narrowing reason: str to reason: DegradationReason is a one-line type change at the canonical site, conditional on auditing the actual emitted reasons across the codebase and expanding the Literal set to cover them. This amendment does not foreclose the tightening; it defers it to a consumer that pays for it.
Consequences.
codegenie.transforms.outcomesis the single declaration site forTrusted/Degraded/Unavailable/AdapterConfidence.codegenie.adapters.confidenceis now a pure re-export module (no class declarations; allowed bytests/unit/adapters/test_protocols.py::test_adapter_modules_are_pure_typingbecausecodegenie.transformsis not in the forbidden-prefix set).- S1-03 story file amended at AC-6, AC-7e, AC-7f to reflect
reason: str+ advisory-catalog framing;_validation/S1-03-tagged-union-outcomes.mdcarries the post-validation amendment. - Phase 2's typed-surface invariant (02-ADR-0007: "Phase 2 ships Protocols +
AdapterConfidence, never implementations") is honored — the typed surface is still shipped, just from a single source. - Future amendments to the Literal catalogs (additive members) require an ADR amendment; field-type tightening (
reason: str→reason: Literal[...]) requires a dedicated story + audit. - Latent
codegenie.types.identifiers↔codegenie.probesimport-cycle uncovered while landing the dedup:types.identifiersre-exportedPackageManagerfromprobes.node_build_systemat module top-level. The transitive chainprobes/__init__ → layer_b/dep_graph → depgraph/registry → types.identifiers.PackageManagerformed a real cycle whenever an outside-probesmodule loadedtypes.identifiersfirst (which now happens for everytransforms.outcomesimport that comes in viaadapters.confidence). Fixed by: (a)types/identifiers.pyresolvesPackageManagervia a module-level__getattr__lazy re-export (TYPE_CHECKING import keeps the static-typing contract); (b)depgraph/registry.pymoves itsPackageManagerimport to a TYPE_CHECKING block (no runtime use thanks tofrom __future__ import annotations); (c)probes/layer_b/dep_graph.pyimportsPackageManagerfrom the canonical origin modulecodegenie.probes.node_build_systemdirectly (whichprobes/__init__.pyfinishes loading before reachinglayer_b/dep_graph). The Phase-1 ADR-0013 "single owner" contract is preserved —probes.node_build_systemstill owns theLiteral. The testtest_package_manager_imported_from_types_identifiersis renamed totest_package_manager_imported_from_canonical_sourceand accepts either source.
Sources.
tests/unit/transforms/test_outcomes_purity.py:_ALLOWED_IMPORT_ROOTS— kernel-tier constraint forcing the canonical home to beoutcomes.py.tests/unit/adapters/test_protocols.py::test_adapter_modules_are_pure_typing— adapter-tier constraint admittingcodegenie.transformsimports.