The plane's memory store no longer names one vendor. `MinioStore`/`SdkClient`/ `minio_settings`/`MINIO_KEYS` become `S3Store`/`S3Client`/`s3_settings`/`S3_KEYS`; the URL scheme is `s3://<bucket>[/<prefix>]` (`s3://` alone uses `S3_BUCKET`); the settings are `S3_ENDPOINT` (a full URL), `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, optional `AWS_SESSION_TOKEN` and `S3_REGION`. The client is boto3 (extra `s3`, imported lazily): SigV4, path-style addressing, SDK checksum trailers off, paginated listing, versions and delete markers listed and deleted by version (`purge`, used by the contract tests' teardown so the bucket keeps nothing of a run). Behaviour is unchanged: the store never configures its bucket and verifies versioning, the `runs/` expiry rule, S3 Select and tag reads at startup; no local fallback; every row carries every queryable field with "" placeholders. The admin hints read `--endpoint-url "$S3_ENDPOINT"`. The fake client still reproduces RustFS's two Select failures (unbound leading-row field, null-typed column). Docs: rustfs.md stays the tested server's setup page (policy JSON unchanged, env names updated, any S3-compatible store with versioning, lifecycle, tags and Select works); memory.md, ops/ax/README.md, README.md, steering and the two current spec designs use the S3 naming. Retired specs and committed measurements are untouched. Real server (RustFS, OUSAST_MEMORY_TEST_S3=1): test_plane_memory + test_learn_compile 75 passed, 1 skipped. Store copy of the live local store to a temporary prefix: every count matched (776 row objects, 2982 rows, 22 facts, 788 excerpts, 2601 responses, 2 programs, 1793 index entries), 4190 versions purged afterwards. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
39 KiB
Design Document
Overview
HarnessX leaves the product in one reviewable sequence, and the plane's memory layer takes over what HarnessX was meant to drive. Three facts from the code shape the design:
- No HarnessX capability is measured, and one of them never existed. The LLM paths are reached only through
config keys, not CLI flags (
scanhaspath,--mode,--config,--fail-on;cli.py:163-167), andbenchmarks/holds no run of any of them (nohx-hunter:finding, nollm-panelsdecision). TheMetaAgent.evolveproposer is only a docstring (improve/evolve.py:8-9,harness_ext.py:7-8). No code calls it. - The plane already does the LLM work better, and outside
ousast scan.verify(two passes, known callers fromrepo-facts) plusagree(2-of-3 with the tie-break) met the ai-service-plane gate (benchmarks/independent/plane-increment-2.json: 16/20 declared sites agreed, $0.0205 per candidate). The standard-mode LLM hunter that users actually reach, the tool hunter at the MAP stage (cli.py:631-640,tool_hunter.resolve_hunter_client), is not HarnessX and stays. - Everything deterministic already runs without the extra. Every guarded branch falls back to a deterministic
function (
run_hunter_pool,verify_findings,fuse_finding). Removal therefore makes the fallback the only path. Quick mode,benchmark,pairs,improveand the gates never enter a guarded branch.
So every HarnessX capability in ousast scan is retired with its deterministic path kept. The plane's verify
and agree tasks are the documented agentic path (ousast plane run <Run>). The proposer is carried by the
memory layer: a persistent store, keyed by repository and pin, that plane Runs write through a model-free
remember task. From that store a deterministic generator emits RuleStatusEdit proposals into the unchanged
EvolveValidator and gate. The improvement loop becomes a plane Run whose only Model-bound tasks are the verify
passes.
Architecture
Before After
ousast scan --mode standard ousast scan --mode standard
hunter_pool: HxScanOrchestrator | run_hunter_pool hunter_pool: run_hunter_pool (only path)
verify: verify_judge (llm-judge) | verify_findings verify: verification.verify_findings
fusion: llm panels | deterministic panels fusion: deterministic panels (fuse_findings)
MAP: tool hunter ([models] hunter, DeepSeek) MAP: tool hunter (unchanged)
harness_ext.py, hunter_harness.py, verify_judge.py deleted
stage_processors.host_under_harnessx deleted; deterministic pipeline kept
[harnessx] config, extra, mypy override, lock one load warning; extra/override/lock gone
improve: benchmark per-rule proposer (+ MetaAgent improve: benchmark proposer + memory proposer (--memory)
"plugs in" -- never implemented)
plane Run (per case)
facts -> va, vb -> agree -> vc -> final (existing)
alerts (new, no model) ------------------+
remember (new, no model) <- facts, final, alerts, va/vb/vc
plane Run (loop tail, once)
measure -> propose -> improve(validate+gate) (no model)
~/ousast-results/plane/memory/ the store: facts by content, rows by repo + pin
New code: src/openultrasast/plane/memory.py (store, ingest, seed; host side),
src/openultrasast/plane/tasks/{alerts,remember,measure,propose,improve}.py,
src/openultrasast/improve/memory.py (deterministic proposal rules). None of it is in the reconciler. The
reconciler gains only mark_done (below). It is 465 lines today, and tests/test_plane_reconciler.py:759-761
keeps it under 500.
Components and Interfaces
1. Fate of each HarnessX capability (Requirement 1)
| Capability | Where today | Fate | What the user loses |
|---|---|---|---|
LLM hunter pool (HxScanOrchestrator) |
hunter_harness.py:49-185; gated in cli.py:400-456 (hx_hunter at :405, orchestrator at :438-448, degradation at :452-455) |
Retired. run_hunter_pool becomes the only standard-mode hunter pool. LLM hunting of a repository stays available in two places: the MAP-stage tool hunter in ousast scan --mode standard/deep with [models] hunter (cli.py:631-640, not HarnessX), and plane verify via ousast plane run plane/runs/validation-46.yaml |
hx-hunter:<path>:<line> findings from an anthropic/openai/litellm agent (hunter_harness.py:155). No run of this path is recorded under benchmarks/, so no measured TP is lost. |
| LLM judge | verify_judge.py:22-63; gated in cli.py:406, 499-511 |
Retired. verification.verify_findings is called directly and verify_judge.py is deleted (it is only the dispatcher and the judge). The plane's LLM verification is verify + agree (+ vc/final tie-break) |
Per-finding plausible/unsupported verdicts that set ACCEPTED/REJECTED (verify_judge.py:83-88). Unmeasured. [models] judge (model-grounded-detection, model/endpoint.py:195) is a different capability and stays. |
| LLM fusion panels | fusion.py:194-326 (_fuse_with_llm_panels, _run_llm_panels); gated in cli.py:519-541 |
Retired. The deterministic panels (_panel_a, _panel_b, _reconcile, fusion.py:101-167) become the only path. fuse_findings_dispatch is renamed fuse_findings(findings, verifications, *, high_assurance, mitigated_ids). The plane counterpart of independent cross-examination is agree across independent passes with the 2-of-3 tie-break |
Steel-man LLM panels (decision_source: "llm-panels"). Unmeasured. fusion.json for deterministic decisions is unchanged. |
| Deterministic stages hosted under HarnessX | stage_processors.py:129-156 (host_under_harnessx); only caller is the skipped test test_slot_contract.py:119-133 |
Retired, no replacement. ax runs whole tasks, so a model-disabled in-loop host has nothing to host. stage_processors/slot_contract stay as the standalone deterministic pipeline that test_gate.py:147-148 pins |
Nothing. The function has no production caller. |
LLM evolution proposer (MetaAgent.evolve) |
docstring only: improve/evolve.py:8-9, harness_ext.py:7-8 |
Carried by the memory layer (Requirement 6): improve/memory.py rules over the store, fed into run_round as extra proposals |
Nothing implemented is lost. |
No CLI flag is HarnessX-specific, so Requirement 1.3 applies to config keys. A key that asked for a retired
capability makes load_config raise RetiredConfigError(ValueError), and the CLI prints the message and exits 2.
A silent downgrade is not allowed:
| Key | Message (abridged) |
|---|---|
[models] verifier |
"[models] verifier was retired with HarnessX on : LLM verification runs on the plane (verify + agree, ousast plane run, see ops/ax/README.md). Remove the key." |
[fusion] panel_model, [fusion] decider_model |
"... LLM fusion panels were retired; fusion is deterministic; independent LLM agreement is the plane's agree task ..." |
[harnessx] is different (Requirement 3.3). It only tuned budgets and never requested a capability, so it loads
with one logging.warning on logger openultrasast.config that names the plane ("[harnessx] is ignored:
HarnessX was removed; agentic work runs on the ax plane with per-task budgets, see ops/ax/README.md"). The section
is otherwise ignored. [models] hunter keeps its meaning (tool hunter, obligations intent adjudication at
cli.py:1237), so it gets no message. With the extra installed it no longer starts a second, HarnessX hunter. The
release notes say so.
Requirement 1.2 (moved capabilities have a CLI path, a Model per task and a fixture test) is met by the existing
verify/agree tasks (ousast plane run; Model deepseek-flash bound per Task; tests/test_plane_verify.py,
tests/test_plane_scoring_parity.py, tests/test_plane_tiebreak.py), and by the new memory and loop tasks below, each with its own fixture test. No
capability is moved into a new Model-bound task.
2. Removal mechanics (Requirement 2)
Source.
- Delete
harness_ext.py(97 lines),hunter_harness.py(197) andverify_judge.py(130). cli.py: drop the imports at :44, :46 and :94. Dropharnessx_present,hx_hunterandhx_verify(:400-406), the orchestrator branch and its degradation (:438-456), and the verify degradation (:499-502). Callverify_findings(findings)at :503-511. Drophx_fusionand its degradation (:521-527). Callfuse_findings(findings, verifications, high_assurance=config.fusion.high_assurance). Theverifier_modellocal goes away.fusion.py: drop the import at :24, thepanel_model/decider_model/provider/use_harnessxparameters, and_with_degradation,_fuse_with_llm_panels,_run_llm_panelsand_text(:229-338). Rewrite the module docstring (:10-14) and update__all__.stage_processors.py: drophost_under_harnessx(:129-156) and its__all__entry, and rewrite the module docstring (:10-11).slot_contract.py:7-14becomes "the zero-dependency substrate for the deterministic pipeline".config.py: dropHarnessxConfig(:99-107),ResolvedConfig.harnessx(:201),_load_harnessx(:345-351) and its call (:230). DropModelConfig.verifier(:37),FusionConfig.panel_model/decider_model(:123-124) and their loaders (:266, :366-367). AddRETIRED_KEYSand the one-warning rule above.harness.py:183keeps the"verifier": Noneliteral inmodel_roles, soharness.jsonkeeps its schema byte for byte (the key has always beennullin the baseline config).- Comment-only mentions:
gate.py:174("a model-backed stage"), andimprove/evolve.py:8-9, which now names the memory proposer. plane/Dockerfile.runner:23loses "no HarnessX".
Packaging.
- Delete the
harnessxextra and its comment (pyproject.toml:14-22) and theharnessx.*mypy override and its comment (:67-71). uv lockregeneratesuv.lock. Theharnessxpackage (:1062), the extra (:2145-2146), the requirement (:2169) andprovides-extras(:2178) go.- Extras that remain:
semantic. There is noallextra.pip install .andpip install '.[semantic]'in a fresh venv are the Requirement 2.2 check. - No source or test imports a HarnessX transitive dependency. The only third-party imports are
yaml,tree_sitterandpytest(checked with an import scan), so dropping the lock entry cannot break an import.
Tests (14 files plus conftest.py):
| File | Action |
|---|---|
test_harness_ext.py, test_hunter_harness.py, test_verify_judge.py, test_cli_hx_dispatch.py |
Delete. Each exercises only HarnessX or its dispatcher. |
conftest.py |
Rename fixture assert_cold_of_harnessx to assert_cold_import(code): a fresh interpreter runs code, with no module-name guard. test_semantic_extra.py:28-35 is its remaining user (the tree-sitter check brings its own assertion). |
test_gate.py |
Delete test_harnessx_stage_stays_within_tolerance_of_baseline (:124-150) and the has_harnessx import (:23). Replace the two "never import" tests (:155-175) with test_governance_planes_import_only_stdlib_and_declared_dependencies, an allow-list (stdlib, openultrasast, yaml) over module-level imports of GOVERNANCE_SCORING_BENCHMARK_PLANES. It covers any extra without naming one. Update the docstring at :5-6. |
test_scan_pipeline_integrity.py |
Rename test_standard_scan_extra_absent_matches_deterministic_baseline to test_standard_scan_with_a_hunter_model_keeps_deterministic_findings (config hunter only). Rename test_quick_scan_is_unaffected_by_the_extra to test_quick_scan_records_no_degradation. Drop the has_harnessx monkeypatch and the harnessx parameter. Recompute the expected degradation set from the post-removal run, since hunter_pool/verify entries no longer exist. |
test_slot_contract.py |
Delete test_host_under_harnessx_disables_the_model and the imports at :16 and :22. Keep the rest. |
test_config.py |
Replace test_harnessx_defaults_when_section_absent and test_load_config_reads_harnessx_section with test_retired_agentic_section_loads_with_one_warning (caplog: exactly one record naming the plane) and test_retired_llm_keys_fail_naming_the_replacement (verifier, panel_model, decider_model). |
test_fusion.py |
Delete test_dispatch_records_degradation_when_llm_requested_but_extra_absent (:142-146) and the import at :16. Rename the dispatch calls to fuse_findings. |
test_tool_hunter.py (:229, :266), test_scan_deep.py (:144) |
Drop the has_harnessx monkeypatch line. |
test_semantic_extra.py |
Use assert_cold_import. |
test_module_audit.py |
The subsystem tuple at :31 becomes ("fusion", "mcp", "skills"). A new assertion checks that the three deleted modules are absent from the audit. |
Module audit. model/audit.py:76-77 (harness_ext, hunter_harness) are deleted, and :78
(stage_processors) is reworded to "deterministic slot-contracted scan stages; pinned by the zero-dependency
guard in test_gate". Regenerate benchmarks/measurements/2026-09-08-module-audit.json in place with to_dict(audit(SRC)).
test_the_manifest_matches_the_tree pins that path. The diff shows three rows removed and no orphan.
Search (Requirement 2.4). grep -rni harnessx src tests pyproject.toml README.md may match only lines tagged
retired 2026-..:
- the
RETIRED_KEYSentry and warning text inconfig.py - the two retirement tests in
test_config.py - the one README line in the plane section
Requirement 3.3 cannot be implemented or tested without naming the section, so these lines count as retirement
notes. The check is a test (test_no_harnessx_outside_retirement_notes) that fails on any untagged match. It
builds its pattern at runtime, so it does not match itself.
3. Equality proof (Requirement 3)
The spec's "ousast regress" is not a subcommand (cli.py:133-251). Regress exists only as the deep-mode
sandbox stage. On this project the regression command over the development pins is ousast pairs
(README.md:38): the vuln-vs-fixed replay over local fixtures and vendored pinned VFC pairs, offline by default.
The benchmark replay is README.md:35's manifest. The baseline also covers the two paths the removal touches
directly: one standard-mode scan (verify and fusion) and one improve --dry-run (evolve). Everything is
quick or standard mode with no Joern on the host, so each command takes minutes, not hours, on this 7 GB host.
Environment (instrument first).
- Run from a
git archive HEADexport in the scratchpad (the source is frozen, per the long-runs rule). uv sync --frozen --extra semantic, which drops the installedharnessx. It is installed in.venvtoday.OPENULTRASAST_SKIP_DOTENV=1andenv -u DEEPSEEK_API_KEY -u OPENROUTER_API_KEY -u ANTHROPIC_API_KEY -u OPENAI_API_KEY, plus an empty--config, so no command can reach a model.
The harness asserts find_spec("harnessx") is None, find_spec("tree_sitter") is not None, and a
uv pip freeze hash. It also prints the byte size of each input manifest, and fails on any non-zero exit code.
Commands (one script, benchmarks/harnessx_removal_equality.py record|compare <dir>):
pytest -q -p no:cacheprovider --junitxml=suite.xml, which gives outcome per test id.ousast pairs --json > pairs.json. Instrument check: pair count > 0 and at least one TP.ousast benchmark benchmarks/manifests/python-vulnerable.toml --mode quick. Keepbenchmark_result.json,calibration_records.jsonandexternal_baseline_deltas.json. Instrument check:expected > 0.ousast scan benchmarks/fixtures/python-vulnerable --mode standard --config empty.toml. Keepfindings.json,verification.json,fusion.json,report.sarif,harness.jsonandmanifest.json.ousast improve benchmarks/manifests/java-spring-boot-vulnerable.toml --dry-run --no-pair-gate, stdout plus the dry-run journal.
Stored. benchmarks/measurements/<date>-harnessx-removal-baseline/: the normalized outputs, and env.json
(commit, freeze hash, instrument checks). The script's exclusion list is written into that directory's
README line.
Comparison. JSON is parsed, normalized and re-serialized with sorted keys. Everything else is compared as raw bytes. Excluded:
- keys
runtime_seconds,scan_id,timestamp,started,finished,created_at,generated_at, and any key ending_secondsor_ms - run directory names (timestamped,
benchmark.py:186) trace/events.jsonlas a whole (timings)- the export's absolute root path, replaced by
<ROOT>
For the suite, every test id that survives must keep its outcome. Deleted and renamed ids are compared through the map in the Tests table above, and any other difference is a failure. Any difference in 2-5 blocks the merge.
4. Memory layer (Requirement 6.1, 6.4)
Store (plane/memory.py). The store lives at results_root()/memory/ (~/ousast-results/plane/memory/,
honouring OUSAST_RESULTS, reconciler.py:58-59). It is JSON files, not SQLite: the rest of the plane is JSON,
diffable and greppable, and one run at a time holds the run lock, so SQLite's concurrency buys nothing.
memory/
index.jsonl one row per ingested (run, task): sha256 of its memory.jsonl, row count
facts/<sha256>.json facts.json by content (identical facts stored once)
repos/<host>__<owner>__<name>/<pin>.jsonl the rows of one repository + 40-hex pin
Ingest is idempotent: an index.jsonl hit skips the delivery, and rows carry a deterministic id. Ingest also
refuses to write when shutil.disk_usage reports less than 1 GiB free. The disk is at 98% today, so it names
the store path in the error.
Storage backends (maintainer, 2026-09-30: asked to use the object-store features of the S3 server he runs,
including its JSON support). The store is written against one interface, MemoryStore (put_row, put_facts, get_facts,
rows(repo=..., pin=..., kind=..., where=...), index), with two backends of identical behaviour:
FileStore: the layout above, for this laptop and the unit tests.S3Store: an S3-compatible bucket (boto3; RustFS is the tested server), and the store of record once the plane runs on a separate Kubernetes cluster. The layout maps onto one bucket with the same key paths:- Object metadata and tags on every row object:
repo,pin,kind,family,run,population,split. Rules list by prefix (repos/<repo>/) and filter by tag instead of scanning the bucket. - Server-side JSON queries:
rows(where=...)pushes the filter down with S3 Select (SelectObjectContent, JSON Lines input, JSON output), so a proposal rule reads matching rows, not whole files. Support is probed once per process against the server (a small Select on a known object); when the server lacks it, the backend fetches and filters locally, logs the fallback once, and returns the same rows. A query never silently returns nothing because a feature is missing. - Versioning on the bucket: each proposal's provenance records the object key and version id of every
memory row it cites, so its evidence cannot change underneath it (Req 6.4). Object lock (governance mode) is
optional for the
proposals/prefix. - Lifecycle rules: raw run outputs under
runs/expire after a configurable number of days;repos/,facts/andproposals/are kept. The near-full laptop disk stops being the store's limit. - Presigned URLs (the step that removes the host receiver for the Kubernetes move, not built in this
spec): the reconciler can hand a task presigned PUT URLs for its outputs and GET URLs for its inputs in the
start request, so tasks write to the bucket directly and never hold credentials. The interface keeps
presign_put/presign_getso that follow-on changes the reconciler, not the store. - Bucket notifications (follow-on): an event on new
repos/rows can trigger the loop Run (event-driven loop engineering).
- Object metadata and tags on every row object:
- Configuration:
OUSAST_MEMORYselects the backend (file:///pathors3://<bucket>[/<prefix>]); the S3 endpoint and credentials come from.env(S3_ENDPOINT,AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY, optionalS3_REGION), never from a manifest, never printed. Client: boto3 as an optional extra (openultrasast[s3]); the core install stays PyYAML-only. - Tests: the same contract tests run against
FileStoreand againstS3Storepointed at an S3-compatible endpoint (skipped unlessOUSAST_MEMORY_TEST_S3is set), including the Select probe with its fallback forced, versioned provenance, and metadata filters. The disk-space refusal applies toFileStoreonly.
remember task (plane/tasks/remember.py, no Model, budget {usd: 0, calls: 0}, one per case). It reads the
case's delivered artifacts as inputs: facts.json, the units.jsonl of va/vb/vc, final/agreed.json and
final/disputed.json with the per-candidate rows, and alerts.jsonl. It takes the repository URL and pin from
the bound Workspace (AX_WORKSPACES_YAML, OUSAST_GIT_PINS) and the population from the Run annotation
openultrasast.io/population. It writes memory.jsonl and passes facts.json through. Task code interprets
the artifacts, so the reconciler and runner stay content-blind (ai-service-plane Req 3.4). After reconciler.run
returns, cli._plane calls memory.ingest(run_dir). ousast plane remember <run> does the same by hand.
Row kinds (every row has id, kind, repo, pin, run, task, population, split, image):
facts:sha256,candidates_digest(repo_facts._digest,repo_facts.py:188),files.verdict:candidate(path::function),family,site,passes {a, b, c}(true/false/null),final(agreed | rejected | disputed),tiebreak(bool),site_match(bool/null),usd,turns.unit_cost:pass,path,usd,calls,usage,turns,model.alert:rule_id,rule_status(enabled | shadow),path,line,function,pin_role(vulnerable | fixed).proposal_outcome:edit_key,evidence_digest,outcome(accepted | reverted | rejected),gate_run.
Fact reuse. A facts entry is valid for the key (repo, pin, candidates digest, runner image digest). The image
is in the key because the host's source and the image's source can differ, and facts computed by other code are
not the same facts. ousast plane workspaces --validation-set writes the key as the Task annotation
openultrasast.io/memory-key. Annotations are already stripped before ax (manifests.py:4-6). Before
reconciler.run, cli._plane calls memory.seed(run_manifest). For each Run entry whose Task carries a key with
stored facts and no state yet, seed writes <run>/<task>/facts.json and a summary.json
(status: done, units_done == units_total == 1, reused: {"sha256", "from_run"}). It then calls
reconciler.mark_done(run, task, reused=...), a public helper of about 10 lines that takes the run lock, sets
the state and releases it. The reconciler already skips done tasks (reconciler.py:342). The receiver already
serves any regular file under the run directory as a declared input (egress.py:242-246). A changed pin, a
changed candidate set or a rebuilt image recomputes.
5. Proposals from memory (Requirement 6.2, 6.4)
Interface. The proposal surface HarnessX would have used is run_round (improve/evolve.py:149-247). It
builds RuleStatusEdits (improve/validator.py:36-47), filters them through the journal's novelty gate
(journal.reverted_edit_keys, journal.py:26-38), validates each with EvolveValidator.validate
(validator.py:75-81, which accepts only RuleStatusEdit and PolicyConstantEdit), smoke-replays and gates.
improve/memory.py exposes:
propose_from_memory(rows, ruleset_by_id, current_ledger, journal_rounds, *, excluded) -> list[MemoryProposal]
MemoryProposal = (edit: RuleStatusEdit, provenance: dict)
run_round gains proposals: Sequence[MemoryProposal] = (). It appends their edits after the benchmark's edits
and keeps the first edit per key(). The validator and the gate are untouched. ousast improve gains
--memory [DIR] (off by default, so the deterministic improve output is byte-identical) and
--qualify-population NAME (repeatable).
Rules (the thresholds are module constants and part of the provenance record). Join key: an alert and a
verdict match when repo, pin and path::function are equal. A disputed final counts as no evidence in
either direction.
- M1, repeated false alerts (
enabled -> shadow). Rule R qualifies when both hold:- at least 3 R alerts sit at candidates whose final is
rejected, or on afixedpin inside the case's declared fix range, across at least 2 distinct repositories; - no R alert sits at an
agreedcandidate or asite_matchsite.
- at least 3 R alerts sit at candidates whose final is
- M2, missed declared sites (
shadow -> enabled). Shadow rule R qualifies when all three hold:- its shadow alerts hit at least 2
agreedcandidates withsite_match == true, across at least 2 repositories; - no enabled rule alerts at those candidates;
- R has no alert on a
fixedpin at the same function and at most 1 alert at arejectedcandidate.
- its shadow alerts hit at least 2
- M3, frequently disputed families. Families with at least 5 candidates and a dispute rate of at least 0.3 go
to
signals.jsonas advisory input for plane routing (for example, scheduling passcup front). They are not an edit and never reach the validator.
Provenance. Each proposal's rationale is memory:<proposal_id>, where proposal_id = sha256 of
(rule, edit key, sorted evidence row ids, thresholds). The sidecar memory_proposals.jsonl, next to the journal,
holds:
proposal_id,edit_key,rule_idevidence(the row ids plus their repo, pin, run and task)- the counts that met each threshold
excluded(populations and pins removed by the guard, with counts)- the store's
index.jsonldigest at proposal time
evolve._record (evolve.py:366-396) adds "evidence": proposal_id to an edit's journal entry only when the
edit came from memory. Otherwise the journal is unchanged byte for byte. A reader follows journal, then sidecar,
then store rows, then the run directory.
Novelty. journal.reverted_edit_keys does not block a rule edit that carries a rationale (journal.py:36), so
a memory proposal would otherwise come back every round. The proposer itself skips any key whose reverted journal
entry has the same evidence digest. New evidence is the "new rationale" that allows a retry.
Train-on-test guard. A row is excluded from proposals when any of these holds:
- (a) its
populationis one of--qualify-population, which is also where the proposal will be gated or qualified; - (b) its (repo, pin) is a case of the benchmark manifest being gated;
- (c) its (repo, pin) is a
split = "holdout"pair of the pair catalog, which the per-profile holdout clause (evolve.py:204-214) measures.
This is the rule the closed-loop leak broke, where a holdout pair taught the shape that "recovered" it. Here the
filter runs before any rule sees a row. Concretely, rows from validation-46 (population
independent-v2/validation) never propose edits that are then qualified on independent-v2/validation. The
tie-break was designed on that set, so it is development evidence only.
6. The loop as a plane Run (Requirement 6.3)
ousast plane workspaces --validation-set ... --loop extends the generated Run. It also emits a Workspace for each
case's fixed pin (the population's fixed sha, already read by generate.fix_ranges, generate.py:89):
| Task (per case unless noted) | Model | Budget | Inputs -> outputs |
|---|---|---|---|
<case>-facts, -va, -vb, -agree, -vc, -final |
verify passes: deepseek-flash; others none |
as generated today (generate.verify_budget) |
unchanged |
<case>-alerts (new) |
none | {0, 0}, serialize: engine |
vulnerable + fixed Workspaces -> alerts.jsonl (quick-mode quick_scan_findings with enabled and shadow rules, function-attributed) |
<case>-remember (new) |
none | {0, 0} |
facts, va, vb, vc, final, alerts -> memory.jsonl, facts.json |
loop-measure (once) |
none | {0, 0} |
every remember + memory-snapshot/rows.jsonl -> measure.json (the increment metrics: declared sites agreed, cost per candidate, dispute rates) |
loop-propose (once) |
none | {0, 0} |
measure, snapshot, journal -> proposals.jsonl, memory_proposals.jsonl, signals.json |
loop-improve (once) |
none | {0, 0}, serialize: engine |
proposals; Workspace = this repository at the commit under test -> gate.json (the RoundOutcome), journal.json, rule_policy.json (only when accepted) |
Before the Run starts, memory-snapshot is seeded into the run directory like reused facts. It holds the store
rows after the guard, since a sandboxed task cannot read the host store. A {usd: 0, calls: 0} budget makes
budget.py refuse any call, so a model-free task that tries one fails loudly rather than spending. Attribution
per task is the existing ousast plane status table.
The gate's verdict lands in ~/ousast-results/plane/<run>/loop-improve/gate.json and is ingested as
proposal_outcome rows. An accepted rule_policy.json is adopted into the repository only by a maintainer
commit, as any ledger change is today (README "Rule-level loop").
7. Documentation and specs (Requirement 4)
- README. Replace "Configuring the HarnessX agentic plane" (README.md:293-329) with "The agentic plane (ax)":
what runs there (repo-facts, verify, agree, memory),
ousast plane run|status|remember, a pointer toops/ax/README.md, and one tagged line saying HarnessX was retired. Rename "The HarnessX self-improving cycle" (:330) to "The self-improving cycle". Its calibration ledger andousast improvecontent is not HarnessX and stays. The MetaAgent paragraph (:393-400) is replaced by the memory proposer (--memory, provenance, guard). Drop thepanel_model/decider_modellines at :227-228. docs/examples.mdsection 6 becomes "Running the agentic plane", with the plane doctor, run and status commands.docs/threat-model.md:17-20, 32-34: egress is the per-task EgressPolicy (receiver, Git hosts, Modelegress-hosts, passthrough only). Credentials travel only in the start request. Spend is the per-taskusd/callsbudget in the Run.- Retirement notes. A dated note ("Retired 2026-..: HarnessX was removed by
harnessx-removal; the agentic plane is ax (ai-service-plane), proposals come from the plane memory. History below is unchanged.") goes at the top of the requirements (and design, where present) ofharnessx-self-improving-rulesets,learning-harness,three-stage-scanandopenrouter-sast-harness, and in.kiro/steering/overview.mdnear :175. The other specs that mention HarnessX in passing keep their history untouched. RELEASE_NOTES.mdgets one entry listing the retired keys. Kiro tooling stays out of user docs.
Data Models
memory.jsonl(remember output) andrepos/<repo>/<pin>.jsonl: the rows above, one JSON object per line, sorted keys, stableid= sha256(kind, run, task, candidate|path|rule_id+line).index.jsonl:{"run", "task", "sha256", "rows", "ingested"}.memory_proposals.jsonl:{"proposal_id", "edit_key", "rule_id", "rule": "M1|M2", "evidence": [...], "counts": {...}, "thresholds": {...}, "excluded": {...}, "store_digest"}.- Journal edit entry: the existing fields (
evolve.py:381-384) plus"evidence"only for memory edits. - Equality record:
env.json{"commit", "freeze_sha256", "harnessx_importable": false, "tree_sitter": true, "inputs": {path: bytes}, "exclusions": [...]}.
Error Handling
RetiredConfigError: exit 2 with the key and its replacement, before any stage runs.- A
[harnessx]section produces exactly one warning, and loading continues. memory.ingeston a run withoutrememberoutputs is a no-op that says so. A malformed row fails the ingest and names the file and line, and nothing partial is written: rows are staged in a temporary file and renamed.- A low-disk refusal names the store and the free space. The plane run's result is unaffected, and ingest can be
repeated with
ousast plane remember. seednever overwrites an existing task directory or a non-pending state. A facts file whose sha256 no longer matches is dropped from the store with a warning, and that task recomputes.propose_from_memorywith an empty store after the guard returns no proposals, andrun_roundreportsno_proposalsas today.- The equality script exits non-zero on the first failed instrument check. It also exits non-zero on any difference outside the exclusion list and prints the first differing path and key.
Testing Strategy
tests/test_config.py: the one-warning section and the retired keys (above).tests/test_no_harnessx_references.py: the tagged-only search of Requirement 2.4.tests/test_plane_memory.py: ingest is idempotent and content-addresses identical facts.seedmarks a facts task done and serves its file, and does not for a changed pin, candidate digest or image. Low-disk refusal (monkeypatcheddisk_usage).tests/test_plane_remember.py,test_plane_alerts.py,test_plane_loop.py: fixture run directories built from the recorded validation-46 shapes.rememberrows matchagree's per-candidate rows.alertsattributes functions.measurereproduces the increment's recorded metrics on the recorded artifacts.proposeandimprovework end to end on a fixture manifest.tests/test_improve_memory.py:- M1 and M2 fire at their thresholds and not below.
- A disputed candidate counts neither way.
- The guard removes rows by population, manifest case and holdout pair, so a proposal that exists only because of a holdout row disappears.
- Provenance ids are stable across runs.
- A reverted memory edit is not re-proposed on the same evidence, and is re-proposed on new evidence.
run_roundwithout proposals writes a journal byte-identical to today's.
tests/test_plane_reconciler.py:mark_doneunder the lock. The existing under-500-lines test stays.- The existing gate and pair tests,
test_benchmark_output_is_byte_identical_to_golden_baseline(test_gate.py:104) and the equality script (section 3) are the regression net.
Requirement Traceability
| Requirement | Design section | Verified by |
|---|---|---|
| 1.1 fate of every capability | 1 (table) | this document; commit 5 |
| 1.2 moved capabilities reachable, Model per task, fixture test | 1 (existing verify/agree), 4-6 (new tasks are Model-free) |
test_plane_verify.py, test_plane_tiebreak.py, new plane task tests |
| 1.3 retired keys fail naming the replacement | 1 (RetiredConfigError) |
test_config.py |
| 2.1 modules and guards gone | 2 Source | commit 6; audit test |
| 2.2 extra, override, lock gone; installs succeed | 2 Packaging | fresh-venv pip install . / '.[semantic]' |
| 2.3 tests deleted or renamed; audit regenerated | 2 Tests, Module audit | test_module_audit.py |
| 2.4 search returns only retirement notes | 2 Search | test_no_harnessx_references.py |
| 3.1 baseline without the extra | 3 | commit 1 record |
| 3.2 byte-identical after, exclusions written | 3 Comparison | commit 7 record |
3.3 [harnessx] loads with one warning |
1 | test_config.py |
| 4.1-4.3 docs, retirement notes, Kiro out of user docs | 7 | commit 8 review |
| 5.1-5.2 order, green steps, no deletion before replacement | Commit sequence | per-commit gates |
| 6.1 store keyed by repo + pin, fact reuse | 4 | test_plane_memory.py, test_plane_remember.py |
| 6.2 deterministic proposals through validator and gate | 5 | test_improve_memory.py |
| 6.3 loop as a plane Run with per-task Models and budgets | 6 | test_plane_loop.py; ousast plane status attribution |
| 6.4 provenance and train-on-test guard | 5 | test_improve_memory.py |
Commit sequence (Requirement 5), mapped to future tasks
Each commit is gated on the full suite's own exit code, ruff and mypy.
- Baseline (3.1): the equality script and
benchmarks/measurements/<date>-harnessx-removal-baseline/, recorded with the extra uninstalled. - Memory store and
remember(6.1):plane/memory.py,tasks/remember.py,reconciler.mark_done, seed and ingest wiring incli._plane, generator annotations, fixture tests. - Memory proposer (6.2, 6.4):
improve/memory.py,run_round(proposals=...),ousast improve --memory, sidecar and journal evidence, guard tests. - Loop Run (6.3):
alerts,measure,proposeandimprovetasks,--loopgeneration, fixed-pin Workspaces, fixture tests. - Retire the scan paths (1, 3.3): the
cli.py,fusion.py,verify_judge.pyandstage_processors.pychanges,RetiredConfigError, the one warning, and the test renames that follow. At this point nothing importsharness_ext/hunter_harness. Their replacements (plane tasks, and the memory proposer from 2-4) have landed. - Delete code and packaging (2): the two modules, the extra, the mypy override,
uv.lock, the deleted tests, the fixture rename, audit rows and the manifest regeneration, and the reference search test. - Equality proof (3.2): rerun the script, commit the comparison record next to the baseline, and link it in the commit message.
- Docs and specs (4): the README,
docs/examples.md,docs/threat-model.md, retirement notes, steering and release notes.
Risks
- Existing users of the LLM scan paths. There is no
--llmflag. Users who set[models] verifieror[fusion] panel_modelget a hard error, which is intended (1.3). Users who installed the extra and set[models] hunterlose the second (HarnessX) hunter without an error. Only the release note covers that, because the key is still valid. No measurement suggests either path found anything the remaining paths miss. The absence of a measurement is itself the risk. - Plane precision is unsolved.
plane-increment-2.jsonagrees 28 candidates, of which 16 are declared sites. The other 12 are unadjudicated and several may be false. M1 treats arejectedfinal as negative evidence, while plane recall is 16/20, so "rejected" is not "safe". The thresholds (2 or more repositories, zero agreed hits) and the unchanged recall/FP gate are the guard. Precision work (judge design) remains out of scope. - Evidence is thin. Today's store would hold 15 cases from one validation population, which is exactly the
population the guard excludes for its own qualification. Expect
no_proposalsuntil a second population has run through the loop. - Disk. The home volume is 98% full (2.4 GB free). One validation-46-sized Run adds about 3 MB of facts (content-addressed; about 0.1 MB when pins are unchanged) plus about 0.5 MB of rows. Ingest refuses below 1 GiB free. The store has no eviction in this spec.
- The baseline measures the environment as well as the code.
.venvhasharnessxinstalled today. A baseline taken without re-syncing would include it. The script'sfind_specand freeze checks exist for that. - Seeding writes reconciler state from outside the reconciler. It is confined to
mark_doneunder the run lock. The reconciler's line budget (465 of 500) leaves no room for fact reuse inside the reconciler.