The prior commit's edit did not match and 7.4 was left unclaimed. model/specs.py is read-only data -- no LLM and no optimiser writes it -- and task 1.1 now carries that criterion with a test asserting the module exposes no writer. All 49 criteria trace to a task. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
18 KiB
Implementation Plan: model-grounded-detection
Guidance for the implementing agent
Read this before starting. It exists so you never have to reverse-engineer a decision this spec already made.
- The failure mode to avoid. The prior work logged eighteen defects, three of them the same shape: a fix applied to one instance while the class of the bug stayed. When you fix a retry, a key, an error path — ask "where else is this shape?" and fix all of them in the one change. That single discipline prevents most of the escalations that happened here.
- The gate baseline is sacred and is your safety net.
benchmarks/measurements/2026-09-06-gate-baseline.txtholds the byte-exact output ofpython -m openultrasast.{gate,map_gate,pair_gate}. Run all three before and after any change and diff against it. If a gate moves, you changed something that was never supposed to change — stop and find it. The wholelearning/package is absent from the gate path (the gates import onlypairs,benchmark,findings,mapping,preprocess,rank,complexity.map), so its deletion cannot move a gate; a moved gate means you touched a retained path by mistake. - RED first, always. Write the failing test, watch it fail for the right reason, then implement. One
commit per task with selective
git add. Push when a group's independent review passes. - Zero-dependency core.
pyproject.tomldependencies = []never changes. Joern, the LLM endpoint and the execution tier are capability-detected, never imported at module load. Copy the exact pattern insemantic/extra.py::has_semantic_extraandsandbox/probe.py::resolve_sandbox_probe— probe, don't import. - Redaction on every prompt and persisted artifact (
redaction.redact_secrets). Non-negotiable. - Two phases can end the design cheaply. Phase 1 (subtraction) is reversible via git and gate-protected. Phase 2 (the Joern spike) is a go/no-go: if Joern cannot produce a deterministic verdict on the injection family at acceptable cost, the design stops there, exactly as the candidate ceiling could have stopped the prior spec. Do not build groups 3–6 until phase 2 is green.
The coupling map (for the subtraction, group 1)
These are every touchpoint of the code being removed. Handle all of them in group 1; do not discover them by running into import errors.
learning/package — imported by:cli.py(thelearningsubcommand group +_run_family_detectors_learning*helpers +from .learning.families import FamilyTaxonomy+from .learning.proposer import Proposer),pairs.py(the hunter-scoring path —_evaluate_hunter_pair,_family_metrics,_hunter_outcome,build_pair_signals, function-local imports oflearning.{families,scoring,classify,rounds}at lines ~519, 1151, 1166, 1184),semantic/seed.pyandsemantic/loo.py(the teacher-rule import fromlearning.split), andlearning/endpoint.py(retained — re-homed, not deleted).
- Mechanism/evolve loop —
improve/evolve.py,improve/validator.py,semantic/mechanisms.py,semantic/variants.py,semantic/variant_search.py,semantic/prove_budget.py,semantic/loo.py,semantic/seed.py, andsemantic/__init__.py(re-exports).cli.pyhas the--mechanism-candidatessurface and_mechanisms_export. config.py—LearningConfig,_load_learning, and thelearningfield onResolvedConfig.reports.py— thelearning=keyword on the report writers and_append_learning; the SARIFfamily/detector/verifierproperties (keepfamily, dropdetector/verifier).pairs.pyis gate-load-bearing viapair_gate(the overlay/inventory path,_evaluate_overlay_pair/_evaluate_inventory_pair). The hunter-scoring path is the learning coupling and is not on the gate path. Remove the hunter path and its learning imports; keep the overlay/inventory path untouched; confirmpair_gatebyte-identical.
What is retained and re-homed (do not delete)
learning/families.py → model/taxonomy.py; learning/candidates.py → model/candidates.py;
learning/endpoint.py → model/endpoint.py. semantic/obligations/ stays (its facts feed the dominance
queries in group 4). The gates, redaction.py, pairs.py overlay/inventory path — unchanged.
Group 1 — Subtract and audit (gate-protected, no Joern yet)
-
1.1 Port the security vocabularies out before deleting their modules
- Create
src/openultrasast/model/specs.pyand move into it, as data, the knowledge the deleted modules carry:_GUARD_PATTERNSand the guard-kind vocabulary fromsemantic/variants.py; the sink token tables (frombenchmarks/pairs/catalog_gen.py::_SINK_TOKENSandsemantic/facts.pySinkFacts) as the seedTaintSpecsource/sink/sanitizer lists per family; a reference to the obligation facts (semantic/obligations/facts.py) for theDominanceSpecoperations/dischargers. This is data movement, not logic — the CPG queries that consume it come in groups 2 and 4. - Observable:
model/specs.pyimports nothing fromlearning/,improve/,semantic/variants,semantic/mechanisms; a test asserts every family in the taxonomy has aTaintSpecor aDominanceSpecseed, and that the guard vocabulary is preserved verbatim.model/specs.pyis read-only data — no LLM and no future optimiser writes it, and a test asserts the module exposes no writer (Req 7.4, the verifier boundary). - Requirements: 1.2, 7.1, 7.2, 7.3, 7.4
- Create
-
1.2 Re-home the three retained modules
- Move
learning/families.py→model/taxonomy.py,learning/candidates.py→model/candidates.py,learning/endpoint.py→model/endpoint.py. Update their imports and every importer.candidates.pykeeps its behaviour exactly (it is the suspicion-band feeder, Req 8.4) — its tests move with it and stay green. - Observable: the moved modules import no other
learning/module;test_candidate_enumeration.pypasses unchanged against the new path; the taxonomy loads and every family resolves. - Requirements: 4.4, 7.1
- Depends: 1.1
- Move
-
1.3 Detach the retained paths from the removed code
pairs.py: remove the hunter-scoring path (_evaluate_hunter_pair,_hunter_outcome,_family_metrics,PairEvalResult.per_family,build_pair_signalssplit arg, thek_runs/hunterplumbing) and itslearning.*imports; keep_evaluate_overlay_pair/_evaluate_inventory_pairand thecontext_filesmachinery.cli.py: remove thelearningsubcommand group,_run_family_detectors, the_learning*helpers, the--mechanism-candidatessurface and_mechanisms_export; keepscan,benchmark,improve(rule-status only),pairs(overlay),mcp/indexfor now (audited in 1.6).config.py: removeLearningConfig/_load_learning.reports.py: drop thelearning=kwarg,_append_learning, and the SARIFdetector/verifierproperties (keepfamily).semantic/{seed,loo}.py: remove the teacher-rule import fromlearning.split.- Observable:
python -m compileall srcclean; the three gates byte-identical to the baseline; the full suite still collects (failures expected only in tests that pin removed behaviour, addressed in 1.5). - Requirements: 1.3, 1.4, 11.2
- Depends: 1.2
-
1.4 Remove the noise architecture and the mechanism/evolve loop
- Delete
learning/{rounds,acceptance,proposer,journal,canaries,verifiers,difficulty,scoring,classify, detectors,split,publish,slices,predicates,judgments}.py(everything underlearning/except the three modules re-homed in 1.2, which are already gone from the directory), then remove the now-emptylearning/package. Deleteimprove/evolve.py,improve/validator.py(mechanism parts — keep rule-status validation ifimproveis retained by the 1.6 audit),semantic/{mechanisms,variants,variant_search,prove_budget, loo,seed}.py, and their re-exports insemantic/__init__.py. - Guardrail: run this after 1.1–1.3, so every vocabulary is ported and every importer detached. If a delete breaks an import, the fix is in the importer (a 1.3 miss), never a re-addition of the module.
- Observable: an import-graph check (a test that walks
astover everysrc/**/*.pyand asserts no import names a removed module) passes;compileallclean; three gates byte-identical; the extra-free matrix and the full matrix both collect. - Requirements: 1.1, 1.2, 1.2b, 1.3, 1.4
- Depends: 1.3
- Delete
-
1.5 Reduce the test suite to surviving behaviour
- For every deleted module, delete the test files that existed only to pin it (
test_learning_*,test_mechanism_*,test_obligation_*only where it tested the lever not the checker,test_pair_*hunter-scoring cases, etc.) in this same change. For every surviving test, confirm it maps to a surviving requirement of this spec or a still-shipping feature; delete any that map to nothing, and delete outright any test that asserts a shape "does nothing", greps--help, or passes with its capability absent. - Observable: the suite is smaller than 832 tests; a one-line-per-cluster note records the before/after
count and the requirement each removed cluster served; the pinned floor (gate-baseline, redaction,
per-slice honesty,
test_candidate_enumeration) is green. - Requirements: 2.1, 2.2, 2.3, 2.4, 2.5
- Depends: 1.4
- For every deleted module, delete the test files that existed only to pin it (
-
1.6 The module audit manifest
- Add
model/audit.pythat classifies everysrc/**/*.pyasload_bearing(reachable from a gate, the scan CLI, or a surviving requirement),standalone_capability(its own CLI/users — e.g.mcp.py,skills.py), ororphaned(no importer, no entry, no requirement). Remove the orphans. Givefusion.py,mcp.py,skills.py,harness_ext.py,hunter_harness.pyan explicit keep-or-remove line each with its reason. Commitbenchmarks/measurements/2026-09-08-module-audit.json. - Observable: the manifest classifies every module; a test asserts no module classified
orphanedremains in the tree; the five named subsystems each have a recorded decision. - Requirements: 1.5, 3.1, 3.2, 3.3, 3.4
- Depends: 1.5
- Add
Group 2 — The Joern spike on one family (go/no-go)
-
2.1 The CpgBackend seam, out of process
src/openultrasast/cpg/capability.py::has_cpg()— Joern on PATH (probejoern-parse --help), thehas_semantic_extrapattern exactly.cpg/backend.py:CpgResult(a builtcpg.binpath + arun(query, params)->JSONcallable);JoernBackend(subprocess:joern-parse <root>thenjoern --script cpg/queries/<name>.sc, parsing stdout JSON, every step timeout-bounded, fail-closed toNone);NullBackend(available()False,build()None);resolve_cpg_backend(). Never import a JVM binding; the subprocess is the only Joern touch-point in the codebase.- Observable: with Joern absent,
resolve_cpg_backend()is theNullBackendandbuild()returns None; a scripted fake backend (canned JSON) exercises the parsing without Joern; the seam has no import of any non-stdlib module at load time. - Requirements: 4.1, 4.2, 4.3, 4.5, 11.3
- Depends: 1.6
-
2.2 The injection taint query and its verdict
cpg/queries/taint.sc— a CPGQL script parameterised by aTaintSpec(sources, sinks, sanitizers) that emits, per candidate, the JSON{sink, source, path, sanitized: bool}.model/taint.py::verdict(cpg, spec, candidate)→ENTAILEDwhen a source→sink path exists withsanitized=false,CORROBORATEDwhen a path exists but a sanitizer may apply, elseNone. InjectionTaintSpecfrommodel/specs.py.- Observable: on a fixture with
os.system(request.args['x'])the verdict isENTAILED; on the parameterised-query fixed twin it is not; the query script is deterministic. - Requirements: 5.1, 5.2, 6.1
- Depends: 2.1, 1.1
-
2.3 Determinism, and the go/no-go reading
test_two_runs_over_one_cpg_give_an_identical_verdict_sequence. Run the injection taint verdict over the injection slice, regenerate the entailment ceiling for injection, and write the reading into the Implementation Notes: does Joern produce a deterministic verdict at acceptable per-target cost and time? If not, stop the design here and record why.- Observable: the determinism test passes repeatedly; the injection entailment number is committed against the 2.2%/multi-hop baseline; the notes state go or no-go.
- Requirements: 6.4, 6.5
- Depends: 2.2
Group 3 — The ladder and the judge
-
3.1 The evidence ladder as the verdict type
model/ladder.py:Rungenum (suspicion/model_corroborated/model_entailed/execution_confirmed),Verdict(rung, family, witness, contradiction). Findings carry the rung in markdown, SARIF and JSON.- Observable: a finding at each rung round-trips through every output format with its rung intact; nothing
is reported above
suspicionwithout aVerdictestablishing it. - Requirements: 5.1, 5.3, 5.4
- Depends: 2.3
-
3.2 The judge: one bounded question, checked against the model
model/judge.py::judge(candidate, cpg, spec, *, client, model)— if the model entails, reportmodel_entailedwith no LLM call; else ask the LLM one typed question (source expression / guard / family) with no tools; then check the answer against the CPG — contradiction drops the claim (records why), corroboration advances tomodel_corroborated, neither leavessuspicion. Reusemodel/endpoint.py.- Observable: an entailed candidate makes zero LLM calls; a claim the CPG contradicts is dropped with the contradiction recorded; a claim the CPG supports advances; there is no rounds/acceptance/floor/evolve code left to import.
- Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 11.4
- Depends: 3.1
Group 4 — Absence and the rest of the taxonomy
-
4.1 Guard dominance for the absence families
cpg/queries/dominance.sc+model/dominance.py::verdict(cpg, spec, candidate)—ENTAILEDwhen an obligated operation is dominated by no discharger while its siblings are, seeded from the obligation facts. This is the arbiter for the classes that never crash (access_control).- Observable: on the ThreatByte/VAmPI absence fixtures the vulnerable handler is
ENTAILED(guard absent) and the fixed twin is not; a handler with no obligated operation yields no verdict. - Requirements: 6.2, 7.3
- Depends: 3.2
-
4.2 The remaining TaintSpecs and the ceiling regeneration
- Fill
TaintSpecs for path, deserialization, untrusted_destination, output_encoding, prototype, and the config constant-abstraction arm (model/taint.py). Regenerate the full entailment ceiling per family and per slice and commit it; name where each family falls back tosuspicion. - Observable: the entailment ceiling artifact carries every web/logic family with its entailed/corroborated split; the number is higher than the flat-IR 2.2% and the fallbacks are named.
- Requirements: 6.3, 6.5, 7.2
- Depends: 4.1
- Fill
Group 5 — Corpus calibration and reporting
-
5.1 The corpus as the model's calibration set
model/calibrate.py— over the vulnerable-parent/fix pairs, run the model on both sides and record whether it distinguishes them; a pair it cannot split is a named model gap (with the reason), not a miss. Provide the CVE-history harvest seam (recipe → parent/fix, the fix diff read as oracle).- Observable: the calibration report lists model gaps by family with reasons; a pair the model splits is recorded as covered; per-slice with the real-world slice deciding.
- Requirements: 9.1, 9.2, 9.4, 11.1
- Depends: 4.2
-
5.2 Reporting: per-slice, overfitting gap, rung
model/report.py— per-slice numbers, the overfitting gap beside every headline, the evidence rung on every finding; no cross-slice aggregate without its per-slice rows. Re-home the surviving per-slice honesty discipline (it was in the deletedscoring/publish).- Observable: the report carries a row per slice and refuses a bare aggregate; the overfitting gap is published; every finding shows its rung.
- Requirements: 9.3, 11.1
- Depends: 5.1
Group 6 — The execution tier stub (deferred)
- 6.1 The execution_confirmed rung and the Clearwing adoption seam
- Wire
Rung.execution_confirmedas an optional escalation reached only where the model cannot decide (memory/C first), behind a capability probe for the Clearwing fork. Do not reimplement any sandbox machinery; the seam calls the adopted tool. Not required for any web/logic class at v1. - Observable: a memory-family finding the model leaves at
suspicionis the only kind eligible for the execution tier; with Clearwing absent the rung is simply never reached and a reason is recorded; no web/logic path depends on it. - Requirements: 10.1, 10.2, 10.3
- Depends: 5.2
- Wire
Task-plan review notes
- Group 1 is the largest and the only one with no new capability — it is pure subtraction, ~5,000 lines removed, gate-protected at every step. It must land and pass an independent review before group 2 begins.
- Group 2 is a go/no-go. If task 2.3 reads no-go on Joern, groups 3–6 do not start; the model layer either finds another CPG engine or the design returns to the maintainer. Building past a red 2.3 is the exact mistake this whole spec was written to stop.
(P)parallelism is deliberately absent: the subtraction is sequential by dependency, and the Joern spike gates everything after it. Do not fan out.