spec(ai-service-plane): switch to ax completely; routing and token attribution requirements

Maintainer decisions 2026-09-29: ax (kind + Agent Substrate + ax controller on
this host) is the only executor; the Run layer carries dependencies, artifacts,
budgets and attribution until ax has them. Requirement 7 declares routing per
Task (one Model per Task, no runtime switching) and a token attribution table
from provider usage fields. Task 0 (bring-up, doctor), runner artifact delivery,
ax-backed reconciler with a fake-ax test seam. harnessx-removal brief records
the HarnessX inventory; removal is gated on the increment's measurement.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-29 17:42:39 +02:00
co-authored by Claude Fable 5.1
parent 152728c052
commit 6884bc8c2c
5 changed files with 141 additions and 44 deletions
+21 -12
View File
@@ -2,9 +2,10 @@
## Overview
A thin service plane under `src/openultrasast/plane/`: manifest schemas in ax's shape plus a `Run` kind, a local
reconciler that executes a Run's tasks in dependency order with persisted status and resume, a runner that
honours ax's contract, and task entrypoints. The pipeline's logic lives only in the task entrypoints; the
A thin service plane under `src/openultrasast/plane/`: manifest schemas in ax's shape plus a `Run` kind, an
ax-backed reconciler that submits a Run's tasks to ax on this host's kind cluster in dependency order with
persisted status and resume, a runner that honours ax's contract and delivers artifacts, and task entrypoints.
ax (with Agent Substrate) is the only executor; there is no local subprocess path. The pipeline's logic lives only in the task entrypoints; the
reconciler and runner know nothing about prompts, models or scoring (Requirement 3.4). The first increment moves
`repo-facts` and `verify` (plus a model-free `agree` step) and measures them on the 46-candidate set
(Requirement 6); the other concerns stay as scripts until that measurement is recorded.
@@ -56,13 +57,19 @@ into the bound path (locally a `git archive` from the case cache, as `evaluate.e
(`openultrasast.plane.tasks.<name>`). With `AX_RUNNER_HTTP=1` it serves `/healthz` and `/readyz` (503 until
workspaces are ready) on port 80 in a thread; locally the flag is unset (Requirement 4.2).
### Reconciler (`reconciler.py`)
### Reconciler (`reconciler.py`, ax-backed)
`ousast plane run <Run.yaml>` and `ousast plane status <run>`. State: `state.json` with per-task status in
`pending | running | suspended | done | failed | unfinished`, timestamps, spend. Execution: topological order;
tasks sharing a `serialize` label never overlap; everything else may run in parallel up to `--workers`. A task's
outcome is read from its `summary.json`: `done` only when the task says every unit finished; `unfinished` on a
budget stop; `failed` on an account or authentication error or a crash. On rerun, `done` tasks are skipped and
`ousast plane run <Run.yaml>`, `ousast plane status <run>` and `ousast plane doctor`. State: `state.json` with
per-task status in `pending | running | suspended | done | failed | unfinished`, timestamps, spend. Execution:
topological order; for each ready task the reconciler renders the Task manifest with the run's env
(`OUSAST_BUDGET_*`, `OUSAST_INPUT_*`, `OUSAST_OUTPUT_DIR`, `OUSAST_ARTIFACT_URL`), applies it and its
Workspaces through the `ax` CLI (`ax apply`, `ax get`, `ax delete`; the CLI path is injectable so tests use a
fake), and polls ax's status. Artifacts: the reconciler serves a small HTTP receiver on the host (reachable from
kind pods via the node's host gateway); the runner posts the output directory there at exit. Tasks sharing a
`serialize` label never overlap; everything else may run in parallel up to `--workers`. A task's outcome is
read from its delivered `summary.json`: `done` only when the task says every unit finished; `unfinished` on a
budget stop; `failed` on an account or authentication error, a crash, or a missing delivery. `status` prints
the token attribution table (Requirement 7.3) from the delivered summaries and writes `attribution.json`. On rerun, `done` tasks are skipped and
the others re-executed; per-unit resume is the task's own duty (Requirement 2.2). No prompts, scoring or model
calls anywhere in this module; a test counts its lines.
@@ -125,9 +132,11 @@ plus the per-candidate cost, turns and the declared-site match (`evaluate.matche
- `tests/test_plane_manifests.py`: valid ax manifests load; an unknown field, a bad provider without the
annotation, and a Run with a cycle are rejected with the field named.
- `tests/test_plane_reconciler.py`: stub tasks (shell commands writing `summary.json`) in a DAG: order,
`serialize` non-overlap, skip-on-rerun, `unfinished` and `failed` propagation, the lock; a test asserts the
module is under 500 lines.
- `tests/test_plane_reconciler.py`: a fake `ax` CLI (a script recording `apply`/`get`/`delete` calls and
posting scripted `summary.json` deliveries to the receiver) drives a DAG: order, `serialize` non-overlap,
skip-on-rerun, `unfinished` and `failed` propagation, missing delivery, the lock, the attribution table; a
test asserts the module is under 500 lines. `tests/test_plane_ax_live.py` (marker `ax`, skipped unless
`ousast plane doctor` passes) applies one trivial Task on the real cluster.
- `tests/test_plane_runner.py`: the env contract on a fixture workspace; `/readyz` 503 then 200 with the flag.
- `tests/test_plane_repo_facts.py`: a three-file tree; callers found across files, none from tests or vendor;
identical output on a second run.
+50 -12
View File
@@ -7,20 +7,28 @@ one long conversation, each with its own resume, budget and pass logic. Measured
tokens are tool I/O around those scripts, and verification re-derives the same per-repository facts in every
hunt and every pass. This feature restructures the pipeline as a Kubernetes-style service plane at the model
level, in the shape of google/ax: declarative `Task`, `Workspace` and `Model` manifests, a reconciler that
executes them in isolation with resume, and a runner contract. ax's software (Kubernetes, Agent Substrate) is a
later deployment target; its design is adopted now (`brief.md`).
executes them in isolation with resume, and a runner contract. The plane runs on ax itself: a `kind` cluster
with Agent Substrate and the ax controller on this host (maintainer decision 2026-09-29: "switch to ax
completely now"). What ax does not yet carry -- task dependencies, artifacts, token budgets (on ax's roadmap),
a DeepSeek provider -- is carried by our thin `Run` layer and dissolves into ax as ax gains it.
## Boundary Context
- **In scope**: the manifest kinds and a `Run` kind for dependencies, artifacts and budgets; a local reconciler
for this host (one engine container at a time); the runner contract; the first two tasks, `repo-facts` and
- **In scope**: the manifest kinds and a `Run` kind for dependencies, artifacts and budgets; bring-up of ax on
this host (kind + Agent Substrate + ax controller) with a doctor command; an ax-backed reconciler that
submits Tasks to ax (one engine-bearing task at a time) and is the only executor; the runner contract and a
runner image; the first two tasks, `repo-facts` and
`verify`, with the existing scripts as reference behaviour; measurement on the 46-candidate validation set.
- **Out of scope**: running on ax or Kubernetes; changing what the model is asked (prompts, families, pass rules)
- **Out of scope**: a remote or multi-node cluster; contributing providers or budgets to ax upstream (a
follow-on once the increment is measured); changing what the model is asked (prompts, families, pass rules)
except as required to consume `repo-facts`; the judge stage, the config-family question and population v3
(they become tasks later, under their own specs); any change to `evaluate.py`'s protocol scoring.
- **Adjacent expectations**: the independent-population protocols and records stay as they are; `finding_dump`
and the engine path are untouched; `vulnerabilities-over-plumbing` applies -- this is plumbing that must pay
for itself in measured tokens and unchanged or better recall.
- **Target state (maintainer, 2026-09-29)**: this plane replaces HarnessX as the agentic plane; HarnessX is
removed by its own spec (`harnessx-removal`) once Requirement 6's measurement shows value; the `Run` layer
shrinks as ax gains dependencies, budgets and artifacts. No new HarnessX feature is added meanwhile.
## Requirements
@@ -55,19 +63,25 @@ without ever recording partial work as a result.
4. A task's spend is metered from the provider's usage fields and stops the task at its budget; a task with no
priced model reports "unpriced", not zero.
### Requirement 3: A local reconciler executes a Run on this host
### Requirement 3: An ax-backed reconciler executes a Run on this host's ax
**User Story:** As the maintainer, I want to run a whole pipeline with one command on the laptop, with the same
manifests a cluster would take.
**User Story:** As the maintainer, I want to run a whole pipeline with one command on the laptop, with every
task executed by ax on the local cluster, so that the same manifests move to a bigger cluster unchanged.
#### Acceptance Criteria
1. The reconciler executes tasks in dependency order, at most one engine-bearing task at a time, and passes
artifacts by path.
1. The reconciler submits each task to ax as a `Task` (with its bound `Workspace`s and `Model`) in dependency
order, at most one engine-bearing task at a time, reads task outcome from ax's status plus the task's
delivered `summary.json`, and passes artifacts between tasks by placing a producer's outputs where the
consumer's manifest expects them. It never executes a task as a local subprocess; ax is the only executor.
2. Task status (`pending`, `running`, `suspended`, `done`, `failed`, `unfinished`) is persisted and shown by
one status command.
3. A suspended or interrupted run resumes from persisted status (Requirement 2.2) with the same command.
4. The reconciler is under 500 lines and holds no pipeline logic: no prompts, no scoring, no model calls.
5. `ousast plane doctor` verifies the host's ax in one command: kind cluster reachable, Agent Substrate control
API up, ax controller ready, runner image loaded; each check names what is missing and how to bring it up.
6. Bring-up on this host (kind + Agent Substrate + ax) is scripted, documented with its measured memory and
CPU footprint, and smoke-tested with one trivial Task before any pipeline task runs.
### Requirement 4: The runner contract is ax's
@@ -78,8 +92,12 @@ later.
1. An entrypoint receives its task and workspace manifests as YAML in `AX_TASK_YAML` and `AX_WORKSPACES_YAML`
and takes nothing else from the command line except what the manifest's `command` states.
2. The default image serves `/healthz` and `/readyz` when run as a container; locally these may be no-ops.
2. The runner image serves `/healthz` and `/readyz` on port 80, `/readyz` returning 503 until workspaces are
materialised, as ax's runner contract requires; the image contains this package and is loaded into kind.
3. One fixture test per task type runs the entrypoint against a small workspace and checks its artifacts.
4. Because ax has no artifact channel, the runner delivers the task's output directory (including
`summary.json` and `units.jsonl`) to the reconciler's artifact endpoint named in `OUSAST_ARTIFACT_URL`
before it reports completion; a delivery failure is reported as `failed`, never as done.
### Requirement 5: `repo-facts` is the memory layer
@@ -101,9 +119,29 @@ before the remaining concerns are converted.
#### Acceptance Criteria
1. `repo-facts` + `verify` run on the 46-candidate validation set through the reconciler, two passes, and
1. `repo-facts` + `verify` run on the 46-candidate validation set through the reconciler on ax, two passes, and
report cost per candidate, tool turns per hunt and declared sites agreed.
2. Acceptance: declared sites agreed at least 16 of 20 (the current value) and cost per candidate below the
current $0.022 for two passes; otherwise the increment is reported as not met and the design revisited.
3. The development transcript measure (`benchmarks/dev/token_report.py`) is run at the end of the increment and
recorded next to the result.
### Requirement 7: Routing is a declared concern of the plane
**User Story:** As the maintainer, I want which model serves which concern to be a manifest decision with a
budget, not a prompt trick inside one conversation, so that token spend is structured by component.
#### Acceptance Criteria
1. Every `Task` in a `Run` binds exactly one `Model`; a Run may bind different Models to different tasks
(a cheap model for reads and triage, a stronger one for verification and judgement), and the reconciler
passes the bound Model's identity and prices to the task.
2. A task never chooses or switches its model at runtime; the Model it was bound to is recorded in its
`summary.json`.
3. `ousast plane status <run>` prints a token attribution table: one row per task and Model binding with
calls, input tokens, cache-hit tokens, output tokens and USD, a total row, and a per-Model subtotal; every
number comes from the provider's usage fields recorded in the tasks' `summary.json`, never estimated. A
`--units` flag breaks a task down per unit (file or candidate) from `units.jsonl`. The same table is
written to `attribution.json` in the run directory so runs can be compared.
4. A task that needs a second model for a sub-step (a judge over a verifier's output) is a separate task with
its own Model binding, never a second client inside one task.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"feature_name": "ai-service-plane",
"created_at": "2026-09-29T15:40:00Z",
"updated_at": "2026-09-29T16:40:00Z",
"updated_at": "2026-09-29T17:55:00Z",
"language": "en",
"phase": "implementation",
"approvals": {
+40 -19
View File
@@ -1,9 +1,20 @@
# Implementation Plan
Order follows the dependency chain: manifests -> budget -> runner -> reconciler -> tasks -> increment run ->
measurement. Every task ships with its tests; a task is done when its tests pass in the host suite, ruff and
Order follows the dependency chain: ax bring-up (parallel with 1-2) -> manifests -> budget -> runner ->
ax-backed reconciler -> tasks -> increment run on ax -> measurement. Every task ships with its tests; a task is done when its tests pass in the host suite, ruff and
mypy are clean, and the commit is gated on pytest's own exit code. Script-level pipeline work stays paused.
- [ ] 0. ax bring-up on this host
- Install `kind`, `kubectl`, the `ax` CLI and `ko` (user-local). `ops/ax/up.sh`: create the kind cluster and
install Agent Substrate with its `hack/create-kind-cluster.sh` and `hack/install-ate-kind.sh
--deploy-ate-system`, deploy the ax control plane with `ko` to the kind registry, apply one trivial Task
and wait for it to finish; `ops/ax/down.sh` tears it down. Record the measured idle footprint (RSS, CPU)
in `ops/ax/README.md` next to the 7 GB host constraint.
- `ousast plane doctor` (in `reconciler.py` or a sibling `doctor.py`): the four checks of Requirement 3.5.
- Stop rule: if the cluster does not come up within one working session, report the exact failure and stop;
the decision to work around it is the maintainer's.
- _Requirements: 3.5, 3.6_
- [ ] 1. Manifest schemas and validation
- Add `pyyaml` as a declared dependency in `pyproject.toml`.
- `src/openultrasast/plane/manifests.py`: dataclasses and loaders for `Task`, `Workspace`, `Model`
@@ -23,20 +34,29 @@ mypy are clean, and the commit is gated on pytest's own exit code. Script-level
- [ ] 3. Runner contract
- `src/openultrasast/plane/runner.py`: read `AX_TASK_YAML` / `AX_WORKSPACES_YAML`; materialise `git`
entries at the pinned commit from the local case cache and write `spec.files`; execute `spec.command`
by importing `openultrasast.plane.tasks.<name>`; optional `/healthz` `/readyz` server under
`AX_RUNNER_HTTP=1`; exit codes 0 done, 2 failed, 3 unfinished.
- `tests/test_plane_runner.py`: the env contract on a fixture workspace; readyz 503 then 200.
- _Requirements: 4.1, 4.2, 4.3_
by importing `openultrasast.plane.tasks.<name>`; `/healthz` `/readyz` server on port 80 (503 until
workspaces are ready; disabled only under `AX_RUNNER_HTTP=0` for unit tests); at exit, POST the output
directory as a tar stream to `OUSAST_ARTIFACT_URL` and report `failed` if delivery fails; exit codes
0 done, 2 failed, 3 unfinished. `plane/Dockerfile.runner`: this package on Python 3.12, installed to
`/usr/local/bin/ax-task-runner`, loaded into kind by `ops/ax/up.sh`.
- `tests/test_plane_runner.py`: the env contract on a fixture workspace; readyz 503 then 200; delivery to a
local receiver; delivery failure yields `failed`.
- _Requirements: 4.1, 4.2, 4.3, 4.4_
- [ ] 4. Local reconciler and CLI
- `src/openultrasast/plane/reconciler.py` (under 500 lines, no pipeline logic): `state.json`, statuses,
topological execution, `serialize` labels never overlapping, `--workers`, budgets and artifact paths
passed as task env, outcome read from `summary.json`, skip `done` on rerun, PID lock per run.
- `ousast plane run <Run.yaml>`, `ousast plane status <run>`, `ousast plane workspaces <population.toml>`
(generates Workspace manifests per case pin).
- `tests/test_plane_reconciler.py`: stub tasks in a DAG: order, non-overlap, skip-on-rerun, `unfinished`
and `failed` propagation, the lock, and the line-count assertion.
- _Requirements: 3.1, 3.2, 3.3, 3.4, 2.2_
- [ ] 4. ax-backed reconciler and CLI
- `src/openultrasast/plane/reconciler.py` (under 500 lines, no pipeline logic, no local subprocess
executor): `state.json`, statuses, topological submission through the `ax` CLI (path injectable), status
polling, the artifact HTTP receiver, `serialize` labels never overlapping, `--workers`, budgets and
artifact env passed in the rendered Task manifest, outcome read from the delivered `summary.json`, skip
`done` on rerun, PID lock per run, `ax delete` of finished Tasks.
- `ousast plane run <Run.yaml>`, `ousast plane status <run>` (token attribution table per task and Model
from the tasks' `summary.json` usage; `--units` from `units.jsonl`; also written to `attribution.json`),
`ousast plane workspaces <population.toml>` (generates Workspace manifests per case pin).
- `tests/test_plane_reconciler.py`: a fake `ax` CLI drives a DAG: order, non-overlap, skip-on-rerun,
`unfinished` and `failed` propagation, missing delivery, the lock, the attribution table, and the
line-count assertion. `tests/test_plane_ax_live.py` (marker `ax`, skipped without a live cluster): one
trivial Task end to end.
- _Requirements: 3.1, 3.2, 3.3, 3.4, 2.2, 7.1, 7.3_
- [ ] 5. `repo-facts` task
- `src/openultrasast/plane/tasks/repo_facts.py`: product files, functions per file (per-language
@@ -50,23 +70,24 @@ mypy are clean, and the commit is gated on pytest's own exit code. Script-level
- `src/openultrasast/plane/tasks/verify.py`: the batched hunt moved from `verify_batched.py` (one hunt per
file, up to 6 candidates, 6 steps, candidate-anchored sites) with known callers from `facts.json` in the
prompt, tool turns and usage per hunt in `units.jsonl`, per-unit resume, `pass` from env, budget wrapper
from task 2; `summary.json` never `done` unless every unit finished.
from task 2; `summary.json` never `done` unless every unit finished, and records the bound Model and
the summed usage fields (prompt, cache-hit, output tokens, calls, usd).
- `src/openultrasast/plane/tasks/agree.py`: agreement across pass outputs, per-candidate cost and turns,
declared-site matching via `evaluate.matches_v2`.
- `tests/test_plane_verify.py`: scripted client; prompt carries the callers; resume asks only the
unfinished file; a scripted 402 yields `failed`; agree on two scripted passes.
- _Requirements: 5.3, 2.1, 2.2, 2.3_
- _Requirements: 5.3, 2.1, 2.2, 2.3, 7.2_
- [ ] 7. Manifests for the increment
- `plane/models/deepseek-flash.yaml` (annotated extension, prices in parameters);
`plane/tasks/repo-facts.yaml`, `plane/tasks/verify.yaml`, `plane/tasks/agree.yaml`;
`plane/runs/validation-46.yaml`: repo-facts -> verify pass a, verify pass b -> agree, budgets per task,
inputs from the recorded candidate and triage artifacts.
- Workspaces generated for the 15 validation cases' vulnerable pins.
- Workspaces generated for the 15 validation cases' vulnerable pins; the runner image rebuilt and loaded.
- _Requirements: 1.1, 1.2, 6.1_
- [ ] 8. Measurement of the first increment
- Run `validation-46` through the reconciler (needs DeepSeek credit); record cost per candidate, tool turns
- Run `validation-46` through the reconciler on ax (needs DeepSeek credit); record cost per candidate, tool turns
per hunt, declared sites agreed, against `verifier-batched-check-2026-09-29.json`.
- Run `benchmarks/dev/token_report.py` on the increment's development session.
- Record `benchmarks/independent/plane-increment-1.json` with the gate result (met or not met) and commit;
+29
View File
@@ -0,0 +1,29 @@
# Brief: remove HarnessX after the plane proves value
Status: brief only, gated. Not started until `ai-service-plane` Requirement 6 records a met gate.
## Inventory (read-only, 2026-09-29)
- Code: `src/openultrasast/harness_ext.py` (97 lines) and `hunter_harness.py` (197) exist only for HarnessX;
guarded branches in `cli.py` (372-512), `fusion.py` (LLM panels, ~100 lines), `verify_judge.py` (judge path,
~90), `stage_processors.py` (`host_under_harnessx`, ~40), `config.py` (`HarnessxConfig`). About 575 of
30,243 lines (1.9%). Every path falls back deterministically when the extra is absent and records a
degradation (`harnessx_extra_unavailable`; fallbacks `run_hunter_pool`, `structural_verifier`,
`deterministic_panels`). `evolve`, `regress`, `benchmark`, `gate` and quick mode do not depend on it.
- Packaging: `pyproject.toml` extra `harnessx` pinned to a git commit; mypy override; `uv.lock` entry.
- Tests: 14 files touch it; dedicated `test_harness_ext.py`, `test_hunter_harness.py`, `test_cli_hx_dispatch.py`,
`test_verify_judge.py`; `tests/conftest.py` fixture `assert_cold_of_harnessx`.
- Docs: README sections "Configuring the HarnessX agentic plane" and "The HarnessX self-improving cycle";
`docs/examples.md` section 6; `docs/threat-model.md` egress and spend caps.
- Specs: `harnessx-self-improving-rulesets` (approved, 43/43 done, Requirement 1 is direct adoption);
`learning-harness` (30/33); `three-stage-scan` framed as its follow-up; `openrouter-sast-harness` (no
spec.json); mentions in four more; steering `overview.md:175`.
## Shape of the removal
1. Replace the three guarded paths with plane tasks (hunter pool -> `verify`; judge -> `judge`; panels ->
`judge`/`agree`) so their fallbacks become the only path, then delete the two modules, the config block,
the extra, the override and the fixture.
2. Retire the four specs' HarnessX requirements with a dated note, not a rewrite; update README and docs.
3. Gate: the plane increment met (recall and cost), and the replaced features measured no worse on the
development pins before deletion.