Story S7-03 — plugins/universal--*--*/ — universal HITL fallback plugin (NFKC-sanitized handoff)¶
Step: Step 7 — First production plugin, universal HITL fallback plugin, synthetic third plugin
Status: HARDENED (validated 2026-05-19 — see _validation/S7-03-universal-hitl-fallback-plugin.md)
Effort: M (was M; the build_subgraph-vs-orchestrator-node reconciliation is reframing, not added scope)
Depends on: S6-04 (the orchestrator's IngestCveNode short-circuits with RequiresHumanReview on UniversalFallbackResolution, and the orchestrator exits 7 — this story amends IngestCveNode to write the handoff; see Validation notes §1), S6-05 (codegenie remediate CLI subcommand + --plugins-root / CODEGENIE_PLUGINS_ROOT — every integration test in the TDD plan runs codegenie remediate), S2-04 (the resolver returns UniversalFallbackResolution when no concrete plugin matches iff the universal plugin is registered), S6-01 (the RequiresHumanReview WorkflowInternalEvent variant + EventLog.emit_internal), S6-03 (SubgraphNode / SubgraphState — note SubgraphState carries no jail / event_log field; both are constructor-injected into nodes per S6-04)
ADRs honored: ADR-0003 (the load-bearing ADR for this story — the universal fallback IS a registered plugin under plugins/universal--*--*/, NOT a code branch in the resolver; specificity-0 (*, *, *) scope guarantees lowest sort priority; loader exits 4 on a concrete plugin's import failure BEFORE the resolver runs, so the universal is NEVER silently substituted), ADR-0002 (register_plugin(plugin) is a function call — NOT a decorator — against default_registry at module-import time; same machinery as the npm plugin), ADR-0011 (the handoff markdown is written via SandboxedPath with O_NOFOLLOW; PLUGINS.lock row regen for this plugin)
Validation notes (2026-05-19)¶
Hardened by phase-story-validator (automated scheduled task). Full audit: _validation/S7-03-universal-hitl-fallback-plugin.md. Verdict HARDENED — the goal (universal fallback is a registered plugin; sanitized handoff written; workflow exits 7; never silently substituted) is sound and traces to ADR-0003 + arch G7. The defects were (a) one structural wiring contradiction with the already-hardened S6-04, and (b) pervasive API drift against the real as-built code surfaces. Block-tier closures:
build_subgraphis NOT on thecodegenie remediatehot path (B1/B2). S6-04 §Notes "D-P1" pins: "Plugin.build_subgraph() is intentionally NOT called in Phase 3 — the 5 nodes ARE orchestrator-owned scaffolding." S6-04'sIngestCveNode, on aUniversalFallbackResolution, returnsShortCircuit(RequiresHumanReview(reason="no_concrete_match"))directly. So this story's one-nodebuild_subgraphsubgraph is exercised only by the contract bake testtests/integration/test_three_plugin_contract.py(arch line 1045; High-level-impl Step 7 line 205) — never bycodegenie remediate. Reconciliation: this story (a) ships the universal plugin so the resolver returnsUniversalFallbackResolution; (b) shipsbuild_subgraphreturning a one-node subgraph for the bake test only; (c) shipswrite_handoff(...)+sanitize_for_handoff(...)as importable functions; and (d) amends S6-04'sIngestCveNodeto callwrite_handoff(...)and sethandoff_pathon theRequiresHumanReviewoutcome so the real CLI path writes the file and exits 7. S6-04 is hardened-but-not-yet-built — the amendment is an explicit, declared edit, not a silent override.SubgraphStatehas nojail/event_log(B4). S6-03 AC-9 pinsSubgraphStatefields exactly (workflow_id,cve,resolution,bundle,recipe_outcome,transform,trust_outcome,branch). The handoff writer must receive the jail (SandboxedPathjail root) andEventLogas explicit parameters / constructor-injected dependencies — never read offstate(S6-04's node pattern: every node takesevent_logvia__init__).import codegenie.plugins.subgraphis fenced out of plugin folders (B3). High-level-impl Step 7 line 270: "noimport codegenie.plugins.subgraphfrom plugin folders."SubgraphStatelives only in that module. The bake-test one-node subgraph node therefore guards theSubgraphState/NodeTransitionimports underif TYPE_CHECKING:(or annotatesAny);ShortCircuitis imported fromcodegenie.transforms.outcomes(allowed) when a value is needed.- API drift fixed (B5–B9, H1, H4).
UniversalFallbackResolution.reasonisLiteral["no_concrete_match"]— a plain string, not an object with.kind.manifest.scopeis aManifestScope(rawstr | list[str]dims) with nospecificity()— lift vialift_manifest_scopefirst.RemediationOutcomeis aTypeAlias;RequiresHumanReviewis constructed directly (noRemediationOutcome.prefix). There is noNoConcreteMatchsymbol —reasonis the string"no_concrete_match"(HumanReviewReasonLiteral).RequiresHumanReview.handoff_pathisstr | None— passstr(sandboxed_path). The import-failure rejection variant isPluginImportError(kind="plugin_import_error");PluginRejectedis the union alias; exit-4 mapping isexit_code_for_rejection.PluginSubgraphisTYPE_CHECKING-only inprotocols.pyand not in__all__—build_subgraph's return is annotatedAny. - Two
RequiresHumanReviewtypes, twoPluginRegistryCorruptedtypes (B9, H2).RequiresHumanReviewexists both as aRemediationOutcomevariant (codegenie.transforms.outcomes) and as aWorkflowInternalEventvariant (codegenie.plugins.events, S6-01).PluginRegistryCorruptedexists both as aCodegenieErrorexception (codegenie.plugins.errors) and as aWorkflowSpanningEventvariant (codegenie.plugins.events, S6-01). Every reference in this story names which one it means.
Context¶
Production ADR-0031 (§"No-match fallback") commits: "no specific plugin matches is never a silent failure." Phase 3's ADR-0003 implements that commitment by making the universal fallback a normal registered plugin loaded by the same machinery as every other plugin, with scope (*, *, *) (three Wildcard dims, specificity 0). The resolver sorts by (specificity desc, precedence desc, name asc) so the universal naturally lands LAST; when the head of the sorted list is the universal, the resolver returns the typed variant UniversalFallbackResolution(reason="no_concrete_match") — distinguishable at compile time from ConcreteResolution. There is no if plugin.is_fallback: branch anywhere in the resolver; the discriminator lives in the return type.
This story lands the plugin directory + manifest + the sanitizer/writer helpers such that:
1. The registered universal plugin makes the resolver return UniversalFallbackResolution when no concrete plugin matches the workflow's scope (per ADR-0003).
2. A sanitized markdown handoff is written to <repo>/.codegenie/handoff/<workflow_id>.md — sanitization is NFKC normalization + ANSI escape strip + bidi-control strip + zero-width strip (the baseline established in phase-arch-design.md §"Open questions deferred to implementation").
3. A RequiresHumanReview workflow-internal event is emitted.
4. A RequiresHumanReview RemediationOutcome (reason="no_concrete_match", handoff_path=<str>) reaches the orchestrator.
5. The CLI exits 7 (per architecture §Decision points — "No plugin matches → universal fallback fires → RequiresHumanReview → exit 7. Never 'no match' exit").
Wiring correction (see Validation notes §1). The arch's Scenario B draws the universal plugin's subgraph doing steps 2–4. The as-hardened S6-04 instead handles the universal-fallback short-circuit inside the orchestrator-owned
IngestCveNode(S6-04 §Notes "D-P1" —Plugin.build_subgraph()is not called by the Phase-3 orchestrator). This story therefore ships steps 2–3 as importable helpers (sanitize_for_handoff/write_handoff), amendsIngestCveNodeto call them, and keeps a one-nodebuild_subgraphonly for thetest_three_plugin_contract.pybake test.
The most subtle invariant this story must protect (ADR-0003 + Edge case E10): if a concrete plugin matches the scope but fails to load (e.g., its import_module raises), the loader exits 4 with the PluginImportError variant of PluginRejected before resolution even runs — the universal fallback is NOT silently substituted. Edge case E10 is the contract; the negative test tests/integration/test_universal_fallback_never_silent.py (called out in High-level-impl.md §Step 7 Done criteria bullet 4) is the gate.
References — where to look¶
- Architecture:
../phase-arch-design.md §Scenarios B— the universal-fallback sequence diagram (CLI → orchestrator → resolver returnsUniversalFallbackResolution→ universal plugin's subgraph writes sanitized markdown → emitsRequiresHumanReview→ returnsRemediationOutcome.RequiresHumanReview→ exit 7).../phase-arch-design.md §Edge cases E2(Yarn Berry → universal) andE10(concrete plugin import-error short-circuits BEFORE resolver runs).../phase-arch-design.md §Component design C2(resolve()algorithm: "head plugin's id isuniversal--*--*, returnUniversalFallbackResolution").../phase-arch-design.md §Decision points(exit 7 mapping).../phase-arch-design.md §"Open questions deferred to implementation"— "Sanitization of HITL.codegenie/handoff/*.md. Synthesis adopts security's NFKC + ANSI/bidi/zero-width strip; implementation may need to add more once we see real HITL content."- Phase ADRs:
../ADRs/0003-plugin-resolution-and-universal-fallback-semantics.md— the load-bearing ADR for this story. Read all 67 lines. Especially §Decision (the algorithm), §Tradeoffs (loader startup check thatdefault_registry.get(PluginId("universal--*--*"))must succeed), §Consequences (every consumer mustmatchoverPluginResolution).../ADRs/0011-honest-framing-capability-sandboxedpath-pluginslock.md— handoff markdown is written viaSandboxedPathwithO_NOFOLLOW; consumers handleOSError(errno=ELOOP)and emitFilesystemRaceDetected.- Production ADRs:
../../../production/adrs/0031-plugin-architecture.md—§No-match fallback; "loaded by the same mechanism."../../../production/adrs/0009-humans-always-merge.md— the universal plugin's existence is the explicit type-level enforcement of "no autonomous merge ever."- Source for the resolver invariant the negative test rests on:
src/codegenie/plugins/loader.py(from S2-03) —importlib.import_module(...)per plugin tree; anImportErrorhere is translated to thePluginImportErrorvariant ofPluginRejected(Err-returned, exit 4 viaexit_code_for_rejection) beforedefault_registry.resolve(...)is ever called.- Sanitization precedent (if any): search the repo for existing NFKC normalization helpers —
unicodedata.normalize("NFKC", s)is stdlib. ANSI/bidi/zero-width strip is regex-based. If no helper exists, this story createssrc/codegenie/plugins/handoff_sanitize.py.
Goal¶
Land plugins/universal--*--*/{plugin.yaml,tccm.yaml,api.py,subgraph/...} plus a sanitizer helper and handoff writer at src/codegenie/plugins/handoff_sanitize.py and src/codegenie/plugins/handoff_writer.py, such that:
1. The plugin loads, registers via the register_plugin(plugin) function call (ADR-0002 — not a decorator), and is resolvable as UniversalFallbackResolution when no concrete plugin matches.
2. sanitize_for_handoff(s) (pure, stdlib-only) and write_handoff(...) (writes the sanitized markdown to <repo>/.codegenie/handoff/<workflow_id>.md via SandboxedPath + O_NOFOLLOW) are importable functions — not buried inside a plugin subgraph.
3. S6-04's IngestCveNode is amended to call write_handoff(...) and set handoff_path on the RequiresHumanReview outcome it already returns on UniversalFallbackResolution, so the real codegenie remediate path writes the file and the orchestrator exits 7. (S6-04's IngestCveNode is the canonical HITL short-circuit in Phase 3 — Plugin.build_subgraph() is NOT invoked by the orchestrator; see Validation notes §1.)
4. build_subgraph(registry) returns a one-node PluginSubgraph whose single node also writes the sanitized handoff — this satisfies the tests/integration/test_three_plugin_contract.py bake test that exercises every Plugin contract surface, but is not on the codegenie remediate hot path.
5. Crucially: the negative test proves that when a concrete plugin's import fails, the loader exits 4 (PluginImportError) before resolution and the universal is NOT silently substituted.
Acceptance criteria¶
Plugin directory + registration¶
- [ ] AC-1
plugins/universal--*--*/plugin.yamlis a validPluginManifest(the nestedManifestScopeshape S7-01 pinned —scope:is a submodel withtask_class | languages | build_systems, not a scalar slug):name: universal--*--*,version: 0.1.0,scope: {task_class: "*", languages: "*", build_systems: "*"},precedence: 0(lowest — concrete plugins use higher precedence; ties between wildcard plugins broken by precedence then name),extends: []. No top-leveltccm:key (the manifest defaultcontributes.tccm: "./tccm.yaml"applies — same as S7-01). - [ ] AC-2
plugins/universal--*--*/tccm.yamlis a minimal valid TCCM: emptymust_read,should_read,may_read,provides,requires. The universal does no context gathering; it escalates. - [ ] AC-3
plugins/universal--*--*/api.pydeclares the plugin and callsregister_plugin(plugin)(the function call — ADR-0002; not@register_plugin) at module-import time. The plugin instance is a@dataclass(frozen=True)mirroringuniversal_fallback_fixture._UniversalFallbackPlugin(the established shape — S7-01 §Implementation outline).adapters()returns{}(typeddict[PrimitiveName, Adapter]);transforms()returns{}. - [ ] AC-4 Running
load_plugins(plugin_root, ...)against a freshPluginRegistryregistersPluginId("universal--*--*");registry.get(PluginId("universal--*--*"))succeeds. The loader'simportlib.import_module("plugins.universal--*--*.api")resolves the literal-*directory name (filesystem lookup — the same mechanism that handles the hyphenatedvulnerability-remediation--node--npmslug per S7-01 CN-10; confirm against S2-03's slug→module mapping, do not invent a new convention). - [ ] AC-5
plugins/PLUGINS.lockcontains a row foruniversal--*--*whose value lifts throughBlobDigestvia the existingLockFile.from_pathAPI (S7-01 DP-7 precedent).
Resolution (the resolver, not a code branch)¶
- [ ] AC-6 With the universal plugin registered into a
PluginRegistry,registry.resolve(PluginScope.parse("vulnerability-remediation--rust--cargo").unwrap())returns aUniversalFallbackResolutioninstance (isinstancecheck, not truthiness).resolution.reason == "no_concrete_match"(a plain string —UniversalFallbackResolution.reasonisLiteral["no_concrete_match"], not an object with.kind).resolution.candidates_consideredis the alphabetised tuple of registered concretePluginIds (excludesuniversal--*--*itself). - [ ] AC-7 The universal manifest's scope, lifted via
lift_manifest_scope(plugin.manifest.scope)(fromcodegenie.plugins.resolver), yields exactly onePluginScopewhosespecificity() == 0(threeWildcarddims). Do not callplugin.manifest.scope.specificity()—manifest.scopeis aManifestScope(rawstr | list[str]dims) and has nospecificity();specificity()is aPluginScopemethod (S7-01 CN-7 precedent). - [ ] AC-8 With the universal plugin AND a concrete plugin (
vulnerability-remediation--node--npm, or a fixture) registered,registry.resolve(PluginScope.parse("vulnerability-remediation--node--npm").unwrap())returns aConcreteResolution— the universal (specificity 0) loses to the concrete (specificity 3). Proves the universal never shadows a real match.
Sanitizer (pure, stdlib-only)¶
- [ ] AC-9
src/codegenie/plugins/handoff_sanitize.pyexposessanitize_for_handoff(s: str) -> str— pure, stdlib-only, no I/O, no module-level mutable state; all regexes compiled at module scope. It: - Applies
unicodedata.normalize("NFKC", s). - Strips ANSI escape sequences: CSI (
\x1b[ … final-byte), OSC (\x1b] … \x07or\x1b] … \x1b\\), and any remaining bare\x1bintroducer. - Strips Unicode bidi-control characters — the four overrides
U+202A–U+202Eand the two isolatesU+2066–U+2069. - Strips zero-width characters
U+200B,U+200C,U+200D,U+FEFF. - [ ] AC-10
sanitize_for_handoffis idempotent:sanitize_for_handoff(sanitize_for_handoff(s)) == sanitize_for_handoff(s)for all inputs — covered by a Hypothesis property test (st.text()). - [ ] AC-11 Per-category unit tests with adversarial fixtures, each constructed so a CSI-only (or otherwise partial) implementation fails: an ANSI-injecting CVE description that includes both a CSI sequence and an OSC sequence; a bidi-override-injecting package name; a zero-width-padded version string. All adversarial control characters in test sources are written as explicit
\u…/\x…escapes (never literal bidi/zero-width bytes in the.pyfile).
Handoff writer + the real codegenie remediate path¶
- [ ] AC-12
src/codegenie/plugins/handoff_writer.pyexposeswrite_handoff(*, jail: Path, workflow_id: WorkflowId, markdown: str, event_log: EventLog) -> str(or equivalent explicit-parameter signature — no dependency read offSubgraphState). It: sanitizesmarkdownviasanitize_for_handoff; resolvesSandboxedPath.create(jail, f"handoff/{workflow_id}.md")and.unwrap()s theResult; writes via.open("w")(alwaysO_NOFOLLOWper ADR-0011); onOSError(errno=ELOOP)from a symlink TOCTOU emits theFilesystemRaceDetectedWorkflowInternalEvent(S6-01) and re-raises a typed error; returnsstr(sandboxed_path). - [ ] AC-13 S6-04's
IngestCveNodeis amended: onUniversalFallbackResolutionit callswrite_handoff(...)(rendering a markdown summary of the CVE, the scope that fell through, andresolution.candidates_considered), then returnsShortCircuit(RequiresHumanReview(reason="no_concrete_match", handoff_path=<written path>)).RequiresHumanReviewhere is theRemediationOutcomevariant fromcodegenie.transforms.outcomes— constructed directly (noRemediationOutcome.prefix; S6-04 §Notes line 613).handoff_pathis astr(RequiresHumanReview.handoff_path: str | None). - [ ] AC-14 Running
codegenie remediate ./tests/fixtures/repos/cargo-fixture --cve <valid-CVE-present-in-the-test-index>exits 7;<repo>/.codegenie/handoff/<workflow_id>.mdexists with non-empty content; the content contains the CVE id and contains no\x1bbyte (sanitization proven end-to-end). The CVE id is syntactically valid (CveId-parseable, e.g.CVE-2024-00000) and is seeded into the testVulnIndexsoIngestCveNode's CVE lookup succeeds and the workflow reaches plugin resolution (a CVE-index miss wouldEscalate("vuln_index_corrupted"), not route to the universal fallback). - [ ] AC-15 A
WorkflowInternalEventrecording the human-review escalation is emitted on the internal stream during the universal-fallback path (theRequiresHumanReviewvariant from S6-01's taxonomy — distinct from theRemediationOutcomevariant of the same name). Its payload fields are whatever S6-01 ships; this story does not invent fields on it.
build_subgraph bake-test surface¶
- [ ] AC-16
plugins/universal--*--*/api.py'sbuild_subgraph(registry)returns a one-nodePluginSubgraph-shaped object whose single node, when run, sanitizes + writes a handoff and yields aShortCircuitcarrying aRequiresHumanReviewRemediationOutcome. Return type is annotatedAny(PluginSubgraphisTYPE_CHECKING-only inprotocols.py, not in__all__— S7-01 DP-9). The node module does notimport codegenie.plugins.subgraph(High-level-impl Step 7 line 270 fence);SubgraphState/NodeTransitionannotations, if present, areTYPE_CHECKING-guarded. - [ ] AC-17
tests/integration/test_three_plugin_contract.py(or this story's slice of it) callsuniversal_plugin.build_subgraph(registry)and asserts the returned subgraph runs its node to aShortCircuit(RequiresHumanReview(...)). This is the only invocation of the universal'sbuild_subgraph— the orchestrator does not call it (S6-04 D-P1).
Never-silent invariant — the gate¶
- [ ] AC-18 — Negative test
tests/integration/test_universal_fallback_never_silent.py. A fixture concrete plugin undertests/fixtures/plugins/broken_import_setup/broken-import--node--npm/has anapi.pythat raisesImportError("synthetic broken plugin")at module-import time, plus aPLUGINS.lockmatching that tree. Running the loader (orcodegenie remediate ./tests/fixtures/repos/express-cve-2024-21501 --cve CVE-2024-21501 --plugins-root <fixture>) exits 4 — the loader translates theImportErrorto thePluginImportErrorvariant ofPluginRejected(kind="plugin_import_error";exit_code_for_rejection(...) == 4) beforeresolve()runs. NOT exit 7. No.codegenie/handoff/*.mdis written (the universal path never ran). Edge case E10 is the contract. - [ ] AC-19 Universal-plugin-missing invariant: with the universal plugin not registered and no concrete plugin matching the scope,
registry.resolve(...)raisesPluginRegistryCorrupted(thecodegenie.plugins.errorsexception —reason="missing_universal"; already enforced by the as-built resolver,resolver.py:524). A unit test pins this. (The story does not add a separate loader-startup check — the resolver already makes "universal absent" a loud failure; re-checking at loader time would duplicate. See Validation notes / Notes for the implementer.)
Bar ACs¶
- [ ] AC-20 No LLM SDK import added under
plugins/universal--*--*/, inhandoff_sanitize.py, or inhandoff_writer.py(verified viamake fence+make lint-imports). - [ ] AC-21 The red tests from §TDD plan exist, were committed in a failing state, and are now green.
- [ ] AC-22
ruff format --check,ruff check,mypy --strictclean on all touched files; fullpytestsuite green (no regressions, including S6-04'sIngestCveNodetests after the AC-13 amendment).
Implementation outline¶
src/codegenie/plugins/handoff_sanitize.py—sanitize_for_handoff(s: str) -> str. Pure, stdlib-only, module-level compiled regexes. NFKC normalize → strip ANSI (CSI + OSC + bare\x1b) → strip bidi controls + zero-width via a fixedfrozensetof codepoints (str.translate). ~15 lines. Test independently (AC-9/10/11).src/codegenie/plugins/handoff_writer.py—write_handoff(*, jail, workflow_id, markdown, event_log) -> str. Sanitize →SandboxedPath.create(jail, f"handoff/{workflow_id}.md").unwrap()→.open("w")write → returnstr(path). Wrap theopen/write intry/except OSErrorand re-raise afterevent_log.emit_internal(FilesystemRaceDetected(...))onerrno == ELOOP(ADR-0011 — symlink TOCTOU). All collaborators are explicit parameters; nothing is read offSubgraphState.- Create the plugin directory tree
plugins/universal--*--*/. The directory name uses literal hyphens and stars; shell globbing in scripts must quote it. The loader maps the literal slug to the import path (importlib.import_module("plugins.universal--*--*.api")— filesystem lookup, same mechanism as the hyphenated npm slug per S7-01 CN-10). Don't invent a new module-name convention; confirm against S2-03's loader. plugin.yaml— minimal valid manifest. Nestedscope:submodel with all three dims"*"(S7-01 pinnedManifestScopeis a submodel, not a slug).precedence: 0.extends: []. No top-leveltccm:key (manifest defaultcontributes.tccmapplies).tccm.yaml— minimal: emptymust_read,should_read,may_read,provides,requires.api.py—@dataclass(frozen=True)plugin instance mirroringtests/fixtures/plugins/universal_fallback_fixture._UniversalFallbackPlugin;_load_manifest()helper translates anErrfromPluginManifest.from_yamlinto aRuntimeError; module-levelregister_plugin(plugin)function call.adapters()/transforms()return typed empty dicts.build_subgraph(registry) -> Anyreturns the one-node bake-test subgraph (step 7).subgraph/handoff_node.py— the one bake-test node. It does not importcodegenie.plugins.subgraph(Step-7 fence). It is constructor-injected with whatever it needs (jail,workflow_id,event_log) — these come frombuild_subgraph's caller wiring in the bake test, not fromSubgraphState. Sketch:from codegenie.transforms.outcomes import RequiresHumanReview, ShortCircuit from codegenie.plugins.handoff_writer import write_handoff class UniversalFallbackNode: def __init__(self, *, jail: Path, workflow_id: WorkflowId, event_log: EventLog, candidates: tuple[PluginId, ...]) -> None: self._jail = jail self._workflow_id = workflow_id self._event_log = event_log self._candidates = candidates async def run(self, state: Any) -> Any: # SubgraphState/NodeTransition not imported (Step-7 fence) md = _render_handoff_markdown(self._workflow_id, self._candidates) handoff_path = write_handoff( jail=self._jail, workflow_id=self._workflow_id, markdown=md, event_log=self._event_log, ) return ShortCircuit(outcome=RequiresHumanReview( reason="no_concrete_match", handoff_path=handoff_path, ))- Amend S6-04's
IngestCveNode(src/codegenie/transforms/nodes/ingest_cve.py). S6-04 already returnsShortCircuit(RequiresHumanReview(reason="no_concrete_match"))onUniversalFallbackResolution; this story changes that arm to first callwrite_handoff(jail=..., workflow_id=state.workflow_id, markdown=_render_handoff_markdown(...), event_log=self._event_log)and pass the returnedstrashandoff_path=.IngestCveNodealready holdsevent_log(constructor-injected per S6-04 AC-8); the jail root is the repo path the orchestrator was invoked against. Update S6-04'stest_run_returns_requires_human_review_when_resolution_is_universalso itsRequiresHumanReviewexpectation carries a non-Nonehandoff_path. PLUGINS.lockregen — add theuniversal--*--*row (value viaLockFile.from_path/compute_plugin_tree_digest).- Negative-test fixture. Create
tests/fixtures/plugins/broken_import_setup/broken-import--node--npm/{plugin.yaml,api.py}whereapi.pybody raisesImportError("synthetic broken plugin")at module-import time, plus aPLUGINS.lockmatching that tree. The loader'simportlib.import_module(...)raises; the loader translates to thePluginImportErrorvariant ofPluginRejected; the CLI maps it viaexit_code_for_rejectionto exit 4.
TDD plan — red / green / refactor¶
Red¶
Unit tests — registration, resolution, sanitizer. Test file tests/unit/plugins/test_universal_fallback_plugin.py. Construct a fresh PluginRegistry() and load_plugins(...) into it (do not assert against the process-wide default_registry — fixture isolation per ADR-0002):
# tests/unit/plugins/test_universal_fallback_plugin.py
from codegenie.plugins.resolver import (
ConcreteResolution, UniversalFallbackResolution, lift_manifest_scope,
)
from codegenie.plugins.scope import PluginScope
from codegenie.types.identifiers import PluginId
def test_universal_plugin_registered_with_wildcard_scope(loaded_registry):
plugin = loaded_registry.get(PluginId("universal--*--*"))
lifted = lift_manifest_scope(plugin.manifest.scope) # ManifestScope -> tuple[PluginScope]
assert len(lifted) == 1
assert lifted[0].specificity() == 0 # three Wildcard dims (AC-7)
def test_resolver_returns_universal_fallback_for_unmatched_scope(loaded_registry):
scope = PluginScope.parse("vulnerability-remediation--rust--cargo").unwrap()
resolution = loaded_registry.resolve(scope)
assert isinstance(resolution, UniversalFallbackResolution)
assert resolution.reason == "no_concrete_match" # plain str — AC-6
def test_universal_never_shadows_a_concrete_match(loaded_registry_with_npm):
scope = PluginScope.parse("vulnerability-remediation--node--npm").unwrap()
resolution = loaded_registry_with_npm.resolve(scope)
assert isinstance(resolution, ConcreteResolution) # AC-8
Sanitizer tests — tests/unit/plugins/test_handoff_sanitize.py. All adversarial control characters are explicit escapes; the ANSI test carries both CSI and OSC so a CSI-only impl fails:
import unicodedata
import pytest
from hypothesis import given, strategies as st
from codegenie.plugins.handoff_sanitize import sanitize_for_handoff
def test_sanitize_strips_ansi_csi_and_osc():
raw = "pkg \x1b[31mred\x1b[0m and \x1b]8;;http://evil\x07link\x1b]8;;\x07 tail"
out = sanitize_for_handoff(raw)
assert "\x1b" not in out
assert "red" in out and "link" in out and "tail" in out
def test_sanitize_strips_bidi_override_characters():
raw = "package good" # RLO + PDF
out = sanitize_for_handoff(raw)
assert "" not in out and "" not in out
def test_sanitize_strips_zero_width():
raw = "version-1.0"
out = sanitize_for_handoff(raw)
assert "" not in out and "" not in out
def test_sanitize_applies_nfkc_normalization():
raw = "fifinale" # U+FB01 = ligature fi
assert sanitize_for_handoff(raw) == unicodedata.normalize("NFKC", raw)
@given(st.text())
def test_sanitize_is_idempotent(s): # AC-10
once = sanitize_for_handoff(s)
assert sanitize_for_handoff(once) == once
Negative integration test — tests/integration/test_universal_fallback_never_silent.py (AC-18). The handoff-absence assertion targets the analyzed repo's .codegenie/, never a CWD-relative path:
import subprocess, sys
from pathlib import Path
def test_concrete_plugin_import_error_exits_4_not_7(tmp_path):
"""ADR-0003 + Edge case E10: a concrete plugin's import failure exits 4
(PluginImportError) BEFORE resolution — universal NEVER substituted."""
repo = Path("tests/fixtures/repos/express-cve-2024-21501")
fixture_plugins = Path("tests/fixtures/plugins/broken_import_setup").resolve()
result = subprocess.run(
[sys.executable, "-m", "codegenie", "remediate", str(repo),
"--cve", "CVE-2024-21501", "--plugins-root", str(fixture_plugins)],
capture_output=True, text=True,
)
assert result.returncode == 4 # NOT 7
assert "plugin_import_error" in result.stderr.lower() or "PluginImportError" in result.stderr
assert not list((repo / ".codegenie" / "handoff").glob("*.md"))
Happy-path integration test — tests/integration/test_universal_fallback.py (AC-14). The CVE is CveId-parseable and seeded into the test index:
def test_cargo_repo_routes_to_universal_fallback_and_exits_7():
repo = Path("tests/fixtures/repos/cargo-fixture")
result = subprocess.run(
[sys.executable, "-m", "codegenie", "remediate", str(repo),
"--cve", "CVE-2024-00000"], # syntactically valid; seeded in the test VulnIndex
capture_output=True, text=True,
)
assert result.returncode == 7
handoffs = list((repo / ".codegenie" / "handoff").glob("*.md"))
assert len(handoffs) == 1
content = handoffs[0].read_text()
assert "CVE-2024-00000" in content
assert "\x1b" not in content # sanitization proven end-to-end
Run; confirm ModuleNotFoundError/ImportError on codegenie.plugins.handoff_sanitize, PluginNotRegistered on registry.get(PluginId("universal--*--*")), and non-7 exit codes on the integration tests; commit the red.
Green¶
Land, in dependency order: the sanitizer → the handoff writer → the plugin tree (plugin.yaml, tccm.yaml, api.py, the bake-test node) → the PLUGINS.lock row → the cargo-fixture/ and broken_import_setup/ fixtures → the IngestCveNode amendment (AC-13).
Smallest shape:
- sanitize_for_handoff ~15 lines: NFKC normalize, regex-strip ANSI (CSI + OSC + bare \x1b), str.translate over a fixed frozenset of bidi + zero-width codepoints.
- write_handoff ~15 lines: sanitize → SandboxedPath.create(...).unwrap() → .open("w") write → try/except OSError ELOOP guard → return str(path).
- UniversalFallbackNode ~15 lines: constructor stores injected deps; run() renders markdown, calls write_handoff, returns ShortCircuit(outcome=RequiresHumanReview(...)).
- api.py ~15 lines.
- The IngestCveNode amendment is ~3 lines inside the existing UniversalFallbackResolution arm.
Refactor¶
- Move the markdown template into a Jinja-free f-string helper
_render_handoff_markdown(workflow_id, cve, scope, candidates)at module scope (shared byapi.py's bake-test node andIngestCveNode). - Confirm
mypy --strictclean. The bake-test node returns aShortCircuitcarrying aRequiresHumanReviewRemediationOutcome;IngestCveNodereturns the same.NodeTransition = Advance | ShortCircuit | Escalate(S6-03). - Document in each new module's docstring that this plugin's existence is the type-level enforcement of production ADR-0009 (humans always merge) at the "no concrete plugin" boundary.
- Add a
WARNINGstructlog line where the universal path fires, reporting the scope that fell through andcandidates_considered— operator visibility into "why did I land in HITL." (Log line, not an AC — logs aren't contracts; story-smell "asserting on logs".)
Files to touch¶
| Path | Why |
|---|---|
src/codegenie/plugins/handoff_sanitize.py |
New — pure sanitize_for_handoff(s) -> str (NFKC + ANSI CSI/OSC + bidi/zero-width strip) |
src/codegenie/plugins/handoff_writer.py |
New — write_handoff(*, jail, workflow_id, markdown, event_log) -> str; SandboxedPath + O_NOFOLLOW + ELOOP→FilesystemRaceDetected |
plugins/universal--*--*/plugin.yaml |
New — PluginManifest with nested all-wildcard scope: submodel, precedence: 0, extends: [] |
plugins/universal--*--*/tccm.yaml |
New — minimal TCCM (all sections empty; universal does not gather context) |
plugins/universal--*--*/api.py |
New — @dataclass(frozen=True) plugin + register_plugin(plugin) function call + build_subgraph(registry) -> Any |
plugins/universal--*--*/subgraph/__init__.py |
New — re-exports UniversalFallbackNode |
plugins/universal--*--*/subgraph/handoff_node.py |
New — UniversalFallbackNode bake-test node; constructor-injected deps; no import codegenie.plugins.subgraph |
src/codegenie/transforms/nodes/ingest_cve.py |
Amend (S6-04) — the UniversalFallbackResolution arm calls write_handoff(...) and sets handoff_path on the RequiresHumanReview outcome (AC-13) |
plugins/PLUGINS.lock |
Modified — add row for universal--*--* |
tests/fixtures/repos/cargo-fixture/ |
New — minimal Cargo repo fixture for the universal-fallback happy-path integration test |
tests/fixtures/plugins/broken_import_setup/broken-import--node--npm/ |
New — fixture plugin tree whose api.py raises ImportError at import time + a PLUGINS.lock matching that tree |
tests/unit/plugins/test_universal_fallback_plugin.py |
New — registration + resolution (UniversalFallbackResolution / ConcreteResolution) + lift_manifest_scope specificity |
tests/unit/plugins/test_handoff_sanitize.py |
New — per-category sanitization tests (CSI+OSC, bidi, zero-width, NFKC) + Hypothesis idempotence |
tests/unit/plugins/test_handoff_writer.py |
New — write_handoff writes via SandboxedPath; ELOOP path emits FilesystemRaceDetected |
tests/unit/plugins/test_universal_missing_raises.py |
New — AC-19: resolver raises PluginRegistryCorrupted (errors exception) when universal absent + no concrete match |
tests/integration/test_universal_fallback.py |
New — codegenie remediate cargo-fixture → exit 7 + handoff written (AC-14) |
tests/integration/test_universal_fallback_never_silent.py |
New — the negative test — concrete plugin import failure exits 4 (PluginImportError), NOT 7 (AC-18) |
tests/integration/test_three_plugin_contract.py |
Amend (or this story's slice) — exercise universal_plugin.build_subgraph(registry) (AC-17) |
tests/unit/transforms/nodes/test_ingest_cve.py |
Amend (S6-04) — RequiresHumanReview expectation now carries a non-None handoff_path |
Out of scope¶
- S6-04's orchestrator + the other four subgraph nodes. S6-04 ships
RemediationOrchestratorandIngestCveNode/MatchRecipeNode/ApplyRecipeNode/Stage6ValidateNode/WriteBranchNode. This story only amendsIngestCveNode'sUniversalFallbackResolutionarm (AC-13) — it does not re-author the node or touch the other four. - Markdown HTML-embed neutralization —
phase-arch-design.md §Open questionsexplicitly defers: "Synthesis adopts security's NFKC + ANSI/bidi/zero-width strip; implementation may need to add more (e.g., markdown HTML-embed neutralization) once we see real HITL content." This story ships the baseline; future hardening is its own story. - Yarn Berry routing test (
tests/integration/test_yarn_berry_routed_to_universal.py) — that's S8-04's adversarial coverage. This story ships the universal plugin; the routing-against-Yarn-Berry case is part of the adversarial portfolio. extends-chain composition with the universal as a base — the universal does not appear in any concrete plugin'sextendschain in Phase 3; concrete plugins compose only with each other (if at all — Phase 3 uses depth-0 chains exclusively per S7-01 + S7-02).- Bench / performance — universal-fallback path is rare; no bench needed in Phase 3. Phase 9 may add
bench_universal_resolution_latencyif relevant. - Bench-replayable emission — S9-04's surface.
- Sigstore signing of the universal plugin — Phase 11.
Notes for the implementer¶
- READ ADR-0003 IN FULL, but read S6-04 §Notes "D-P1" too. ADR-0003 says the universal fallback is a registered plugin whose subgraph escalates — and at the resolver layer it is exactly that. But S6-04 (HARDENED) decided that in Phase 3 the orchestrator does not call
Plugin.build_subgraph()— the 5 nodes are orchestrator-owned. The reconciliation this story implements: the universal plugin'sbuild_subgraphone-node subgraph exists and is exercised by thetest_three_plugin_contract.pybake test (so thePlugincontract surface is real), but the operational HITL handoff on thecodegenie remediatepath is done by S6-04'sIngestCveNodeshort-circuit, which this story amends to callwrite_handoff(...). Both routes sharesanitize_for_handoff+write_handoff— one helper pair, two callers. This is the Rule-7 "two patterns contradict — pick one, document the other" outcome: the as-built S6-04 wins for the hot path; the arch's plugin-subgraph framing survives as the bake-test contract. - The negative test is the gate. Edge case E10: a concrete plugin's import failure exits 4 (
PluginImportError) BEFORE resolution — universal is NOT substituted. If your green code returns exit 7 instead, the implementation is wrong, not the test. SubgraphStatecarries NOjailand NOevent_log. S6-03 AC-9 pins its fields exactly. The handoff writer and the bake-test node receivejail/workflow_id/event_logas explicit parameters or constructor-injected dependencies — the same pattern every S6-04 node uses (IngestCveNode(vuln_index, registry, event_log), etc.). Do not try to widenSubgraphState— S6-03 §Notes forbids it; field additions are reserved for Phase-4/7 additive optional fields.- Plugin folders may not
import codegenie.plugins.subgraph(High-level-impl Step 7 line 270 import-linter fence).ShortCircuit/RequiresHumanReview/RemediationOutcomecome fromcodegenie.transforms.outcomes(allowed).SubgraphState/NodeTransitionlive only incodegenie.plugins.subgraph— the bake-test node guards them underif TYPE_CHECKING:or annotatesAny.make lint-importsis the gate. - Two
RequiresHumanReviewclasses — name them. TheRemediationOutcomevariant (codegenie.transforms.outcomes.RequiresHumanReview:kind,reason: HumanReviewReason,handoff_path: str | None) is what you put inside aShortCircuit. TheWorkflowInternalEventvariant (codegenie.plugins.events.RequiresHumanReview, S6-01) is what youemit_internal(...). They are different classes with different fields. There is noNoConcreteMatchsymbol —reasonis the string"no_concrete_match". - Two
PluginRegistryCorruptedtoo. ACodegenieErrorexception (codegenie.plugins.errors) AND aWorkflowSpanningEventvariant (codegenie.plugins.events, S6-01). AC-19 means the exception — the as-built resolver already raises it (resolver.py:524,reason="missing_universal"). Do NOT add a separate loader-startup check: the resolver already makes "universal absent" a loud failure; a duplicate loader check is redundant scope (Rule 2). The original story's "loader startup check" AC has been dropped for this reason. manifest.scopeis aManifestScope, not aPluginScope. It has rawstr | list[str]dims and nospecificity(). Lift it vialift_manifest_scope(plugin.manifest.scope)(fromcodegenie.plugins.resolver) to gettuple[PluginScope, ...], then call.specificity(). S7-01 hit this exact confusion (CN-7).handoff_pathis astr.RequiresHumanReview.handoff_path: str | None.write_handoffreturnsstr(sandboxed_path); never pass aSandboxedPathobject into the outcome.- The handoff path uses
SandboxedPath, notpathlib.Pathdirectly. Per ADR-0011,SandboxedPath.open(...)is alwaysO_NOFOLLOW; a symlink swap betweencreate()andopen()raisesOSError(errno=ELOOP).write_handoffcatches it and emitsFilesystemRaceDetected(the S6-01WorkflowInternalEvent). Symlink TOCTOU on the handoff directory is a real attack vector — don't skip the guard. sanitize_for_handoffis stdlib-only and pure. Regexes compiled at module scope; no state; no I/O. The idempotence property (AC-10) holds because NFKC normalization and each strip pass are individually idempotent — keep it that way.- The CVE in the happy-path test must parse AND exist in the index.
IngestCveNodelooks up the CVE inVulnIndexbefore resolving the plugin; a lookup missEscalates withvuln_index_corrupted(exit ≠ 7), not the universal fallback. Use aCveId-parseable id (CVE-YYYY-NNNNN) seeded into the test index. The universal fallback is about plugin-scope mismatch (Rust/Cargo matches no concrete plugin), not a CVE miss. - Precedence
0is fine — the universal is the only specificity-0 plugin in Phase 3; precedence ties are not possible yet. Phase 7+ may register other wildcard plugins, at which point ADR-0003's name-sort tiebreaker kicks in. - No
extendsfor the universal — it is the base of nothing. Keepextends: []. - The directory name
universal--*--*has literal*characters. Filesystems allow it;importlib.import_module("plugins.universal--*--*.api")resolves it the same way the hyphenated npm slug resolves (S7-01 CN-10 confirmed the loader uses the literal slug). Confirm against S2-03's loader rather than assuming; shell scripts must quote the path. - Resist gold-plating. No retry. No "maybe a concrete plugin matches if I squint." If the resolver returned
UniversalFallbackResolution, the workflow ends in HITL. The handoff markdown explains what was tried and why nothing matched, sized small (≤ 8 KB target; align withAttemptSummary.prior_failure_summarycap from S1-04).