## Roadmap to detection with the decision engine on kube-ax (2026-10-04)
This section supersedes the ordering below it. The older sections stay as history.
### Destination
On every push, from a hook or CI, OpenUltraSAST answers for the changed code. The answer is advisory for every
family, and BLOCK only for families whose precision has been proven on repositories the system never saw. The work
is split by what each place is good at:
- **kube-ax cluster** (operator k3s, Agent Substrate v0.3.0, AX v0.3.1, 2 nodes): the Joern engine, as AX Tasks.
No API keys and no store credentials in the cluster; tasks read and write through short-lived presigned links.
- **The VM or CI runner**: candidate selection, the decision engine's model calls (DeepSeek), the memory store,
and the fallback engine lane (2.5 GB heap) for pins that do not fit the cluster's 1.7 GB heap.
- **kind** stays the local development profile, with the same code and manifests.
### The question everything answers
Does the safety net work on repositories it has never seen? That is the purpose of the A/B experiments, and every
phase below is judged by it. On an unseen repository almost every push is an ordinary change, so two numbers decide
whether the safety net is useful, measured on the hook's advisory output exactly as a user would see it:
1.**Catch rate:** on real vulnerability-introducing changes in unseen repositories, how often it flags the change.
2.**False-alarm rate:** on ordinary changes in the same unseen repositories, how often it flags anything.
Within-pair AUC on known fix pairs answers neither. It stays a development signal, not the goal.
### Where we are (2026-10-04)
| Area | State | Evidence |
| --- | --- | --- |
| Generalisation measured so far | The classifier ranks above chance on repository-held-out folds (within-pair AUC about 0.76) but cannot reach the block bar; the engine-based scan failed fresh populations (v1 1/11, v2 0/17); push-level catch and false-alarm rates on unseen repositories have never been measured | exp-003/004/005, `results-v1.json`, `results-v2.json` |
| Pre-push hook | Operational and advisory on any repository | `2026-10-02-pre-push-operational-1/record.json` |
| Labelled data | 710 complete real fix pairs | `2026-10-04-decision-engine-harvest-3/record.json` |
| Engine | Almost blind to fixes (paths differ between sides in 2 of 105 both-asked pairs) | `2026-10-04-joern-slice-leak-audit/record.json` |
| Infrastructure | Engine runs on kube-ax as AX Tasks with a VM fallback; kind for development | memory `kube-ax-cluster` |
Correction recorded 2026-10-04: the previous two days went mostly into infrastructure and engine internals that did
not move a generalisation number. From here, infrastructure and engine work is done only when it removes a
| Labels | 100 machine-derived (SZZ), **unchecked**. Record 07 is a **machine-label baseline** (caveat stated); a human label pass is a later refinement, before any published claim. | `freeze-p1.json` label_check_counts |
| Harvest | Rebuilt to converge. Pure GitHub-API harvesting is a dead end for this benchmark (it needs the SZZ introducing commit + 12 stratified history negatives, not one commit). The fix: a full-objects clone (drop `--filter=blob:none`, which caused ~600 per-blob lazy fetches) + bucket-first durability + a get_repo cache that paid the 4,499-name used-set canonicalization once. Yield ~2.2%; **GitHub-rate-limit bound, not CPU**. | 95bed70, 857263d, 21aebe8, 7b711a6 |
| Persistence | **Bucket-first, not local disk.**`ousast-corpus` is the source of truth: enumeration, resolution, get_repo, and accept/reject decisions all stream to and resume from it. The host disk (~9-13 GB free) is the scarce resource; RustFS/S3 has the room, so run artifacts and **replay outputs go to RustFS too**, not `~/ousast-results` on local disk. | `storage-streaming.md`, `bank.py` |
| Replay image | Provenance pinned. CI `engine-image.yml` builds `ousast-replay-task` (harness with `/app/benchmarks/unseen/replay_entry.py`) + `ousast-engine-task` (Joern hook), publicly pullable by digest. The old `ousast-runner:dev` (248 MB ax-runner, no `/app/benchmarks`, no Joern) is the **wrong** image for replay. | CI run 38116281676 |
| Baseline (G0.2) | **Not yet.** Replay reads the frozen pool cleanly (dry-run = 1,300 changes, `freeze_digest` verified). Remaining: mirror CI's passing `replay` job wiring, and stand up the kube lane (`ax` binary + a public-HTTPS presign-capable store endpoint; GHCR pull already public). | CI `replay` job (passed) |
Lesson, recorded so it is not re-paid: sourcing and engine-image work cost real time. The durable takeaway now lives
in the pipeline and these docs — bucket-first persistence, pinned image provenance, a measured yield — so the next
pool or arm is cheap, not a rediscovery.
### Readiness plan (to G0 exit = record 07)
1.**Kube replay lane (infra, operator-owned):** install the `ax` binary; provide the store endpoint the AX lane
needs. The only thing settled in-repo is a code fact: `benchmarks/ax/batch.py` currently expects a public-HTTPS
DNS, presign-capable S3 endpoint. How that endpoint and the cluster's access to the store are provided is an
operator/AX decision, not an assumption to make here. GHCR pull of the images is verified public.
2.**Unblock the replay** by mirroring CI's passing `replay` job (replay-task + engine-task wired, store + network).
3.**Run slice-1 baseline on kube:**`--lane ax --arm default`, 1,300 changes, no model cost, resumable; outputs to
RustFS.
4.**Adjudicate (7.3) -> record 07 (7.4)** = the measured machine-label unseen baseline = **G0 exit**.
5. Then **G1 arms** (DeepSeek decision engine) on the frozen pool.
**G3. Decide BLOCK per family** on the pool's false-alarm rate and the pooled precision bound (Req 6.4).
**G4. Detection on push for any repository** (`plane-on-kubernetes` P4 work: `ousast plane scan --base --head`,
engine pins on kube-ax, model calls on the VM or CI), sized by what G1-G3 kept.
**G5. Qualification and rollout:** adopt, commit `prediction-v3.json`, spend population v3 once, advisory rollout,
BLOCK only for families that passed.
### Cross-cutting rules
- No hand-tuning per miss. Every detection change is a registered experiment or an admission by held-out
separation.
- Records carry counts, digests and pattern names, never repository names: the reserved-repository guard covers
every population.
- Instrument first: a zero from an unread input is a failed run (AGENTS.md).
- Cluster stability: sandbox deaths are retried once, then sent to the VM lane; the operator owns workers, egress
and Substrate.
### Spec map
| Spec | Role in this roadmap | State |
| --- | --- | --- |
| `unseen-repo-evaluation` (new) | G0, G1: the pool and the push-level metric | To specify |
| `learned-decision-engine` | G1-G3, G5; fix-mechanisms plan is one G2 arm | Implementation; experiments 3-5 recorded |
| `plane-on-kubernetes` | G4; the engine executors for kube-ax | Groups 1-2 done (kind); production path re-scoped to the operator's kube-ax (AX Tasks), groups 3-8 to re-plan |
| `pre-push-safety-net` | the hook replayed in G0-G1; M4-M5 in G5 | Operational increment 1 done; M4-M5 open |
| `ai-service-plane` | The plane the above runs on | Complete |
| M1a | Show the NodeGoat detection with every admission veto recorded, not applied | Raw finding with witness on the vulnerable revision; analyzed fixed twin and unchanged control quiet; per-finding veto list. One-week time box | MET 2026-09-16 |
| M1b | Repair only the vetoes M1a showed to matter | Supported evaluation finding through production rules, exact change/base evidence and repair direction; fixed twin and unchanged control stay quiet | IN PROGRESS: comparison half verified on all three revisions; the consequence and repair rendering (10.10) remains |
**Rewritten 2026-09-09.** The previous version (kept as `.kiro/archive/steering/roadmap-2026-09-04.md`, outside steering) described a pre-Joern world —
> "a first release that is ready for smaller projects like libpng or wordpress"
Those two are, today, exactly the cases that do not work — and they fail for **different** reasons, which is
what makes them a good pair of targets rather than one.
| target | why it fails now | what a release needs |
| --- | --- | --- |
| **WordPress** (php) | `taint_specs(language="php")` returns **zero families**. Joern's `php2cpg` parses it; we say nothing. | Fact tables and entry-point mapping. The engine is right for this: PHP web bugs are the classic taint families, which is what the taint arbiter already decides well. |
| **libpng** (c) | `c/memory` models string functions; libpng overflows through *arithmetic*, and its library never calls a sink we model. | Either a second abstraction (size arithmetic — a constraint problem) or an honest, stated limit. |
So "ready for" has to mean two different things, and the release criteria say which applies where:
* For a project whose bug class a family **can** decide (WordPress, vibe-code): find the known vulnerability.
* For one it **cannot** (libpng today): run inside its envelope, report nothing false, and *state the limit*
rather than returning a quiet clean bill of health.
The second is not a consolation. A tool that says "I do not model this class" is usable; one that silently
finds nothing is the thing that teaches developers to distrust SAST.
### Release criteria (v0.1)
1. Runs unattended on a pinned checkout of each target within a stated time and memory envelope.
2.**Zero known-false entailed findings** on every pinned checkout.
3. Finds the known vulnerability wherever a family can decide it in principle (Req 4.6).
4. States what each family cannot decide, in the report (Req 5.6).
5. The report is proportionate — no rule repeating itself past a stated count (Req 5.5).
6. Installs and runs without the user having Joern, a JVM, or a PHP interpreter (docker compose + wrapper).
7. A committed regression baseline per target, so the next change is measurable rather than argued about.
### What that reorders
PHP moves **up**: it is the shortest path from here to a target the maintainer named, and it is the case the
existing arbiter is best suited to. The C decision moves up with it, because criterion 4 needs `c/memory`
either earning its place or being scoped — and scoping it is cheap.