Orchestrata Ultimate › Plans › test-run-ledger-design

Test-run observability — event stream, live view, durable ledger

Source: plans/test-run-ledger-design.md at 8a2cb42ad · last changed 2026-08-20 · first committed 2026-08-20 · 1224 lines.

Prose pending.

Plan text

Test-run observability — event stream, live view, durable ledger

STATUS: requirement captured, design forks open. Written in plan mode from injected context only — every "exists" claim below is from docs/CODEBASE_MAP.md one-line entries and memory, NOT from opening the files. First action on exit is to verify the inventory.

Context — why

Asked "walk me through the last test run", the honest answer was almost entirely "not recorded". Concretely, from the artifacts injected this session:

Field evidence — authoritative full-suite run, 2026-08-20

Kernel started + gRPC-verified, all safety gates passed, so this run is authoritative. It supplied measured support for several requirements that were previously only argued.

Baseline: 34,414 passed · 1 failed · 0 errors · 0 skipped · 1665/1666 modules · 1196s (19.9m), exit 1.

Observation Bearing
306 modules produced NO RESULT on the first pass — "306 passed on rerun, 0 still failed" 18% of the suite silently yielded nothing, rescued only because a rerun round exists. Largest single lead on 19.9m vs a documented ~480s test phase
Evidence for the one failure was DELETED — /tmp/pte_junit_*/ removed at run end; the captured stdout that would separate a nested-runner timeout from an _check_infra() abort no longer exists The strongest argument in this document for R2's content-addressed sidecar. One failure in 34,414 tests, and its diagnosis was garbage-collected ~20 min later
Container CPU 1307.15 core-s over 1196.5s wall (memgraph 1265.51 ≈ 1.06 cores continuously, redis 41.64) SPEC-113 FR-005 paid off on its first observed run — the previously unmeasured floor, now a number
Solo retry: 14 batch failures → 13 recovered, cost 193 tests executed twice + 53.6s Retry tail is 4% of the run, NOT the slowness explanation. My live "serial tail" inference was wrong and this quantifies it
The runner itself labels those 13 "load/order flakes" The exact loose usage the lexicon replaces. Recovering in isolation makes them order_dependent_failure / load-dependent — deterministic bugs, not flakiness
5 tests over 60s: one real (test_check_feature_loss… at 113.7s), four at exactly 60.1s Four tests agreeing to a tenth of a second is a 60s timeout, not slowness — a distinction "slow" cannot express, per the lexicon
solo_retry_cap is 25 in the live invocation The signature default of 5 is not what runs. Corrected

The one failure, and why it matters here. test_run_tests_main_infra.py::test_main_with_real_infra_check_and_skip_gates — assert rc == 0 got 1; failed in batch and on solo retry. Re-run on an idle box: passes in 12.80s against its 30s budget (~2.4× headroom, which suite load exceeded). So: environment_dependent_failure, load-dependent — not flaky.

Cause not isolated: a nested-runner timeout and an _check_infra() abort under load both return 1 (test_main_infra_check_failure_returns_1 confirms the latter is an expected path), and the discriminating output was deleted with the temp dir. Fix applied: the test now reads capsys before asserting on rc, so a future failure is self-diagnosing. Verified 6 passed.

Diagnosing one failure required the verbatim error, the load context at the time, and a comparison run — three things no artifact currently retains.

Requirement (Mohamed, session 266)

  1. Test runs emit EVENTS — the output is a stream, not inherently a file. Named by Mohamed: test started (identifying which test), test stopped, test pass, test failed, test error. Redis is the obvious carrier — it is already the project's queue/broker.
  2. A durable record per run, appended, never overwritten — a fold over the event stream, carrying:
    • run id, start ts, end ts, wall duration
    • scope: full suite | --affected subset | named modules | single module, and the actual selection (module list / selector args / commit)
    • collected (Y), passed, failed, skipped, errored, xfail/xpass
    • list of errors; list of warnings
    • commit sha, env profile, worker count, whether the kernel was started+verified
  3. Autonomous run — once scope is selected the run proceeds unattended.
  4. Live view while running: processes/threads in flight, how many, which tests are executing now, running tallies. Must answer mid-run: "full suite, started 11:04, 6m in, 8,412 of 29,100 tests, 8,390 pass / 2 fail / 20 skip, 8 workers busy."
  5. Per-test durations joined to run id → slowest/fastest, and deltas across runs so performance regressions from code changes are visible.

Event model (proposed)

The five events Mohamed named, plus the minimum extras the stated queries require. test_started/test_stopped bracket timing; outcome is a separate fact.

Event Payload Why required
run_started run id, scope kind + selection, commit, profile, worker count, kernel-verified flag Scope must exist from event #1, else fragments look authoritative
collection_complete collected count Y, module count The denominator for "X of Y"
worker_started / worker_stopped worker id, pid, thread id Answers "how many processes and threads"
test_started nodeid, worker, ts "what is running now"
test_stopped nodeid, worker, ts, duration Per-test timing → slow-test comparison
test_pass / test_failed / test_error / test_skipped nodeid, outcome, head (first 2 KB of longrepr), longrepr_ref (sha256), reason Tallies + failure list; skip is required by the stated summary line. Full text lives in a content-addressed sidecar — R2
warning category, message, nodeid Warnings are in the requirement and recorded nowhere today
heartbeat ts, tallies Distinguishes a stalled run from a dead one
phase_started / phase_ended phase name (preflight, gates, parallel, retry_tail), ts Per-RUN phase boundaries — see below
run_finished exit code, final tallies, end ts Terminal fold; must also fire on kill/timeout

Per-run phase timing is required, and total wall time is not enough (observed live 2026-08-20). The evidence is a failed inference, which makes the case stronger than a successful one would have.

During this session's authoritative run, load moved 5.19 → 2.44 → 5.75 on 12 cores. At the 2.44 sample I concluded the parallel phase had ended and a serial solo-retry tail had taken over (_run_suite(..., solo_retry_cap=5)). The next sample refuted it: load recovered, so the dip was almost certainly a batch boundary — workers cycling between batches, of which there are ~48 (8 workers × 6 batches).

Run phase could not be reliably inferred from the only live signal available, and a real attempt to infer it produced a wrong answer within three minutes. That is the argument for recording phase boundaries as events rather than deriving them.

A 480s parallel phase + 40s tail and a 480s parallel phase + 450s tail have the same total wall time and completely different diagnoses. Today the only way to tell them apart is watching loadavg in real time, which survives nothing. So the run record carries phase boundaries, not just a total.

Note this is distinct from SPEC-113 FR-002, which retains per-test phases (setup/call/teardown). This is per-run phases, and nothing records it.

Two projections over one stream:

A killed run still yields a well-formed partial record, because the stream is the truth and the fold is replayable.

Payload bounding — verbatim WITHOUT unbounded events (resolves R2). A module-level import error fails every test in the module with the same traceback; 1,000 failures × ~10 KB longrepr is ~10 MB in one run, which would trim the stream mid-run and break late-joiner accuracy. So the event carries a 2 KB head plus a sha256 reference; the full text is written once to a content-addressed sidecar under reports/test-run-artifacts/<sha256> — the same pattern codemod's content-addressed parse cache already uses. Identical tracebacks across a thousand tests therefore store once, which is the common case. Verbatim is preserved addressably; MAXLEN then bounds event count, not payload size, and byte-identity (V6) is asserted against the sidecar rather than the stream.

Unmanaged runs — scope is never simply absent (resolves R7). The plugin fires on every invocation, but only the runner supplies scope on run_started. The Bash guard is a PreToolUse hook and governs my tool calls only — not volks, CI, or a shell. So a run can reach the ledger without a runner. Such a run infers what it can from pytest's own config.args and is otherwise recorded explicitly as scope: unknown, authoritative: false, trigger: unmanaged. No consumer may present an unmanaged run as authoritative — this is the 17-test fragment's exact escape route, closed by naming it rather than leaving the field empty.

Emitter architecture — one drain thread, not one client per test thread (resolves R14). Tests run threaded under free-threaded 3.14t, in a codebase with a documented heap-corruption history under load. Test threads therefore push events to a bounded in-process queue; a single drain thread owns the Redis client and does every XADD. On queue overflow the emitter drops and records the drop count in the run record — degradation is always reported, never silent. *_NUM_THREADS=1 stays pinned.

Existing assets to reuse (VERIFY FIRST — map-level claims only)

Concern Existing Note
Event emitter core/threading/pytest_live_observer.py — "real-time, SIGKILL-durable" Already emits per-test events; the open question is the transport it writes to
Consumer scripts/observe_pytest.py list/attach; /opt/orchestrata-tools/bin/{pytest-live,observe-pytest} Reader half exists — extend, don't rewrite
Pytest-free reader tests/core/threading/test_pytest_live_reader.py Consumers must not import pytest
Multi-consumer broadcast tests/core/threading/test_pytest_session_broadcast.py A session-registry broadcast already exists
Redis transport Redis 10.53.2.11:6379 = "hot storage, queues, message broker"; gateway/ring_buffer.py (RedisRingBuffer), gateway/pubsub.py, postman/ topics, core/redis_factory.py Bounded replayable buffer + fan-out already built
Durations reports/test-durations.ndjson, scripts/test_durations.py tests/core/threading/test_durations_identity.py ⇒ run identity already on records
Runners scripts/run_tests.py (canonical, 8 workers), run_tests_streaming.py, run_tests_threadpool.py, core/threading/test_executor.py Four entry points — argues for emitting from the plugin, not each runner
Scope selection scripts/affected_tests.py, scripts/rts.py, scripts/schedule_tests.py These already know the scope — they must hand it to the ledger
Consumers to repoint scripts/error_report.py, claude/hooks/test_run_context.py Already inject into every session/prompt — upgrade, don't duplicate
Graph/UI knowledge_graph/test_results_store.py (inferred), gateway/test_runner.py, gateway/handlers/test_metrics_handler.py May already provide the queryable/UI half
Flake exhaust .flake_recoveries.ndjson, scripts/flake_report.py Join to run id rather than staying a separate stream

Decisions (settled by Mohamed, session 266)

  1. Event transport — Redis Streams. XADD/XREAD, replayable by a late joiner, MAXLEN trim, many simultaneous readers. Rejected: pub/sub (no replay — a mid-run attach sees nothing) and list queues (consumed once — one observer starves another). Reuse gateway/ring_buffer.py if it already wraps Streams.
  2. Emission point — the pytest plugin emits; the runner supplies scope. pytest_live_observer emits every event, so every invocation is recorded — bare pytest, all four runners, subagent runs. Runners pass scope/selection into run_started. One emitter; no runner can forget.
  3. Durable ledger — NDJSON first, async KG sync second. reports/test-runs.ndjson is the append-only durable log (matching the existing test-durations.ndjson convention); a separate idempotent job syncs into orchkg for querying. A graph outage must never fail a test run.
  4. Consumers wired in this cut — all four: claude/hooks/test_run_context.py (prompt banner), scripts/error_report.py (session red-list), the CLIs (observe_pytest.py live attach + test_durations.py comparison), and the gateway/UI (gateway/test_runner.py, handlers/test_metrics_handler.py).

Requirement block 2 (Mohamed, session 266) — discipline, enforcement, determinism

  1. Exact error messages, precise language. No paraphrase, no softening. Exact text as emitted.
  2. "Flake" is a defined engineering term and may not be used unless the definition is met. Same discipline for every other loose word.
  3. My output is reviewed by a Prolog ruleset — the vocabulary/claims gate.
  4. I am reminded of the rule list on every test run.
  5. No random bash.
  6. After "run tests", pytest may ONLY be invoked through the test runner, which is a versioned submodule of codemod.
  7. Runner parameters include profile yes/no and trace yes/no.
  8. "Tell me about the last test run" is answered deterministically from the ledger + Prolog: the time, the scope, the last full run, the last scoped run and its scope, the wall time, and whether a gate ran it.
  9. Session start makes me aware of these rules at skill + context level, and they are simultaneously enforced deterministically.
  10. I may fix and add features to the runner module — versioned, under codemod.
  11. Context injection + natural-language query, scoped to the project — resolved from cwd, or named explicitly when several are in play. Events, queries and data are all project-aware.
  12. An explicit policy for what lives in the KG vs. what lives in logs.
  13. Prolog answers these questions deterministically, reaching the data via Python atoms where needed, and the answers are injected into my context and into agent context, with agents scoped to running the tests.
  14. Runs are threaded, under all existing test-run rules.

The lexicon — words that must be earned

Single source: src/orchestrata_ultimate/prolog/test_lexicon.pl, generated the way prolog/intent_lexicon.gen.pl already is. A term may be used only when its predicate succeeds against the ledger.

flaky(Project, NodeId) holds only when outcomes differ across ≥2 executions identical in all six of: nodeid · source commit · env profile · scope selection · worker count · scheduling mode — with no write to any tracked file in the test's impact set (code_intel.py impact) and no change to any generated or untracked artifact in that set between them, over ≥N repetitions. Anything short of that is one of:

Why six and not four (resolves R12): two runs at the same commit with different concurrency are not identical executions, so the original four-part tuple would have classified a genuine concurrency-dependent failure as flaky — misclassification by the very definition whose purpose is precision. And "same commit" already implies no tracked-file writes, so the write clause only earns its place once it is split to cover generated and untracked artifacts, which a commit hash does not constrain.

Correct term Meaning
failed One failing execution. No comparison run exists.
failed_then_passed_on_retry Recovered inside one run — what .flake_recoveries.ndjson records today. Not flakiness.
unreproduced_failure Failed once, passed on re-execution at the same commit, below N repetitions.
order_dependent_failure Outcome depends on execution order or shared state. A deterministic bug, the test-isolation class — routinely and wrongly called "flaky".
concurrency_dependent_failure Outcome depends on worker count or scheduling mode. Deterministic given the concurrency, therefore not flaky.
environment_dependent_failure Outcome correlates with profile/host/load, not with nondeterminism.

Also gated: "hung" (use no_event_for(N seconds); distinguish stalled from dead), "slow" (requires a measured threshold and the number), "transient" (must name the condition), "intermittent", "spurious", "mostly green", "should pass". Extends the existing weasel-word ban (problematic/issue/suboptimal → failed/bug/wrong).

Error text is quoted verbatim from the event payload. Summarising an assertion message is a gate violation, not a style choice.

Enforcement — aware AND enforced, from one artifact

The banner I read and the gate that blocks me read the same rulebase, so they cannot drift (the loophole scripts/rulings_lint.py exists to close; drift gated the way check_generated_artifacts.py does).

One ruleset, two languages — resolved by generation, not by assertion (R8). The rules live as Python checks (verification/review/checks_*.py, scripts/code-review.py) and as Prolog (test_discipline.pl, test_lexicon.pl). Two implementations of one ruleset is exactly the drift this section claims to prevent, so the claim is made true rather than asserted: the Prolog is generated from the Python checks' declared metadata, the way gen_intent_lexicon.py already generates lexicon facts, with regeneration gated by check_generated_artifacts.py (RUL-030). A differential test covers the entire rule set, not just flaky/2 — every rule runs through both implementations over the shared corpus and must agree.

The claims gate is three-valued (R3). Absence claims ("no run recorded a wall time") are checked by negation-as-failure, and negation-as-failure over an incomplete KB proves absence that is not true — certifying a false claim as verified, which is the false-confidence class this plan exists to kill, reintroduced at the layer meant to stop it. So an absence claim is certified only when run_ingested(RunId) holds for the relevant runs. Otherwise the verdict is unknown, and the gate permits the claim only if it is stated as unknown. Unknown must never collapse into false.

Rule Aware (skill/context) Enforced (deterministic)
pytest only via the runner test-loop skill, LEVEL0 PreToolUse(Bash) — extend force-test-observation.sh / route_pytest_to_threads.py
No random bash CLAUDE.md PreToolUse(Bash) allowlist
Lexicon + verbatim errors rules banner each run Stop hook → Prolog claims gate (precedent: no-guessing-stop.sh, forbidden-doubles-stop.sh)
Rule reminder every run runner prints it Runner refuses to start if banner ≠ rulebase
Agents scoped to their run gen_brief_pack.py claude/hooks/task_context_hook.py PreToolUse(Agent) injects ledger + rules + project scope

Runner — refactor/codemod/testrun/, versioned

Lives under the SPEC-110 codemod engine and rides its existing daemon (codemod/server.py JSON-RPC over Unix socket), warm workspace, CodeIntelligence Graph (instant affected-set selection) and 5-observer framework. Exposed as a codemod verb via scripts/codemod.py. Carries __version__; I may extend it.

Parameters — the two profiling senses are separate flags, so the ambiguity is removed at the CLI rather than by convention:

Flag Meaning
--project Project scope. Defaults to cwd resolution; raises if unresolvable
--scope {full,affected,modules,single} + selection What is being run
--cprofile {yes,no} Run under cProfile for CPU attribution
--env-profile {dev,test,prod} Infra profile (env/<name>.env)
--trace {yes,no} SETTLED (Group C). Sets ORCH_RCG_DIR per batch, enabling core/tracing/runtime_callgraph.py (dormant when unset). Output merges into plans/ast-ultimate.json via build_runtime_callgraph.py and feeds RTM evidence via attribute_traces_runtime.py. Never global — freshness_guard.py:368 already names this mode "instrumented suite run (per-batch ORCH_RCG_DIR env; never global)". NOT core/tracing/tracer.py spans, which are a separate observability axis
--workers N Thread count

Threaded, never xdist (banned in .claude/banned-libraries.txt; -n auto deadlocks ~78%) — via core/threading/test_executor.py ParallelTestExecutor. Worker events therefore carry thread ids as well as pids.

Deterministic answer contract

scripts/test_intel.py, built like code_intel.py / trace_intel.py / history_intel.py, over ground facts (Python atoms via janus; variable-bearing rules stay in .pl, only ground facts asserted):

test_run(RunId, Project, StartTs, EndTs, WallSeconds, ExitCode).
run_scope(RunId, ScopeKind, Selection).
run_totals(RunId, Collected, Passed, Failed, Skipped, Errored).
run_trigger(RunId, Trigger).        % interactive | gate(Name) | hook | ci | agent(Id)
run_commit(RunId, Sha).  run_profile(RunId, EnvProfile).
run_kernel_verified(RunId, Bool).
last_full_run(Project, RunId).  last_scoped_run(Project, RunId, ScopeKind).
test_outcome(RunId, NodeId, Outcome, DurationMs).
flaky(Project, NodeId, Evidence).   % only when the definition above is met

Every answer is provable via prolog/explain.pl — "why do you call that flaky" returns a derivation, not an opinion.

Data placement policy (req. 17)

Redis = what to watch · logs = what to grep · KG = what to join · Prolog = what to prove.

Store Holds Retention
Redis Stream, dedicated DB index live per-test events, in-flight + recent runs MAXLEN on event count, TTL hours
reports/test-runs.ndjson, test-durations.ndjson full-fidelity run records, per-test durations, warnings, error heads + refs unbounded, git-ignored
reports/test-run-artifacts/<sha256> verbatim longrepr text, content-addressed and deduplicated pruned with the ledger
orchkg one TestRun node per run (project-qualified id) + edges to Project, Commit, Gate; Test edges only for failures, errors and outcome transitions — never 29.1K per-test nodes per run kg_retention.py ages exhaust
Prolog KB ground facts derived from the above refreshed, freshness-gated

The event stream must not share fate with the system under test (resolves R4). Run events go to a dedicated Redis DB index outside the suite's per-slot allocation (the mapping asserted by tests/core/test_worker_database_mapping.py). This repo has already had the incident — a slot-0 flushdb wiped shared db0 mid-flight — and a run that can erase its own evidence loses exactly the data needed to diagnose what happened. The isolation is asserted, not assumed.

Project awareness (req. 16)

Every event, record and node id is project-qualified — extending the existing multi-project KG invariant (identity = project-qualified id, never a bare relative path). Project resolved by knowledge_graph/project_resolver.py from cwd; --project overrides; an unresolvable project raises — no default, per the no-infra-defaults rule.

Decisions block 2 (settled)

  1. Parameters — split flags: --cprofile, --env-profile, --trace.
  2. Claims gate blocks, on test-claim messages only. A Stop hook parses my message; if it asserts a test/run claim (numbers, outcomes, lexicon terms) it must satisfy the rulebase or the stop is denied and I revise. Prose with no test claim passes untouched.
  3. Bash: extend the existing deny guards, not deny-by-default. Add a rule denying pytest invoked outside the runner. Accepted trade-off: unknown-unknown commands stay permitted until a rule names them.
  4. Execution: the daemon orchestrates, the run is spawned. codemod does selection (warm CodeIntelligenceGraph), scheduling, IPC and event emission; the run executes in a spawned process with its own thread pool. A hang, segfault or env mutation cannot reach the daemon — material given the 3.14t heap-corruption history.

Deliverable 1 — the spec (authoritative contract)

This plan is the design record. The authoritative artifact is a numbered spec, because in this project specs outrank tests when resolving a design question and every gate downstream keys off requirement ids.

Number: next free. SPEC-112 is the highest I can see referenced (kimi/k3-spec-112); confirm the free number in Phase 0 rather than assuming.

Structure — Purpose · Scope and non-goals · Definitions (normative — the lexicon is part of the contract, not commentary) · FR-001..FR-0NN · NFRs · Data-placement policy · Interfaces (event schema, Prolog predicate surface, CLI surface) · Acceptance criteria · Test ids · Traceability.

Id grammar — canonical forms already enforced in this repo: REQ-NNN-MMM, stories REQ-NNN-US-NNN-MMM (canonical since the legacy-story-id migration), AC-NNN-*, TST-NNN-*, FR-NNN.

"Easy to verify" is mechanised, not asserted: every FR gets exactly one AC carrying a machine-checkable predicate with a number in it, and at least one TST-NNN-* id; the implementing symbol carries @traces(reqs=[...]). The spec is then gradeable by tooling that already exists — check_req_format.py, check_spec_authority.py (RUL-013), check_requirement_corpus.py, parse_spec_requirements.py, check-traces, trace_intel.py untraced-reqs, generate_rtm_evidence.py, verification/spec_completeness.py. An FR with no gradeable AC is a spec defect and fails the gate — which is exactly the "easy to validate" property, made enforceable.

Delivery workflow — executed BY orchestrata, via the volk pipeline

This plan is built by orchestrata itself, not by Claude subagents. It is the dogfood: the volk pipeline runs autonomously, I watch, and every defect the run exposes in orchestrata's own workflow is a finding to fix — that is a deliverable of this work, not a distraction from it.

All of it on one feature branch, merged and deleted at the end.

Stage Volk Output Gate to pass
0 Intake U:Cx Concierge → U:BA Analyst intent decomposed into slices Handoff sentence per the VP-A1/A2 chain
1 Spec U:BA Analyst → U:PM PM specs/SPEC-NNN-*.md, requirements + stories Req-format, spec-authority (RUL-013), corpus gates clean
2 Design U:Ar Architect on Opus (with U:Ux where a surface exists) Mermaid contract diagram + TDD test plan Traced, and complete enough to generate from
2b Design review Fable verdict on the diagram No generation starts until this passes
3a Generate U:Cd CodeDirector — deterministic, no model structure from the diagram: diagram_to_code, gen-skeleton, gen-dto/gen-protocol/gen-serde Round-trips; ~half the code at zero tokens
3b Fill J:Junior on Haiku method bodies into generated signatures Review rules injected; each edit reviewed at the edit
3c Supervise senior_dev on Sonnet review, escalation target, unsticking Junior never runs unobserved
4 Tests U:QA (TestGenMixin, LLM review, autofix) tests against the TST-* ids Real infra, no mocks, test_id isolation, no skips
5 Review U:Cd self-review (VP-A8 → fix_issues) then U:QA pass/fail routing (VP-A7) findings Every finding fixed, none deferred
6 Compliance U:Cp Compliance verdict Gate battery scripts/gates.py (RUL-008)
7 Docs U:Do Documentor spec + wiki page Wiki page is part of done
8 Merge deploy/ship.sh merge-to-master arm on master Full authoritative run — kernel started + gRPC-verified
9 Cleanup scripts/worktrees.py gc branch + worktree deleted Ancestry proof before deletion

Escalation is the seam where I intervene. orchestrator/pipeline_escalation.py plus session pipeline respond|approve|reject — the pipeline escalates a decision rather than guessing, and I answer without stopping the run.

Observation, never blind waiting: orchestrata watch (kernel/cli_watch.py, the streaming proof consumer), kernel events, gateway pubsub → the VizInt UI, and scripts/e2e_run.py (the dogfood runner with real-time observability). Test runs inside the pipeline emit into the very event stream this spec builds — the system observes its own construction from S1 onward.

Model routing is a profile, not an open question — see the next section. The profile is created in S0 and overrides any volk's built-in model choice (the Architect's current deepseek-reasoner binding included).

The LLM profile (S0 deliverable)

Orchestrata already carries provider/model profiles — pure YAML, no hardcoded defaults (llm/config.py, tests/llm/test_config.py), routed by llm/client.py through providers/provider_manager.py + registry.py to a provider adapter. So the tiering is expressed as a profile, not as an argument.

Binding rule — the whole profile in one line: EVERY Claude model — Fable, Opus, Sonnet AND Haiku, without exception — runs on THIS Claude Code as the provider. Only a non-Claude model would use OpenRouter.

claude-* → provider claude_cli (this Claude Code account, no API key). Everything else → provider openrouter.

Adapters exist for both: providers/adapters/claude_cli_adapter.py and providers/adapters/openrouter_adapter.py.

Both routing seams are covered, and the API-key paths are forbidden. A volk can reach a model through providers/provider_manager.py or through drivers/driver_factory.py, and each side has a Claude API-key path that would take the call off this account while the profile still reads correctly:

Seam Permitted for Claude Forbidden
Provider manager adapters/claude_cli_adapter.py adapters/anthropic_adapter.py
Driver factory drivers/claude_code_driver.py (SPEC-025 Claude builder track) drivers/claude_api_driver.py

Sonnet and Haiku are Claude models and therefore bound to claude_cli exactly as Fable and Opus are. There is no tier for which OpenRouter is the Claude route.

The build factory — Opus designs, Fable reviews, generation is deterministic, Haiku fills, Sonnet supervises:

Stage Tier Output
1 Design Opus The Mermaid contract diagram — traced, composition-first
2 Design review Fable Verdict on the diagram before a line is generated
3 Generation no model — deterministic diagram_to_code / gen-skeleton / gen-dto/gen-protocol/gen-serde turn the diagram into structure. ~half the code, zero tokens, cannot hallucinate
4 Method bodies Haiku Fills the generated signatures — bounded, high-volume, contract-and-test-constrained
5 Supervision Sonnet Reviews Haiku, observes it, is its escalation target, and unsticks it

Stage 3 is why the bar on stage 1 is so high: the diagram is not documentation, it is the source the structure is generated from. It must be complete and traced enough to generate from — which is exactly what an Opus author plus a Fable reviewer are for.

Stage 5 is the control on stage 4: Haiku is right for filling bounded bodies and wrong for judgment, so it never runs unobserved. Sonnet watches, catches, and unblocks — and escalates upward rather than guessing.

The fill → review → fix loop terminates (resolves R5). Nothing in an autonomous loop may retry without a bound, or an overnight run spins on one method body, burning quota with no progress and no alarm. So, per body: 3 attempts by Junior, then senior_dev writes it directly, then escalate to U:Cd, then to me. The question channel is the parallel escape hatch — a volk that cannot satisfy the reviewer asks rather than retrying blind. Attempt counts and escalations are recorded per packet in the ledger, so a body that consumed the full ladder is visible as a number rather than as lost time.

Volk → tier mapping:

Volk Model Provider Why
U:Cx Concierge claude-opus-5 claude_cli The front door. Interprets the actual ask — a misread propagates through every downstream stage
U:BA Analyst claude-opus-5 claude_cli Requirements + spec — design-side authoring
U:Ar Architect claude-opus-5 claude_cli Authors the Mermaid contract diagram (replaces deepseek-reasoner)
U:Ux UX claude-opus-5 claude_cli Surface design — design-side authoring
Design-review seam claude-fable-5 claude_cli Fable reviews the diagram before generation. Which volk owns this seam is confirmed in S0 (a design-review handoff already exists at VP-A6)
U:Cd CodeDirector claude-opus-5 claude_cli Runs generation, decides fan-out
J:Junior claude-haiku-4-5-20251001 claude_cli Fills in the method bodies from generated signatures
senior_dev claude-sonnet-5 claude_cli Observes and reviews Junior, takes its escalations, unsticks it — the name is the role
U:QA QA claude-sonnet-5 claude_cli Test generation + LLM code review — never Haiku, review is judgment
U:Cp Compliance claude-opus-5 claude_cli Gate verdicts must not be wrong
U:PM PM claude-sonnet-5 claude_cli Routing/planning
U:Do Documentor claude-sonnet-5 claude_cli Accurate wording is a stated requirement

Consequence: for this work the profile is Claude-only end to end. OpenRouter is declared as the non-Claude route and stays unexercised unless a non-Claude model is introduced. Opus review of Fable's design remains my job in-conversation, on top of U:Cd's in-pipeline review.

Second consequence — Haiku is a volk brain, and never an unsupervised one. J:Junior runs on Haiku to fill generated method bodies, which is bounded work constrained by a signature, a contract and a test. It is paired with senior_dev on Sonnet, which observes it, reviews its output, takes its escalations and unsticks it. Haiku is never given judgment work and never runs alone; that pairing is the control, not an optimisation.

Mechanically gated invariant — one predicate, trivially checkable, so this cannot silently drift:

for every volk in the profile: model.startswith("claude-") ⟺ provider == "claude_cli", and not model.startswith("claude-") ⟺ provider == "openrouter"

A Claude model routed through OpenRouter or the Anthropic API-key path fails the gate. A volk with no mapping raises — no fallback, matching the no-hardcoded-defaults rule already asserted by tests/llm/test_config.py.

Hazards to encode now, not rediscover:

Code-review rules are injected into every coding volk

Coding volks — U:Cd CodeDirector, J:Junior, senior_dev, U:QA — receive the code-review rules in their context at dispatch. The set is declared explicitly in config, not inferred from a volk's name.

This matters most for J:Junior on Haiku: it fills method bodies and is the tier least able to infer an unstated rule. A rule it was never shown is a rule it will break, and Sonnet supervision then spends its budget catching what injection would have prevented.

One source of truth — injected rules ≡ enforced rules. The same artifacts that gate the code are what get shown to the volk, so the two cannot drift: verification/review/checks_{correctness,quality,safety,structure}.py, scripts/code-review.py, scripts/test-review.py (T-rules), .claude/banned-libraries.txt via core/banned_libraries.py (the single reader), core/standards.py, and the CLAUDE.md Key Rules — no mocking, no pip, absolute imports only, no bare os.environ[...], no monkeypatch, function-scope fixtures, no xdist, declare every dependency, no AI attribution. Same discipline as the test rulebase: the reminder and the gate read one artifact.

Mechanism — reuse, don't invent: knowledge_graph/context_injector.py inject_for_task is already SPEC-106 selector/packer-backed and already task-type-aware, dispatching by SUBJECT_TASK_TYPE through pipeline_kg. Code-review rules become a layer selected for coding task types, assembled by context/context_assembler.py and bounded by context/context_budget.py. scripts/gen_brief_pack.py carries the same pack for swarm fan-out, and claude/hooks/task_context_hook.py does it for any Claude-side agent.

Budgeted and measured, not unbounded — per-layer, per-volk token spend is already reported by scripts/context_usage_report.py. The rules layer is measured like every other layer; if it crowds out task context, that is a number to see, not a surprise.

Every edit is reviewed by the tool, at the point of the edit

No file changed by a volk reaches the branch unreviewed. Each edit — or the file list a volk changed — is run through the review tool immediately, routed by path:

Changed path Reviewer
tests/**/*.py scripts/test-review.py (T-rules, incl. T15/T16 banned-dep)
any other *.py scripts/code-review.py (correctness / quality / safety / structure)

Findings block the volk's turn — it fixes and re-runs before proceeding. There is an existing seam to build on: agents/volks/_edit_review.py, plus the PostToolUse precedent (.claude/hooks/autoformat.py) for firing on write.

Reviewing at the edit rather than at the review stage is the cheap ordering: Haiku corrects its own method body while it still holds the context, instead of Sonnet discovering it three files later and spending supervision budget on a fix that injection-plus-immediate-review would have prevented. The same tool still runs at U:Cd self-review and U:QA — per-edit is the tight loop, not a replacement.

Rule class this must catch — silent swallows. The named example is a non-empty pass for exceptions: except SomeError: pass discards a failure with no handling and no signal. This is the same defect class the repo already gates in one specific place — scripts/check_graph_write_swallow.py ("a graph write failure must reach the caller"); the review rule is the general form.

Verify in S0 whether code-review.py already carries this rule. If it does not, adding it is in scope — it is a stated requirement, not an enhancement.

The rule gets the same treatment as every other: a deny corpus at least as large as the allow corpus, because a deliberate best-effort swallow is occasionally legitimate. Those carry the existing waiver convention — # gate-ok: <reason>, read by core/gate_waivers.py, the single reader — so an intentional exception is declared and reviewable, and an undeclared one fails.

Every volk gets a prepared context — and may ask, and gets a real answer

The brief pack — per volk, per run

No volk is dispatched without a pack containing everything it needs to execute. Built by scripts/gen_brief_pack.py over the SPEC-106 context pack (knowledge_graph/context_pack/{frame,selector,packer,relevance,cache}.py), task-type-aware through context_injector.inject_for_task, assembled by context/context_assembler.py, bounded by context/context_budget.py.

Minimum contents, tailored per volk:

Completeness is checked, not assumed — factory/projection/completeness.py (rule-driven projection + completeness check, ADR-0006) already performs this class of check. A pack failing completeness blocks dispatch. Dispatching an under-specified brief is a defect belonging to the dispatcher, never to the confused volk.

The question channel — two tiers

Tier 1 — retrievable, so the volk pulls. tools/context_tools.py (SPEC-107 FR-007, on-demand context) gives direct access to AST, source, graph, spec and ledger. No human in the loop; most questions should resolve here.

Tier 2 — not retrievable, so the volk asks. A strictly ordered, acyclic ladder:

J:Junior (Haiku) → senior_dev (Sonnet) → U:Cd CodeDirector (Opus) → me (Opus, in-conversation) → Mohamed

The answer rule — real information, or escalate; never invent

Question volume is a metric, not noise

A packet generating many questions means the brief pack was under-specified — the fix is the pack, not the volk. Per-packet question counts are recorded in the ledger beside the run, turning the observe-and-improve requirement into a number instead of an impression. This also supplies R5's missing escape hatch: a volk that cannot satisfy the reviewer asks, rather than retrying blindly into an unbounded loop.

Slices — one pipeline intent each, small enough to watch

Each slice is a complete pipeline pass (intake → spec/design → build → QA → compliance → docs), submitted as one intent and observed end to end. Between slices: a retrospective on the workflow itself, and fixes land before the next slice dispatches.

Slice Packets Proves
S0 Inventory + spec + profile + blast-radius proof Phase 0, spec, LLM profile, P15 That the pipeline can carry an analysis+spec work type to a gradeable artifact, with every volk on its intended model — and that affected-set selection is safe before anything relies on it
S1 Event spine P1, P2 First build slice. From here the pipeline's own test runs emit into the ledger
S2 Runner module P3, P4 codemod submodule, spawned execution, threading, kernel verify
S3 Prolog + its tests P5, P14 Rulebase with a deny corpus ≥ its allow corpus
S4 Deterministic query P6 test_intel.py answers the question that started this plan
S5 Enforcement P7, P8, P13 Claims gate, runner lock, agent-context injection
S6 Consumers P9, P10, P11 Banner, error report, CLIs, gateway/UI
S7 KG + freshness P12, P16 Ontology extended before import; no stale-AST touch

Who executes S0 (resolves R10). I do, in-conversation — inventory verification, the LLM profile, the spec, and the blast-radius proof. The first slice cannot be executed by the mechanism it configures: S0 is what puts each volk on its intended model, so volks cannot run S0. Volks take over at S1, once the profile they run under exists.

Merge cadence — per slice, not once at the end (resolves R1 and R13). Every slice merges to master behind its own full authoritative run (kernel started and gRPC-verified). Two consequences, both deliberate:

Cost is roughly 8 minutes of full run per slice — about an hour across eight slices, which is the price of not discovering a six-slice-deep defect at the end.

S0 and S1 are the proving slices. The volk pipeline has been demonstrated end-to-end on review, analysis and documentation work types; carrying a sixteen-packet implementation autonomously is a larger claim than anything it has been measured doing. If a slice stalls, escalates wrongly, or produces work that fails review, that is the finding — recorded, fixed, and the workflow improved before S2. Success is not assumed; it is measured per slice.

Branch: feat/spec-NNN-test-run-ledger, cut from origin/master, not local HEAD — edits to files touched by unpushed commits silently no-op on a stale base. Worktree provisioned with its own venv (RUL-021 prohibits symlinking the shared one; a bare uv run re-points the shared editable install and poisons other checkouts), gated by check_worktree_envs.py. Delegated work is watched by swarm_supervisor.py heartbeat — never hand-polled.

Work packets

Each is self-contained and independently dispatchable; the numbering is dependency order, not a mandate to serialise.

# Packet
P1 Versioned event schema + pytest_live_observer Redis Streams emitter (local durable write retained as truth-of-last-resort; transport_degraded when Redis is unreachable — reported, never swallowed)
P2 Pure pytest-free fold + reports/test-runs.ndjson ledger append
P3 refactor/codemod/testrun/ module + codemod/server.py JSON-RPC verb + scripts/codemod.py test
P4 Scope resolution (CIG + affected_tests.py/rts.py), threaded execution via ParallelTestExecutor in a spawned process, kernel start+gRPC-verify, authoritative flag
P5 prolog/test_lexicon.pl (generated) + prolog/test_discipline.pl — the single rulebase the banner and the gate both read
P6 scripts/test_intel.py — last, last-full, last-scoped, run, slowest, compare, flaky, explain
P7 Stop-hook claims gate (blocks on test-claim messages)
P8 PreToolUse(Bash) rule denying non-runner pytest; runner refuses to start on banner/rulebase drift
P9 Consumers: test_run_context.py live view (non-blocking, R6), error_report.py scope/skips/warnings — and delete reports/run-tests-latest.json once both are repointed (R9): the original defect's own artifact, and a surviving false-green path is a defect by the dead-code rule
P10 Consumers: observe_pytest.py stream attach, test_durations.py run-over-run comparison
P11 Gateway/UI via gateway/test_runner.py + handlers/test_metrics_handler.py
P12 canonical_schema.py extension first, then idempotent orchkg sync + kg_retention.py
P13 task_context_hook.py — inject ledger + rules + project scope into agent briefs
P14 Prolog test suite for every .pl artifact this spec adds (see below)
P15 Blast-radius safety proof — affected-set selection proven non-under-selecting by mutation testing
P16 AST freshness enforcement — no code is touched or selected against a stale AST

Prolog is tested exactly as the Python is (P14)

The .pl rulebases this spec adds are production artifacts and get the same treatment the existing gate rulebases already get in tests/gates/ and tests/prolog/:

Test class Precedent Applied to
Fact extraction tests/gates/test_fact_extractor.py Ledger → ground facts: every field round-trips, malformed records rejected
Rules, negative corpus ≥ positive corpus tests/gates/test_gate_rules.py flaky/2 and every lexicon predicate: the deny corpus must be at least as large as the allow corpus
Proof trees / why-not tests/gates/test_gate_explain.py, tests/prolog/test_explain.py explain must derive why a term was permitted and why it was refused
Differential vs. what it replaces tests/gates/test_differential_vs_legacy.py New flaky/2 vs. today's flake_report.py / flaky_test_hunter.py verdicts — differences must be explained, not averaged away
Tabling safety tests/governance/test_tabling_safety.py Any recursive predicate over run history
Module export disjointness tests/prolog/test_module_exports.py New modules must not collide with existing exports
Meta-lint scripts/prolog_meta_lint.py The rulebase itself

The negative-corpus rule is the important one: a false positive in the lexicon gate is a defect in the gate, never something to route around — and the matcher may only be relaxed with a deny corpus proving the genuine violations still deny.

Blast radius — used for selection, and proven safe (P15)

--scope affected resolves through the blast radius, not a heuristic: code_intel.py impact / rts.py (AST-based safe regression selection over the Prolog KB) and the codemod daemon's CodeIntelligenceGraph.

The failure mode that matters is under-selection — an affected set that misses a test produces a false green, which is the same class of defect that started this whole plan. So the selection is proven, not asserted, by reusing the machinery that already exists for it: factory/evidence/mutation.py (SPEC-033 B6 affected-set mutation testing). Mutate a symbol → a test in the computed affected set must die. If nothing dies, the affected set is unsafe and the run must refuse to present itself as covering that change.

Over-selection is a cost, not a correctness bug, and is reported as a number (selected vs. minimal), never silently trimmed.

Runtime evidence → traceability: ALREADY SPECIFIED, Phase 1 BUILT (verified 2026-08-20)

Mohanmed's "call-graph should always feed requirement traceability" is an existing open backlog item, not new design — plans/bdd-runtime-tracing-plan.md:

The safeguards proposed in review already exist in code:

Safeguard As built
Exclusivity _UBIQUITY_CAP = 0.5 — a def executed by >50% of all contexts is dropped pre-scoring
Bounded nomination _TOP_N = 3 per requirement
Proof classes 4 tiers (incl. STATIC-UNTESTABLE); EXECUTED is the Phase-3 addition
Report, don't write add_traces_from_evidence.py dry-run by default; --apply writes
Unknown ≠ false citing contexts restricted to nodeids that ran in this coverage db — counting non-executed cites would emit false STATIC-UNCONFIRMED on partial runs

Scoring: support / (log2(1+breadth) * log2(1+spread)) — deterministic, "sorted inputs in, sorted outputs out".

Blocker: T1b records that attribution cannot run today — "needs ~8min per-test coverage regen (current .coverage has no contexts)". Phase 3 begins with a contexts regeneration, not with code.

Runtime call-graph differential — the primary blast-radius validator

Different signal from the traceability work above, and this one IS new. attribute_traces_runtime.py consumes coverage contexts — node-level, which defs executed under which test. Blast-radius validation needs edges — which caller called which callee — and coverage contexts never record that. So the two ideas share the --trace run but consume different data, and nothing in bdd-runtime-tracing-plan.md covers the edge-level differential.

Static blast radius is structurally incomplete on this codebase. code_intel.py impact, rts.py and codemod's query_blast_radius all infer reachability from AST-derived edges, which cannot see dynamic dispatch, DI resolution, plugin/registry lookup, the observer mesh, driver-factory dispatch, CXP handler routing, or Prolog→Python py_call callbacks. This architecture is built from exactly those patterns, so static analysis under-approximates precisely where the system is most dynamic — the false-green source R1 names.

The --trace runtime call graph is ground truth for what executed. Validation is a differential: every caller→callee edge observed at runtime MUST appear in the static graph.

The asymmetry is the discipline and MUST NOT be inverted:

Direction Verdict
Runtime edge absent from static graph DEFINITE static bug — a hole in the selector
Static edge never observed at runtime NOT a bug. Unexercised ≠ nonexistent. A candidate for over-selection, equally likely a coverage gap

Observed = true; unobserved = unknown, never false — the same three-valued rule as R3 and SPEC-113 FR-006. Inverting it would emit a flood of bogus over-selection findings.

Complementary to, and ordered before, mutation testing:

Run the differential first: broader and cheaper, and it finds structural holes.

Validates all three implementations, and cross-validates them. code_intel.py, rts.py and codemod's query_blast_radius can each be checked against runtime ground truth and against each other — three implementations that disagree is itself a finding (R8's "one ruleset, N implementations" in another guise).

Shared-signal economy. One ORCH_RCG_DIR run pays for this and the traceability-candidate report. --trace yes stops being a rare debug flag and becomes a periodic evidence run worth scheduling.

Limit, stated: validates only code the suite exercises. Validation coverage is bounded by suite coverage.

Possibly half-built: build_runtime_callgraph.py merges runtime capture into plans/ast-ultimate.json — the static snapshot itself. If static and runtime edges already coexist there, the differential is a query over one artifact, not a join across two systems. Verify before designing.

This proof runs in S0, not S7 (resolves R1). It was originally scheduled last, which would have left S1–S6 validated by a selector whose safety was not yet established — the plan's own target defect, reproduced inside the plan. It needs nothing this project is about to build: factory/evidence/mutation.py and run_tests.py both exist today. Prove the selector before relying on it.

AST freshness — fresh before touch (P16)

Any code that is touched, and any selection computed, is based on a fresh AST — including code that was just generated. A builder emitting a new module and then computing blast radius from a pre-generation snapshot under-selects by construction.

Mechanism, reusing what exists:

The daemon does not host its own mutation (resolves R11). The runner is a codemod submodule, and codemod is the warm daemon that enforces freshness over that same tree — so editing refactor/codemod/** mutates code the daemon has loaded, while the daemon is the authority on whether the tree is fresh. Rule: after any edit under refactor/codemod/**, the daemon restarts before serving the next selection, and it refuses to serve selection when its loaded module hashes differ from disk. Self-modification under a warm cache is otherwise a silent correctness hole, not a performance question.

Hazard to encode, not rediscover: two different programs answer to ast-sync on this machine — the repo venv's serializer (writes plans/ast-ultimate.json) and /usr/local/bin's graph syncer (never does). PATH resolution silently runs the wrong one and exits 0. Every refresh call site invokes by absolute venv path. Related: never launch the daemon with uv run — it holds the machine-global uv cache lock for the child's entire life.

Technical detail per packet

Phase 0 — verify the inventory (non-negotiable first step). Every "exists" claim in this plan came from docs/CODEBASE_MAP.md one-liners. Confirm via serialize_codebase_ast.py symbols/source before writing a line: pytest_live_observer, observe_pytest, test_durations, whatever writes run-tests-latest.json, gateway/ring_buffer, codemod/{server,client}, project_resolver, task_context_hook, test_run_context, error_report, flake_report/flaky_test_hunter, and whether reports/ is tracked or ignored. Correct the plan where it is wrong.

Phase 1 — event spine. Versioned event schema in refactor/codemod/testrun/events.py. Extend pytest_live_observer to XADD to test:run:{project}:{run_id}:events, keeping its existing SIGKILL-durable local write as truth-of-last-resort. If Redis is unreachable the run proceeds and the record is stamped transport_degraded: true, surfaced in the live view — reported, never swallowed. Pure pytest-free fold in fold.py; ledger append in ledger.py.

Phase 2 — runner. refactor/codemod/testrun/ with __version__; wired into codemod/server.py JSON-RPC and a scripts/codemod.py test verb. Scope via the daemon's CIG + affected_tests.py/rts.py. Threaded through core/threading/test_executor.py. Kernel started + gRPC-verified via start_test_services.py; kernel_verified recorded on run_started. A record carries authoritative: true only when scope is full and the kernel verified.

Phase 3 — Prolog + determinism. prolog/test_lexicon.pl (generated) and prolog/test_discipline.pl (the one rulebase). scripts/test_intel.py with last, last-full, last-scoped, run <id>, slowest, compare <a> <b>, flaky, explain <goal>. Ground facts only, asserted from Python atoms (janus); variable-bearing rules stay in .pl.

Phase 4 — enforcement. Stop-hook claims gate. PreToolUse(Bash) rule denying non-runner pytest. Runner prints the rulebase banner and refuses to start on banner/rulebase drift. task_context_hook.py injects ledger + rules + project scope into agent briefs.

Phase 5 — consumers. test_run_context.py reads the live stream + last-run facts. error_report.py gains scope/skips/warnings. observe_pytest.py attaches to the stream; test_durations.py gains run-over-run comparison. Gateway/UI via gateway/test_runner.py + handlers/test_metrics_handler.py.

Phase 6 — KG sync. Extend knowledge_graph/canonical_schema.py before any import (ontology-before-graph-import rule), then an idempotent sync writing one TestRun node per run; extend kg_retention.py. NDJSON is local exhaust (git-ignored); orchkg is the durable shared record that survives a fresh clone.

S0 Group B — SPEC-113 ALREADY OWNS PART OF THIS (2026-08-20)

specs/113-measurement-retention/spec.md exists (Mohamed, 2026-08-19) and is authoritative. It must be reconciled with, not duplicated.

SPEC-113 FR Its status Bearing here
FR-001 run identity on duration records BUILT + pinned run_id/batch_id; header {"kind":"run","ts","run_id","head_commit","batch_id","lane"}; join by (run_id, batch_id), never file position. The ledger's identity scheme already exists and is normative. Per-run constants live on the HEADER only — denormalising onto rows measured at 2.7× growth and rejected. Block append = ONE os.write on an O_APPEND fd
FR-002 per-phase setup/call/teardown retention says UNBUILT ⚠️ but phase_durations_ndjson_path + _append_test_phase_durations exist
FR-003 pytest_fixture_setup instrument says UNBUILT, "zero hits repo-wide" ⚠️ but pytest_live_observer.py:634 pytest_fixture_setup exists
FR-004 freshness guard wired + tested SATISFIED —
FR-005 container CPU share says UNBUILT, "no cpu.stat reader exists" ⚠️ but run_tests.py:952/979 + container_cpu_accounting.py + reports/container-cpu.ndjson exist
FR-006 no statistical facts in the gate rulebase standing prohibition Constrains the Prolog design — see below

Spec drift — WIRING VERIFIED 2026-08-20. All three FRs marked UNBUILT are in fact built, wired and tested:

FR Production call site Tests
FR-002 _append_test_phase_durations at pytest_live_observer.py:803, inside pytest_sessionfinish 10 refs across test_durations_identity.py, test_live_observer.py
FR-003 @pytest.hookimpl(wrapper=True) — pytest invokes it; gated on ORCH_FIXTURE_COST_DIR, dormant when unset; keyed by run_identity() test_live_observer.py:811–1029
FR-005 _snapshot_container_cpu_before run_tests.py:1314; _measure_container_cpu :1325 test_run_tests_container_cpu.py

The drift was already tracked in docs/architecture/measurement-retention.md (all three logged as "IN PROGRESS (uncommitted) — spec text predates this code landing"). That doc is now stale in turn: it flags tests/core/threading/fixtures/fixture_cost_module.py as untracked ?? (now committed, git status clean), cites the hook at :715 (actually 633–688), and asserts "zero hits in tests/" for pytest_fixture_setup (now exercised at test_live_observer.py:811–1029).

ONE GENUINE GAP SURVIVES — REQ-113-FR3-002. FR-003 has two requirements. The instrument (-001) is built; the report (-002) is not: "ONE gated run SHALL suffice to produce a per-fixture cost report naming the top-N fixtures by total setup seconds." The hook writes fixture-durations-{pid}.ndjson; the only reader of that glob is a test helper (test_live_observer.py:835). No reader, no report. SPEC-113 is one reader away from FR-001..FR-005 all being satisfied.

Actions: (a) build the FR3-002 reader — a natural sibling of test_durations.py and a good S0/S1 warm-up on the real codebase; (b) correct SPEC-113's status lines for FR-002/003/005; (c) refresh docs/architecture/measurement-retention.md.

FR-006 + Non-Goals change the Prolog design

FR-006 bans asserting statistical measurement — explicitly including flake rates and durations — into any GateEngine-consulted rulebase. Non-Goals list "A Prolog fact base for the observation stream" as explicitly dropped (370× slower than dicts; every proposed query is a GROUP BY, not a recursion).

The test_run/6, run_totals/6, test_outcome/4, flaky/3 contract written earlier in this plan is that fact base and contradicts the spec.

Reconciliation (satisfies both the spec and Mohamed's stated requirement): Prolog holds the rules — what makes a lexicon term legal — and reaches the measurements through py_call python atoms into deterministic Python readers (scripts/test_durations.py and successors). Nothing is asserted, so FR-006 holds; no fact base is built, so the non-goal holds; explain.pl proof trees still work. This is exactly the "or access to it through python atoms" Mohamed specified.

Three conditions make the atoms honest rather than a dodge (Mohamed, 2026-08-20 — "save the aggregate queries as python atoms that prolog can use to answer"):

  1. Atoms return EVIDENCE, not booleans. FR-006 mechanism 5 is that explain.pl renders every unit clause as an identical leaf. A py_call goal is not a unit clause, so without care the proof tree dead-ends at the Python boundary — "true because an external call said so" is not a derivation. The atom returns the supporting rows (which runs, which outcomes, which commits) so explain can still answer why.
  2. Atoms return a THREE-VALUED verdict. FR-006 mechanism 3 is that statistical facts are invisible to freshness — "outside its safety net, not failed by it." A reader can fix what an asserted fact cannot: it returns unknown when the sink is stale or does not cover the runs in question. R3's rule, enforced at the boundary; unknown never collapses to false.
  3. Atoms MEMOIZE per run_id. The durations sink is 392 MB / 2,085,361 records (measured 2026-08-20). Re-parsing it per Prolog goal would make the gate unusable. Compute once per run; cache on the FR-001 identity key.

All five FR-006 mechanisms are satisfied: nothing enters the flat namespace, nothing alters the 142 \+ sites, nothing sits invisible to freshness, no confidence-threshold truncation, and no statistical fact masquerades as a hash-verified one in a proof tree.

FR-006 mechanism #3 independently confirms R3, and words it better: statistical facts are "INVISIBLE to the freshness discipline — outside its safety net, not failed by it."

Corrections to this plan from Group B

  1. Next free spec number is 115 (113 = measurement-retention, 114 = governance rule learning). This plan previously guessed 113.
  2. Specs are DIRECTORIES: specs/NNN-slug/spec.md + checklists/, governed by specs/SPEC_STANDARD.md, validated by specs/spec-review/validate_spec_standards.py, verify_all_specs.py, verify_requirements.py.
  3. Requirement id format is REQ-113-FR1-001 — REQ-<spec>-FR<n>-<nnn>.
  4. R7's resolution is REVERSED — the spec wins. REQ-113-FR1-001: a bare-pytest row OMITS run identity; "omission means 'not run under the executor'; writing null would claim the field was measured and found empty." Encode absence by omission, not by a scope: unknown sentinel.
  5. reports/ is TRACKED (51 files); exhaust is ignored per file (test-durations.ndjson, test-phase-durations.ndjson, container-cpu.ndjson). So reports/test-runs.ndjson and reports/test-run-artifacts/ need explicit .gitignore entries, or every run dirties the tree.
  6. reports/run-tests-latest.json is TRACKED and not ignored — R9's deletion is a git rm, and run results are currently committed as a practice.

S0 Group A — inventory VERIFIED (2026-08-20)

Read via docs/CODEBASE_MAP_FULL.md + AST-toolkit source. These supersede the map-level guesses above.

# Target Verdict
A1 pytest_live_observer Richer than assumed. Observable + pluggable observers (_NdjsonWriterObserver, _SessionRegistryObserver). run_identity(env) exists. _atomic_append_ndjson(path, header, records) — appends already carry a header. Two duration files (durations_ndjson_path, phase_durations_ndjson_path). Registry observer keeps live tallies (_count_report, _write) and captures pytest args. pytest_collection_modifyitems gives Y
A2 observe_pytest File tailer, _follow(..., poll_interval=0.1). _render_snapshot = late-joiner fold. _pid_alive = dead vs stalled
A3 test_durations A run ledger in embryo — RunHeader, RunSummary, read_headers, summarize_runs, slowest, over_threshold, runs subcommand
A4 run-tests-latest.json Written by run_tests.py:919 _write_canonical_report. Prime suspects for passed=17, modules=0: _try_reuse_report:361 (report reuse keyed on head + dirty_sig) and _supersede_module_totals:898
A5 RedisRingBuffer Already Redis Streams — write→id, read_since(ms), serialize_for_redis. ⚠️ async, pytest hooks are sync
A6 codemod daemon Real. query_blast_radius(node_id, hops) and reload() already exist; commit(changes, reviews, dry_run) already takes reviews; _dispatch is the seam for a test verb
A7 project_resolver resolve_project_id(...) -> str | None — ⚠️ returns None, does not raise
A8 inject_for_task Confirmed at context_injector.py:1368, task-type + volk aware; context_pack/cache.py keyed by task type + volk; analyze_request(..., budget=)
A9 gen_brief_pack ⚠️ Thinner than the plan claimed. build_pack carries files/symbols/refs/who-imports/docstrings only — no requirement ids, ACs, diagram slice, tests, review rules or escalation target. Mitigation: GraphContext._format_requirements_section / _format_decisions_section already exist and are reusable
A10 empty-except rule ❌ DOES NOT EXIST. PassVisitor.visit_Pass emits TEST_PASS_STATEMENT ("Pytest file contains pass statement") — a test-file assertion rule, unrelated to except X: pass. The silent-swallow rule must be built
A11 mutation.py run_mutations(units, test_selector, runner_cmd, limit, timeout, repo_root) — test selector is a callable, runner is a command. The R1 proof needs no new code

Consequent plan changes:

  1. P1 shrinks — add a third observer, not an emitter rewrite.
  2. P2 shrinks — extend RunHeader/RunSummary; the fold already exists.
  3. R11 shrinks — reload() already exists on the codemod client.
  4. Sync client in the drain thread — NOT an async bridge. RedisRingBuffer is async; pytest hooks are sync and must be, because pluggy's _multicall dispatch never awaits, and pytest deliberately owns no event loop so that pytest-asyncio / anyio / trio can each own loop policy. (pytest-asyncio wraps async tests from a sync hook; it does not make hooks async.) Resolution: the R14 drain thread uses a sync redis-py client and reuses serialize_for_redis, which is a module-level def in gateway/ring_buffer.py and therefore usable without the async class. A dedicated drainer gains nothing from async — blocking XADD there is correct, and under free-threaded 3.14t a blocking socket call does not impede the test threads. No event loop, no bridge, no new dependency.
  5. A9 correction against my own claim — the brief pack is a ~70% extension, not a reuse.
  6. A7 — wrap resolve_project_id to raise on None, per the no-default rule.
  7. A4 — investigate _try_reuse_report / _supersede_module_totals as the origin of the 17-test fragment before deleting the artifact (R9).

Also discovered — code-review.py's real rule surface (the set to inject): RuffChecker, RuffFormatChecker, MyPyChecker, StyleChecker (docstrings, type hints, naming, imperative accumulator, complexity, test isolation, code patterns), ChecklistChecker, TraceabilityChecker (@traces), ConnectionStringChecker (_is_env_get_call, _collect_env_get_defaults), check_banned_dependencies, check_prolog_boundary.

Review 1 — adversarial findings (ALL RESOLVED INTO THE DESIGN ABOVE)

Self-review; treat accordingly. Ranked by likelihood of biting in execution. Each row's fix is now written into the section named in the third column — this table is the audit trail, not an outstanding to-do list.

# Finding Resolution — now written into the design
R1 Affected-set safety is proven in S7 but relied on from S1. Compounded by an ambiguity: the plan never says whether merge (and its full authoritative run) is per-slice or once at the end. If once, six slices are validated by an unproven selector State merge cadence. Move the mutation proof to S0/S1 — factory/evidence/mutation.py needs no new runner
R2 "Verbatim error text" contradicts "MAXLEN-trimmed stream". 1,000 failures × ~10 KB longrepr = ~10 MB/run: trims mid-run (breaking V3), bloats NDJSON, and would blow context if injected Truncated head + content-addressed sidecar (codemod's parse-cache pattern). Byte-identity verified against the sidecar
R3 Claims gate can certify a false claim. Negation-as-failure over a stale/incomplete KB proves absence that is not true, and stamps it verified — the false-confidence class, reintroduced at the layer meant to stop it Apply the three-valued freshness verdict to the claims KB. unknown ≠ false; a stale KB refuses to certify
R4 Event stream shares fate with the system under test. Same Redis the suite uses; a FLUSHDB (already an incident here) destroys the run's own evidence Dedicated DB index/instance outside the suite's slot allocation, isolation asserted
R5 Fill → review → fix loop has no termination condition, and no cost/time estimate for "autonomous" Max attempts per body → senior_dev writes it → escalate. Budget per slice
R6 Prompt hook must not block. One rule ("block, never spin") applied to consumers with opposite needs; degraded Redis would hang every prompt Non-blocking read + hard timeout for the hook; blocking only for the CLI observer
R7 Bare-pytest runs produce scopeless entries. The Bash guard governs only my tool calls, not volks/CI/shell — the 17-test fragment reborn Explicit scope: unknown + authoritative: false, or infer from pytest config.args
R8 Anti-drift claim is weaker than stated — rules exist in Prolog and Python; two languages, one ruleset Generate one from the other, or a differential test proving agreement (currently promised only for flaky/2)
R9 reports/run-tests-latest.json has no stated fate — the original defect's own artifact. If kept, the old false-green path survives Decide: delete, repoint, or keep with a stated reason
R10 S0 bootstrap — the first slice cannot be executed by the mechanism it configures State that I execute S0 in-conversation; volks take over at S1
R11 Daemon self-modification — the runner is a codemod submodule, and codemod is the warm daemon enforcing freshness over it Rule: the daemon does not host its own mutation. Versioned reload or restart
R12 flaky definition omits worker count, scheduling and load from its identity conditions, so a concurrency-dependent failure classifies as flaky. "No write to impact set" is near-redundant with "same commit" for tracked files, silent on generated/untracked Add concurrency to the identity tuple; split tracked vs generated in the write clause
R13 No rollback path for a slice that fails review repeatedly on a long-lived branch Abandon/revert/reset per slice, with the branch state asserted before the next dispatch
R14 New concurrency surface — a Redis client per test thread under free-threaded 3.14t, in a codebase with a documented heap-corruption history under load Emitter thread-safety + backpressure when XADD lags test completion

Constraints carried from project doctrine

Verification (to fill in once designed)

Real infra, no mocking, per project doctrine. Each item is a PASS/FAIL with numbers.

# Check Passes when
1 kill -9 a run mid-flight Partial record is well-formed; live view says dead, not stalled; record marked incomplete
2 Redis unreachable Run completes; record stamped transport_degraded: true; degradation is visible, not silent
3 Late attach at t+N Folded tallies equal the fold of the complete stream
4 Fragment guard A 17-test run cannot be presented as authoritative — scope is shown and authoritative: false
5 Lexicon gate A message saying "flaky" without flaky/3 succeeding is denied; the same message using unreproduced_failure passes
6 Verbatim errors Ledger error text is byte-identical to the event payload
7 Determinism test_intel.py last returns time, scope, wall time, gate/trigger, last-full and last-scoped without hand-reading a file; explain returns a derivation
8 Project scoping Run from another project's cwd → events/records/nodes carry that project id; an unresolvable project raises
9 Runner lock pytest invoked outside the runner after "run tests" is denied by the Bash guard
10 Round-trip run → events → fold → error_report.py → session banner shows correct scope + totals
11 Threading Worker events carry both pid and thread id; no xdist anywhere
12 Bands stay Perf-band tests remain in the default gate; nothing deselected to improve numbers
13 Prolog rules tested Every new .pl predicate has a deny corpus at least as large as its allow corpus, and both pass
14 Why-not works explain derives both why a term was permitted and why it was refused
15 Differential New flaky/2 verdicts reconciled against flake_report.py/flaky_test_hunter.py; every difference explained, none averaged away
16 Blast radius is safe Mutate a symbol → a test in the computed affected set dies. If none dies, selection is unsafe and the run refuses to claim coverage of that change
17 Over-selection reported Selected-vs-minimal counts printed as numbers, never silently trimmed
18 Fresh AST before touch An edit or selection attempted against a stale AST is blocked, not served a plausible wrong answer (three-valued freshness verdict)
19 Generated code ingested Code generated during a run is in the AST before the next selection; a generated-then-selected round trip proves it
20 Right ast-sync Refresh call sites resolve to the repo venv serializer by absolute path — asserted, since the wrong binary exits 0
21 Slice autonomy Each slice completes without me driving it; every intervention is logged as an escalation, not a silent takeover
22 Dogfood self-reference From S1, the pipeline's own test runs appear in the ledger with correct scope and trigger agent(<volk>)
23 Workflow findings Every volk-workflow defect the run exposes is recorded and fixed before the next slice dispatches — improving orchestrata is a deliverable, not a side effect
24 Profile binding For every volk: claude-* ⟺ claude_cli, non-Claude ⟺ openrouter. A Claude model on any other provider fails
25 No silent fallback A volk absent from the profile raises; it does not default to a model
26 Right account, both seams Claude calls resolve through claude_cli_adapter (provider seam) or claude_code_driver (driver seam) — never anthropic_adapter or claude_api_driver. Asserted from call records, not assumed
26a Every tier on this account Per-tier assertion from live call records — Opus (Concierge, CodeDirector, Compliance), Fable (Analyst, Architect, UX), Sonnet (PM, Junior, senior_dev, QA, Documentor) and Haiku (bulk-extraction sub-task) each resolve to claude_cli. No Claude tier reaches OpenRouter
27 Tier honoured Each volk's live calls carry its mapped model id — verified from actual call records, not from the YAML alone
28 Diagram is the source diagram_to_code/gen-skeleton round-trip: the Mermaid contract diagram regenerates the structure it produced. Generation uses no model
29 Fable gate before generation No code is generated until the design review verdict exists on the diagram
30 Supervision loop Junior (Haiku) never runs unobserved: every Junior packet has a senior_dev (Sonnet) review record, and escalations resolve rather than time out
31 Rules injected A coding volk's assembled prompt provably contains the review rules — asserted on the built prompt, as the VP-A handoff tests already assert prompt content
32 Injected ≡ enforced The injected rule text derives from the same artifacts the gates read; a drift check fails if they diverge
33 Every edit reviewed No file changed by a volk reaches the branch without a reviewer record; tests/** routes to test-review.py, source to code-review.py
34 Silent-swallow rule except X: pass fails review; the same construct carrying # gate-ok: <reason> passes. Deny corpus ≥ allow corpus
35 Rules layer measured Per-volk, per-layer token spend for the injected rules is reported, not unbounded
36 Pack completeness Every dispatch carries a completeness-checked brief pack; an incomplete pack blocks dispatch rather than producing a confused volk
37 Provenance on answers Every answer to a volk question carries a source — spec id, file:line, graph query, ledger run id, or named human. A sourceless answer is rejected
38 No level invents An escalation answered with "probably" / "assume" / "it should be" fails; the level must escalate instead
39 Stall, never default An unanswered question stalls the packet visibly in the live view; it never unblocks by assumption or timeout-to-default
40 Answer caching The same question from parallel volks is answered once and cached into the run's pack
41 Ladder integrity The escalation ladder is acyclic and ordered; per-packet question counts are recorded in the ledger

The end-to-end acceptance test is this conversation: ask "tell me about the last test run" and get time, scope, wall time, trigger, last-full and last-scoped — sourced from the ledger, not reconstructed from prose.