RACT

:warning: This file is project documentation, not part of the source code.

RACT Architecture

RACT is a model-agnostic, local-first agentic coding tool. It turns operator intent into structured plans, executes those plans through configurable providers, and writes verified artifacts to disk.

System diagram

┌─────────────┐     intent      ┌─────────────┐     plan        ┌───────────┐
│   Operator  │ ───────────────▶ │   Planner   │ ─────────────▶ │  Manager  │
└─────────────┘                  └─────────────┘                └─────┬─────┘
       ▲                                                              │
       │                         ┌─────────────┐                      │
       │      run report         │   Executor  │ ◀────────────────────┘
       │ ◀────────────────────── │ (chokepoint)│
       │                         └──────┬──────┘
       │                                │ writes
       │                         ┌──────┴──────┐
       │                         │  Workspace  │
       │                         │ + Rootknot  │
       │                         │   index     │
       │                         └─────────────┘

The planner emits a versioned plan. The manager recurses through steps. The executor is the only component allowed to mutate the workspace. Every written artifact is entered into the Rootknot index.

Boundaries and contracts

  1. Plan contract. Every Plan carries a schema version, a load-bearing assumption, and an ordered list of steps. The plan validator rejects unknown schema versions and steps with missing required fields.
  2. Provenance contract. Every written artifact carries a signed Rootknot that binds it to its plan step, assumption, generator, and parent artifacts. verify_workspace checks RK-1 and RK-2 before each recursion step. The public statement of what a Rootknot attests and how RACT stays independent of private systems lives in docs/PROVENANCE.md.
  3. Assumption contract. Every step declares the assumptions it depends on. The AssumptionRegistry tracks the lifecycle (proposed, active, discharged, violated) and propagates violations through the dependency graph.
  4. Threat-model contract. Every workspace-mutating action passes through authorize_action in src/ract/core/threat_model.py. Tier-3 actions are refused by default; tier-2 actions require an operator handshake.
  5. Termination contract. The recursion loop halts on one of T1–T7, each with a distinct TerminationCause: success, regression, provenance violation, assumption cascade, budget exhaustion, handshake block, or provider fault.

Failure modes and concurrency

RACT is specified by what it refuses to do silently. Every failure has a named halt cause, checked in a fixed order by evaluate_termination (src/ract/core/loop.py): T1 → T7, first match wins.

Acceptance suite compiled before loop entry

Termination is a fact about the environment, not a model judgment.

Before the recursion loop enters step one, IntentCompiler.compile (src/ract/core/compile.py) turns the operator’s intent into a frozen AcceptanceSuite (src/ract/core/predicate.py). The suite is a tuple of AcceptancePredicate values, each a concrete PredicateInvocation (pytest selector, mypy target, Hypothesis property, assertion callable, or artifact requirement) tagged with a required flag. The suite is persisted as canonical JSON to evals/runs/<run_id>/suite.json before LoopState is returned to the caller (build_loop_state(..., run_dir=…)).

T1 (TerminationCause.COMPLETE) reads

all(p.evaluate(final_snapshot).ok for p in state.suite.predicates if p.required)

Evaluators are pure over (invocation, WorkspaceSnapshot); verifiers that would otherwise mutate state read pre-recorded results from WorkspaceSnapshot.metadata. Live execution against a scratch copy of the snapshot is deferred to the transactional worktree substrate (module_02).

Three guardrails keep the compile-before-loop rule intact:

RunReporter.render_acceptance_suite reads the suite and every PredicateResult from the final snapshot, so a reviewer inspects the exit condition and what the environment observed without re-running the tool.

See ADR-0010 for the design rationale and rejected alternatives, and docs/RACT_v0.4.0_SUBSTRATE_SPEC.md §2 (Substrate Layer 1) and §11 signals 1 and 2 for the master-spec source.

Transactional execution: worktree-per-step

Every step in the v0.4 loop opens a StepTransaction (src/ract/core/transaction.py). The workspace is the durable artifact; the plan reduces to a schedule over transactions.

See ADR-0011 for the design rationale and rejected alternatives, and docs/RACT_v0.4.0_SUBSTRATE_SPEC.md §3 (Substrate Layer 2) plus §11 signal 3 for the master-spec source.

Typed action union + conformance gate

Every action a model may propose is a member of a closed Pydantic v2 discriminated union (src/ract/core/actions.py, ract.core.actions.Action). Eight kinds ship: WriteFileAction, RunTestsAction, ReadFileAction, SearchWorkspaceAction, ProposePredicateAction, DeleteFileAction, RequestHandshakeAction, EmitEventAction. Every member forbids extra fields (ConfigDict( extra="forbid", frozen=True)) so a stray key never grants an unmeant capability. Adding a new kind requires an ADR (see ADR-0014).

The provider layer serialises the union to whatever shape the underlying API expects, chosen at request time from the provider’s declared response_shape (ract.providers.provider.Provider):

Every response passes through ResponseValidator.parse (src/ract/providers/validator.py) before it reaches the executor. A first validation failure returns a corrective prompt naming the offending field and the accepted shape. A second consecutive failure for the same step id flips should_halt = True; the loop terminates with TerminationCause.PROVIDER_TIMEOUT (T7 in src/ract/core/loop.py; SUBSTRATE §5.4 assigns this cause to a repeated shape failure).

The router will not route to a provider without a recent passing conformance report (src/ract/providers/gate.py, check_provider_gate). Reports live under evals/conformance/results/<provider>-<date>.json; a report card is produced by ract conformance run --provider <name> and covers three categories with three thresholds:

A stale, missing, or below-threshold report yields GateOutcome(admitted=False, reason=…); the reason names the missing report card or the offending category so the operator can act.

The corpus supports a response cache at evals/conformance/cache/<provider>/<intent_id>.json; subsequent runs replay from the cache unless --refresh is passed (lateral chain branch E).

--provider fake is the CI-exercisable path today (src/ract/providers/fake_provider.py); live-provider integration is follow-up work logged in module_04’s Flagged gaps.

See ADR-0014 for the design rationale and rejected alternatives, and docs/RACT_v0.4.0_SUBSTRATE_SPEC.md §5 (Substrate Layer 4) plus §11 signals 7 and 8 for the master-spec source.

Event trace as the product

module_05 (SUBSTRATE §6) makes the append-only event log the source of truth. Every load-bearing decision — every predicate evaluation, every step transaction outcome, every prompt/response pair, every sandbox entry, every handshake, every rootknot write, every assumption lifecycle transition — lands as one event in a hash-chained JSONL log at evals/runs/<run_id>/events.jsonl. Each event carries a SHA-256 of its canonical payload and a prev_hash reference to the tip hash at append time; a bit-flip anywhere in the middle of the log surfaces as a ChainBrokenError when EventReader.load re-hashes the chain.

The vocabulary is closed (ract.trace.events.LEGAL_EVENT_KINDS); adding a kind is a schema-version bump in docs/EVENTS.md. The log mirrors through OpenTelemetry OTLP-HTTP when otlp_endpoint is set in ract.yaml, following the GenAI Semantic Conventions; export is opt-in — a run with no endpoint configured still writes the JSONL log.

RunReporter reads only the event log and derives its summary from it. The report is derived data; the log is the source of truth.

Five CLI verbs operate on the log:

See ADR-0015 for the design rationale and rejected alternatives, and docs/RACT_v0.4.0_SUBSTRATE_SPEC.md §6 (Substrate Layer 5) plus §11 signals 9, 10, 11 for the master-spec source.

Write-first invariant + repair (v0.5.1 spec-completeness module_03)

The event log’s JsonlEventWriter enforces a write-first invariant (per 04-RACT-DESIGN §5.1.2 + spec-completeness module_03): no state change is observable to any observer before the corresponding event is durably written to disk. The commit sequence is: acquire commit lock -> assign sequence via EventChain.build_next -> write() + flush() + os.fsync() (all inside the lock) -> append to in-memory chain -> release lock -> fire post-commit observers. Any observer that attempts to re-enter the writer while _committing=True raises :class:ract.trace.writer.WriteFirstViolation.

Two observer classes are supported (matching the spec’s Two Observer Classes pattern):

The legacy add_mirror(sink) surface is retained (OTEL exporter uses it); mirror sinks fire on the post-commit path with raise-propagation preserved for backward compatibility.

The :mod:ract.trace.repair module reconstructs a coherent event stream from a possibly-truncated log by synthesizing close events for open handles. repair() is deterministic (synthesized event ids derive from sha256(marker || open.id || close_kind)[:16]) and idempotent (repair(repair(x)) == repair(x)) because synthesized closes carry payload["source_event_id"] for by-id pairing on second pass). Synthesized events participate in the hash chain – EventReader.load succeeds on a repaired log. JsonlEventWriter(..., repair_on_open=True) extends the log during construction (opt-in; default OFF).

Trust direction: environment attests

module_06 (SUBSTRATE §7 and §8) inverts the trust direction. In v0.3 the Rootknot’s single signature was the generator’s — a self-signed capability whose trust flowed from the author. In v0.4 the environment is the primary attester: every v2 sidecar carries an environment_signature produced by a per-run SandboxKey (src/ract/security/keys.py) alongside the generator_signature. RK-3 (Environmental Attestation) requires the environment signature to verify, the acceptance_suite_digest and manifest_digest to be currently registered, and predicate_results to be non-empty. v1 sidecars from v0.3 workspaces continue to verify under RK-1 + RK-2 only, with a DeprecationWarning and refusal under --strict. See docs/PROVENANCE.md for the v1 / v2 sidecar compatibility table.

Whisperer, Fence, and Auction move from CLI features to environment-enforced contracts under src/ract/contracts/:

FenceGate and AuctionSweep compose: an Auction proposal the operator approves still passes through FenceGate at execute time, because the deletion itself is a DeleteFileAction.

__root_author__ moves to display-only under ract --about (src/ract/_about.py); the marker has no role in any invariant and its absence from other code paths is enforced by tests/test_root_author_display_only.py.

See ADR-0016 and ADR-0017 for the design rationale and rejected alternatives, and docs/RACT_v0.4.0_SUBSTRATE_SPEC.md §7 and §8 plus §11 signals 12, 13, 15 for the master-spec source.

Eval-first: borrowed benchmarks locate RACT on the existing map

SUBSTRATE spec §9 (Eval-First as Engineering Discipline) and §11 signal 16. The v0.3 harness ships three RACT-authored tasks under evals/tasks/ — enough to prove the harness works, not enough for the reviewer to locate RACT against the field. Module_07 borrows two established coding-eval anchors so RACT’s numbers become comparable without inventing a new benchmark:

Both runners execute each problem/instance inside a fresh StepTransaction (module_02) with a CapabilityManifest (module_03) attached — that is where the substrate proves itself against real-world workloads.

evals/LEADERBOARD.md is the canonical published record. It carries the four per-corpus columns — Aider Polyglot, SWE-bench Lite, conformance (module_04), security (module_03) — plus three v0.4.0 ALM columns landed by ALM module_07 (claimed_pass_rate, attested_pass_rate, attestation_gap). It is regenerated idempotently by evals/leaderboard/update.py from the most recent report per (provider, corpus). Module-internal RESULTS.md files remain the source of truth for the columns they contribute (Lateral Chain branch E, substrate module_07); the leaderboard reads them without duplicating their content. See docs/PUBLIC_LEADERBOARD.md for the column definitions and the gap-interpretation thresholds; ADR-0025 documents the rejected alternatives (attested-only, claimed-only, single-combined-score, model-graded attestation).

The evals/antilazy/ corpus (10 adversarial cases drawn from public reward-hacking incidents — SWE-bench Verified conftest.py, Palisade chess-hacking, monkey-patched scorer, patch leakage, semantic no-op, weak-assertion insertion, under-edit missed callers, sycophantic reversal, sandbagging, pattern-matching rule-like) is the source-of-truth for the anti-lazy conformance dimension. Each case cites its public source in expected.json (Lateral Chain branch A, ALM module_07).

CI runs a smoke tier on every PR against a fixture provider (schema v2 event streams under evals/fixtures/providers/); a nightly evals-full.yml workflow runs the full 10 + 5 sweep against live providers, gated by the RACT_EVAL_ENABLED repository secret so public forks and PRs do not incur cost (Lateral Chain branch B, module_07). When an upstream registry or fixture is unreachable, the runner reports SKIPPED with a specific reason and the CI summary counts the skip (Lateral Chain branch A).

Every subset revision is an ADR-tracked event (Lateral Chain branch C); historical numbers remain readable against historical subsets.

Design rationale in docs/ADRs/ADR-0018-aider-polyglot-swebench- lite-external-anchors.md.

Anti-Lazy Gate G1 (held-out predicates) and Gate G2 (mutation-kill)

ALM spec §3.1 and §3.2; §13 signals 1 and 2. The substrate AcceptanceSuite already prevents “model votes done, environment does not”; the ALM turns the pressure one level in. A model that observes which predicates the suite contains can steer its patch to satisfy those predicates literally while missing the underlying intent. G1 and G2 sit between the substrate suite and the pre-commit gate to close that failure mode.

Gate G1 — held-out predicate enforcement. IntentCompiler.compile accepts an optional companion: HoldoutComposer; when present it returns a DualAcceptanceSuite wrapping the substrate AcceptanceSuite as visible plus a companion-composed held_out suite whose canonical JSON is AES-256-GCM sealed under a symmetric key derived from the run’s SandboxKey via HKDF-SHA256 (context string "ract/antilazy/holdout-seal/v1", salt = the raw Ed25519 pubkey bytes). The public held_out_digest is committed to evals/runs/<run_id>/suite.json alongside the base64-encoded seal; the plaintext of the held-out predicates never lands on disk in that file. The sandbox layer’s capability manifest denies read of .rack/sandbox/holdout/ so a model-facing tool cannot request the seal bytes.

A composer that produces a suite whose predicates evaluate identically on the current workspace and on a deterministic byte-shuffled perturbation of the touched surface is marked holdout_kind="trivial" and T1 treats the held-out half as auto-pass (lateral chain branch E). This closes the escape hatch where a model influences the composer into emitting theatre.

T1 (ract.core.loop.check_t1) duck-types the dual suite through a visible/held_out attribute pair and calls check_visible_and_held_out, which fires laziness.violated with kind="visible_holdout_gap" when the visible half is all-ok but the held-out half is not. Substrate T1 semantics remain unchanged for callers that hold a bare AcceptanceSuite.

Gate G2 — mutation-kill threshold. After the model claims completion and before the StepTransaction commits, enforce_g2 runs mutation testing against the touched surface. A MutantSource produces mutants (default is a built-in AST-level arithmetic-operator swap; the MutmutSource adapter wires to the optional mutmut dev dependency), the runner caps per-file mutants at 200 with deterministic sampling under a fixed seed (lateral chain branch B), and each mutant is scored against the acceptance suite under a per-mutant timeout defaulting to 10s. A mutant that exceeds the timeout lands under mutants_survived, never mutants_equivalent, so a hung evaluator cannot inflate the kill rate. Surviving mutants are batched into groups of 10 for a single companion-backed equivalence check per batch (lateral chain branch C); mutants flagged equivalent land in mutants_equivalent and are subtracted from the denominator. Below a 0.7 kill threshold, the gate returns should_roll_back=True and emits laziness.violated with kind="mutation_kill_below_threshold". The full MutationReport is persisted to evals/runs/<run_id>/mutation.json.

Both gates land as ALM extensions on top of the substrate; nothing in ract.core imports from ract.antilazy. The trace vocabulary gains exactly one new value (laziness.violated); every other event site is reused.

See ADR-0019 for the design rationale and rejected alternatives, and docs/RACT_v0.4.0_ANTILAZY_SPEC.md §3.1 and §3.2 for the master-spec source.

Anti-Lazy Gate G3 (patch differentiation) and Gate G4 (coverage delta)

ALM spec §3.3 and §3.4; §13 signals 3 and 4. Two more failure modes the substrate suite and G1/G2 do not close:

Gate G3 — patch differentiation. run_patchdiff(patch, workspace, generator, runner, baseline_kind="null") asks a companion-shaped DifferentiatorGenerator for pytest-format tests targeting the functions the diff touches. The generator allocates a total budget (default 30 tests per transaction, capped per function at 10) with proportional allocation across touched functions (lateral chain branch A). Each candidate runs three times against the patch to filter flaky tests, then keeps only those whose verdict differs between patch and the baseline. A diff that touched functions but produced zero surviving differentiators is is_semantic_noop=True and rolls back with kind="semantic_noop".

The leakage fingerprint is a rolling hash (SHA-256) over the newline- joined added lines per hunk. For every hunk that clears the 5-line / 100-char floor (lateral chain branch B), the scan queries git history via git log --all -S and (when supplied) the optional RetrievalIndex. A match surfaces as a ref in leakage_matches; the report also carries leakage_below_floor (below-floor hunk count) and retrieval_index_absent (lateral chain branch E) so the reviewer sees what evidence the scan consulted. Non-empty leakage_matches rolls back with kind="solution_leakage".

Gate G4 — coverage delta. run_coverage_delta(parent, child, patch, mutation_report_parent, mutation_report_child) computes lines_new = added_lines - moved_lines (lateral chain branch C: a pure refactor moves lines, and moved lines are already covered on the parent side), reads coverage.<path> metadata from the child snapshot to count covered new lines, and returns coverage_ratio = lines_new_covered / lines_new. Below tau_cov = 0.8 rolls back with kind="coverage_delta_insufficient". For non-trivial changes the mutation-coverage delta between child and parent reports must exceed delta_mut = 0.1.

A change is trivial iff the substantive added-line count is at or below 2 and every substantive added line has a whitespace-normalized match in the removed lines (a pure reformat), or the substantive added-line count is zero. Trivial changes skip the mutation-delta check. Non-Python touched files land under non_python_files; the gate does not measure coverage for them yet (v0.5 backlog).

Both gates land as pure-over-inputs helpers so tests exercise them without live worktrees; the StepTransaction pre-commit path calls them alongside G2. The trace vocabulary gains no new event kind; both gates emit under laziness.violated (with kind payload discriminators semantic_noop, solution_leakage, and coverage_delta_insufficient) and under predicate.evaluated (with kind="coverage_delta" for successful G4 runs).

See ADR-0020 for the design rationale and rejected alternatives, and docs/RACT_v0.4.0_ANTILAZY_SPEC.md §3.3 and §3.4 for the master-spec source.

Anti-Lazy Gate G5 (test integrity) and Gate G6 (symbol-graph under-edit)

ALM spec §3.5 and §3.6; §13 signals 5 and 6. Two more failure modes the substrate suite plus G1-G4 do not close:

Gate G5 — sandbox-enforced test integrity. analyze_diff (parent_snapshot, child_snapshot, config) walks the diff between parent and child workspace snapshots per Python test file. Any hit against config.denied_ast_patterns (net-new pytest.skip, pytest.xfail, pytest.mark.skip*), config.denied_assertion_transforms (assertion_removal, assert_true_to_pass), config.denied_file_edits (tests/**/*grader*.py, tests/**/conftest.py), or config.monkey_patch_watchlist (sys.modules['grader'], builtins.__import__, sys.settrace) is a hard-block violation. The AST analyzer also surfaces metaprogramming shapes — getattr(pytest, 'skip')(), pytest.__dict__['skip'](), exec("pytest.skip()") — under the pattern test_integrity_metaprogramming_escape (Second Pass Q1). enforce_g5 rolls the transaction back and emits laziness.violated with kind="test_hack_denied". An operator handshake covering a denied pattern flips the gate to passed=True and emits the handshake.requested / handshake.resolved pair per SUBSTRATE module_05.

Portability skips (pytest.skip(reason="only on windows"), @pytest.mark.skipif(sys.platform == ...)) are exempt by the lateral-branch-A rule — false-positive rollbacks would train the operator to disable the gate. Test files in TypeScript, Go, and Rust surface a test_integrity_unsupported_language advisory (lateral branch D); the run continues but the coverage gap lands in the trace so a reviewer can see what evidence the analyzer consulted.

The CapabilityManifest grows a strict-mode TestIntegrityConfig section populated with the §3.5 defaults; ManifestValidator refuses a manifest whose denied_ast_patterns or denied_file_edits is empty (code="test_integrity_section_narrowed"). Narrowing requires a signed operator handshake — parallel to the tier-3 compile-time hard-off pattern from ADR-0012.

Gate G6 — symbol graph and under-edit closure. build_graph (workspace, cache_db=path) parses every Python file in the snapshot with the stdlib ast module and produces a SymbolGraph (symbols, call_edges, import_edges, generated_files). The graph persists to ${WORKSPACE_META}/symgraph.db (SQLite) keyed by workspace snapshot digest (lateral branch B); a fresh call with an unchanged workspace loads from the cache instead of re-parsing.

compute_closure(graph, edited_symbols, edited_files, passing_tests_touched, declared_unaffected) returns the set of downstream callers of the edited symbols, partitioned into covered_by_edit (caller lives in an edited file), covered_by_test (a passing test exercises the caller), covered_by_declaration (the acceptance suite explicitly marks the caller unaffected), and uncovered (nothing covers it). Generated files (from .gitattributes linguist-generated=true plus a per-language heuristic default — *_pb2.py, *_pb2_grpc.py, **/generated/**, Second Pass Q4) drop from the downstream set. Getattr-based references (Second Pass Q2) land in getattr_advisories — an advisory-only channel because stdlib ast cannot see through reflection any more than tree-sitter could.

Non-empty uncovered rolls the transaction back with kind="under_edit_uncovered_callers" and the uncovered caller list gets injected into the next planning prompt with the message “the following call sites depend on symbols you just changed. Update them or explicitly declare them unaffected with rationale.”

Both gates land as pure-over-inputs helpers so tests exercise them without live worktrees. WorktreeManager._check_test_integrity and WorktreeManager._check_under_edit are thin delegators; the substrate merge site calls them in front of commit. The trace vocabulary gains no new event kind — both emit under laziness.violated (with kind payload discriminators test_hack_denied and under_edit_uncovered_callers) and under predicate.evaluated (with kind="test_integrity_advisory" for passing runs that had advisories).

Design decision (v0.4.0-rc1): stdlib ast ships as the parser rather than tree-sitter. Python-only coverage is what the DoD requires; tree-sitter is a v0.5 backlog switch when the language expansion pipeline actually needs the wider grammar. Extension points for TypeScript, Go, and Rust are declared and stub-implemented today; the switch is a parser change, not an API change.

See ADR-0021 for the design rationale and rejected alternatives, and docs/RACT_v0.4.0_ANTILAZY_SPEC.md §3.5 and §3.6 for the master-spec source.

Anti-Lazy Gate G7 (companion red team) and Gate G8 (effort reconciliation)

ALM module_04 adds the last two completion-path gates before the sycophancy circuit lands in module_05.

G7 (companion red team). A cold-context second provider receives only the intent, the final diff, and the visible predicates (never the held-out ones, never the event trace, never the primary’s turn history). Its job is to propose counterexamples that break the completion claim. The companion runs in a read-only workspace mount (bwrap on Linux, seatbelt on macOS) with a 2-minute wall-time budget. A surviving counterexample — one that passes on the pre-change workspace and fails on the post-change workspace — emits laziness.violated with kind="companion_counterexample" and the loop resumes with the counterexamples injected into the next planning prompt.

The router-level different-provider constraint refuses to schedule a companion whose Provider.name matches the current primary or any of the last three primary-step providers; matches raise CompanionProviderCollisionError. Single-provider deployments opt into deployment_mode="single_provider_advisory" and get advisory-only findings (they log but do not block).

G8 (effort reconciliation). Before the loop enters step one, a static-heuristic estimator produces a low-precision effort estimate from the workspace: grep density of intent keywords against filenames, symbol-graph fanout of matched entry points, existing test-to-symbol ratio, per-symbol median lines from git history. No model call, so the primary cannot frame the intent to bias the estimate. Keyword extraction drops tokens that hit more than half of workspace filenames as low-signal (Second Pass Q3 defense against keyword-packing). After T1 fires, realized effort is measured from the final diff; a ratio below tau_effort (default 0.3) on any dimension queues a suspicion prompt and holds COMPLETE until the primary either enumerates skipped items with justification or produces additional work that closes the anomaly.

The completion-gate wiring lives in ract.antilazy.completion_gate.run_completion_gates; the LoopController calls it after T1 predicate evaluation and reads blocks_complete from the returned CompletionGateOutcome to decide whether to terminate or queue the resume prompt.

The router-gate GateConfig extends with anti_lazy_conformance: float = 0.7; providers below the floor are refused for both primary and companion roles at registration. evals/conformance/COMPANION_MATRIX.md publishes the eligible primary-companion pairs (different training family, anti-lazy score ≥ 0.7, schema score ≥ 0.9); it is regenerated idempotently by evals/leaderboard/update_companion_matrix.py.

Module_04 also closes module_01’s flagged gap on holdout_kind="trivial": a per-pipeline trivial-rate ceiling (enforce_trivial_rate_ceiling in ract.antilazy.holdout) refuses to compile a new held-out suite when the composer’s rolling trivial rate exceeds 0.3 over the last 20 compositions. The gap becomes a loud refusal (TrivialRateCeilingExceededError) instead of a silent auto-pass.

See ADR-0022 for the design rationale and rejected alternatives, and docs/RACT_v0.4.0_ANTILAZY_SPEC.md §3.7 and §3.8 for the master- spec source.

Anti-Lazy Attestation: three signatures and Invariant AL-1

ALM module_05 lands the sacred-spine change: the Rootknot gains a third signature (antilazy_signature), a snapshot of the eight anti-lazy gate outcomes (gate_results), and a taint value the sycophancy circuit produces (reversal_taint). schema_version bumps to 3; canonical_bytes dispatches on the version so v1 (v0.3) and v2 (v0.4 substrate) rootknots continue to verify under the compatibility reader.

The three signatures. A v3 Rootknot carries the generator signature (session key, RK-1.2), the environment signature (sandbox key, RK-3.1), and the anti-lazy signature (ALM verifier key, AL-1.1). The three keys live in three separate processes: model layer, sandbox, ALM verifier. Compromising any one does not forge the others.

Invariant AL-1 (Anti-Lazy Attestation). verify_workspace runs three sub-clauses per v3 rootknot:

Under strict=True the bar rises to “AL-1 required” the same way module_06 raised it to “RK-3 required”: pre-v3 sidecars are refused outright.

Sycophancy circuit breaker (ract.antilazy.sycophancy). The scanner reads the event trace for assistant turns that flip position within a five-turn window without intervening evidence (predicate evaluation, tool result, companion response, or Investigator report). Position classification is deterministic (regex plus small heuristic ledger) so the sycophancy detector is independent of the primary provider. A suspicious reversal fires a forcing prompt via force_evidence_or_restore; unresolved suspicious reversals set reversal_taint to partial. The two-consecutive-reversal gate plus the Investigator-report exemption keep the false-positive rate bounded.

Investigator (ract.antilazy.investigator). A pre-completion contract that reads files the primary loop did not touch. Files are selected by symbol-graph adjacency (import + call edges) to touched symbols; the top 20 by score are opened (Lateral Chain branch B). The selection pads with lexicographically-earliest untouched files when the adjacency ranking is shorter than the bound, so a primary that tries to name no symbols the graph would connect to hidden files still cannot cap the Investigator’s reach (Second Pass Q3 defence). The Investigator’s probe is a companion-provider callable (different family per G7); its report is required input to G6 and G7. A completion without an investigator.report event in the trace emits laziness.violated with kind="investigator_missing" and does not authenticate.

ALM verifier key chain of custody. The v3 sidecar embeds the ALM pubkey the sidecar was signed under. This is trust-by-declaration: the sidecar carries both the signature and the pubkey that verifies it. The verifier is REQUIRED to cross-check the embedded pubkey against an out-of-sidecar source: a resolver passed as alm_pubkey to verify_workspace (typically a lookup into the workspace-level .rack/alm/archive/*.key file the ALM verifier process wrote at run close, or an operator-supplied registry). The load_sidecar_alm_pubkey docstring documents this explicitly. See ADR-0023.

See docs/RACT_v0.4.0_ANTILAZY_SPEC.md §4, §5, §8, §10 for the master-spec source.

Anti-Lazy Isomorphic Perturbation Gate

ALM master spec §9. An optional completion-path gate that fires only when the intent is rule-like — universally quantified (“every user must have exactly one primary email”; “no function may bypass the audit logger”; “all monetary values are stored as integer cents”). The gate restates the intent under three isomorphic transformations (rename entities, swap syntax, permute example order) and dispatches each transformed variant to the primary provider with the same workspace. Solutions are compared under AST-normalized digest with the rename map applied in reverse; divergence emits laziness.violated with kind="isomorphic_divergence" and blocks COMPLETE, injecting the divergence as evidence into the next planning turn.

Detector (ract.antilazy.iso_perturb.detect_rule_like_intent). Stdlib-regex over the universal-quantifier keywords (every, all, no, exactly one) and the modal keywords (must, never, always, cannot). Returns a RuleLikeDetection with a confidence score; below 0.7 confidence the caller runs one transformation instead of three (lateral chain branch A). The detector is deliberately over-inclusive — a false positive costs one extra companion dispatch, a false negative loses the gate entirely, which is the more expensive error.

Compile pass (IntentCompiler.compile_and_detect_rule_like). The rule-like flag rides alongside the AcceptanceSuite return so loop wiring reads a single (suite, rule_like) tuple and does not re-parse the intent. Substrate callers that call compile see the old return shape unchanged.

Transformations (transform_intent). Deterministic, stdlib only. rename_entities substitutes free variables against a fixed synonym table; identifiers in workspace_symbols pass through unchanged so the rename does not shift domain vocabulary (lateral chain branch B). swap_syntax reorders clauses via a small sentence-splitter pass. permute_examples reverses dash-prefixed, numeric-prefixed, or comma-quoted lists inside the intent. The three variants are always returned in the same fixed order so the transformed-solution digests stay stable across runs.

AST-normalized comparison (compare_solutions). When both the original and transformed solutions parse as Python, the reverse renaming map is applied to the transformed solution and both are compared via ast.dump. Exact match returns similarity 1.0 with no divergence reason. Below the similarity threshold the divergence reason is one of ast_dump_mismatch, parse_failure_original, parse_failure_transformed, string_similarity_below_threshold, or solution_missing (closed vocabulary). Non-Python solutions fall back to difflib.SequenceMatcher.ratio and emit an advisory under lateral chain branch C.

Loop wiring (LoopController.iso_perturb). An optional IsoPerturbBundle (primary SolutionProducer, optional companion, config, workspace-symbol preservation set, report directory). When present, the loop’s completion callback runs the gate after the module_04 completion gates. Divergence writes the resume prompt into _repair_intent and returns blocks_complete=True so the loop does not terminate COMPLETE. The gate itself internally checks rule-like detection and returns skipped_reason="non_rule_like" for non-rule-like intents, matching the DoD “the gate does not fire on non-rule-like intents”.

Orthogonality with G1 (lateral chain branch D). G1 verifies that the specific solution passes the held-out predicates the composer wrote. Iso-perturbation verifies that the solution’s SHAPE is invariant under transformation of the intent. Different questions; both run when the intent is rule-like. See ADR-0024 for the explicit note in the “Rejected alternatives” and “Interaction with G1” sections.

Report at evals/runs/<run_id>/iso_perturb.json. The canonical form carries the original intent, the transformations with their renaming maps, the original and transformed solution digests, the divergences, and the is_pattern_matching flag. Written on every rule-like completion for retrospective audit. See ADR-0024.

Token budget system (v0.5.0 memory discipline)

Every function that reaches a model in v0.5.0 declares a budget structure and every write into the assembled context passes through a per-invocation :class:BudgetAccountant. The accountant is the pre-model gate: on over-ceiling it refuses the invocation BEFORE the model call and emits budget.exceeded to the event trace.

Surface: src/ract/memory/budget.py (accountant + declaration + narrowing types), src/ract/memory/budget_defaults.yaml (per- function defaults), src/ract/memory/budget_registry.py (typed loader), src/ract/memory/composition.py (playbook override + runtime narrowing), src/ract/memory/events.py (null-sink emitter helpers for the seven memory-discipline event kinds; module_09 swaps the null sink for :class:JsonlEventWriter and bumps the closed EventKind vocabulary).

Master spec: docs/RACT_v0.5.0_MEMORY_DISCIPLINE_SPEC.md §The token budget system. Rationale: ADR-0031.

Three sources feed the budget in precedence order: the function default from budget_defaults.yaml; the composition override from the playbook YAML for the current use case (module_07); the runtime adjustment from the self-adjustment layer (module_08). Runtime adjustment ALWAYS narrows, never widens; widening is a design change that requires a fresh function-default commit. Both narrowing paths refuse widening at construct time and at helper time (belt-and- suspenders).

Sacred spine anchor: tests/memory/test_budget_ceiling.py:: test_over_ceiling_refuses_invocation_before_model_call.

Symbol index (v0.5.0 memory discipline)

The first of the three memory-discipline indexes. SQLite-backed store at .rack/index/symbols.db with an FTS5 mirror for docstring + name full-text search. Tree-sitter parses Python, TypeScript, Rust, and Go into a flat symbol list per file; the store is language-agnostic so find_by_name("User") returns Python + TypeScript matches in one result set.

Surface: src/ract/memory/symbol_index.py (store + query API + :class:SymbolRow), src/ract/memory/symbol_index_schema.sql (canonical schema), src/ract/memory/parser.py (extension dispatch + content-hash + token-count helpers), src/ract/memory/languages/{python,typescript,rust,go}.py (per- language tree-sitter chunking with pinned grammar versions), src/ract/memory/walker.py (.gitignore + .ractignore respecting file walk + initial_build), and src/ract/memory/watcher.py (watchdog observer + per-path debouncer + periodic mtime-scan fallback).

Master spec: docs/RACT_v0.5.0_MEMORY_DISCIPLINE_SPEC.md §The three indexes / Symbol index. Rationale: ADR-0032.

Grammar pins are load-bearing: each language module refuses to load on a mismatched tree-sitter-<lang> distribution version, so a silent AST-node-kind rename cannot degrade the parse. The watcher runs two invalidation paths side by side; the periodic mtime scan lives on its own daemon thread so a slow parse cannot block missed-save recovery on Windows.

Sacred spine anchor for FTS mirror consistency: tests/memory/test_symbol_index.py:: test_find_by_text_reflects_update_in_same_transaction.

Graph index (v0.5.0 memory discipline)

The second of the three memory-discipline indexes. SQLite-backed store at .rack/index/graph.db with an edges table keyed on (source_symbol_id, target_symbol_id, edge_type, location_file, location_line). Every edge references symbols.id from the module_02 store; the two stores are separate SQLite databases and maintain referential integrity through the graph populator’s source-file-scoped delete + re-insert path rather than declared foreign keys.

Surface: src/ract/memory/graph_index.py (store + query API + :class:EdgeRow), src/ract/memory/graph_index_schema.sql (canonical schema), src/ract/memory/lsp.py (multilspy wrapper + per-language adapter map + synthetic probe), src/ract/memory/graph_populator.py (LSP-driven initial build + per-file update + probe-cache), and src/ract/memory/lsp_fallback.py (symbol-only degradation with neighborhood_source='symbol_only' marker).

Master spec: docs/RACT_v0.5.0_MEMORY_DISCIPLINE_SPEC.md §The three indexes / Graph index. Rationale: ADR-0033.

Query API: :meth:GraphIndex.callers_of / :meth:GraphIndex.callees_of (1..N hops, exclude symbol-only edges from transitive walks), :meth:GraphIndex.blast_radius (symmetric N-hop reach as :class:SymbolRow set), :meth:GraphIndex.path_between (shortest edge path, BFS with max_hops guard), :meth:GraphIndex.orphans (dead-code candidates; exclude_public=True by default so the public API is not surfaced), :meth:GraphIndex.hotspots (edges at or above a strength threshold).

Every query helper accepts an optional :class:~ract.memory.budget.BudgetAccountant so a caller building a retrieval bundle for a model call seats the traversal cost against the same accountant that gates the invocation (memory-discipline axiom 1).

LSP fallback: when :func:~ract.memory.lsp.probe_lsp reports a language as unavailable, the populator invokes :func:~ract.memory.lsp_fallback.populate_symbol_only and inserts one self-referential edge per symbol under that language; edges are marked neighborhood_source='symbol_only' so downstream retrieval (module_05) treats them as “no neighborhood” rather than a callback loop.

Recommended LSP installs (per-platform):

Semantic index (v0.5.0 memory discipline)

The third of the three memory-discipline indexes. LanceDB-backed vector store at .rack/index/semantic/ with one embedding per AST chunk. Chunks derive from the module_02 :class:~ract.memory.symbol_index.SymbolRow records; small symbols produce one chunk, symbols over the 500-token cap split at logical boundaries (blank-line groups + line-count fallback) with the parent signature prepended to every sub-chunk.

Surface: src/ract/memory/semantic_index.py (:class:SemanticIndex store + :class:ChunkRow value type + query API), src/ract/memory/embedding.py (:class:~ract.memory.embedding.EmbeddingModel protocol + :class:~ract.memory.embedding.BgeSmallEmbedding / :class:~ract.memory.embedding.NomicEmbedTextEmbedding real wrappers + :class:~ract.memory.embedding.SyntheticHashEmbedding offline / CI fallback + :func:~ract.memory.embedding.load_embedding dispatch), src/ract/memory/chunker.py (:func:~ract.memory.chunker.chunk_symbol splitter + oversize warning), src/ract/memory/semantic_builder.py (initial + per- symbol build path + parent-symbol linkage helper), and src/ract/memory/cpu_fallback.py (:func:~ract.memory.cpu_fallback.probe_lancedb + backend override).

Master spec: docs/RACT_v0.5.0_MEMORY_DISCIPLINE_SPEC.md §The three indexes / Semantic index + §Chunk discipline. Rationale: ADR-0034.

Query API: :meth:SemanticIndex.search (top-k vector search with optional filter), :meth:SemanticIndex.search_by_symbol (mean- vector query over the seed symbol’s chunks, excluding the seed itself), :meth:SemanticIndex.search_with_budget (returns as many top-k results as fit under a caller-supplied token cap; skips individual chunks that overflow the remaining budget so a later smaller chunk can still fit — Second Pass Q1 pack-greedy-by- relevance), :meth:SemanticIndex.enrich_with_graph (one-hop graph enrichment on semantic hits with default neighborhood_source='lsp' filter — module_03 POST inbound constraint 1).

Every write path (:meth:insert_or_update / batch variant / :meth:delete_by_symbol / :meth:delete_by_file) validates the vector dim against the store’s embedder and rejects chunks whose chunk_kind is outside the shipped :data:CHUNK_KINDS vocabulary. Store identity is protected by metadata.json alongside the LanceDB directory: a re-open under a different embedder raises :class:~ract.memory.semantic_index.EmbeddingModelMismatchError; metadata missing while the chunks table exists raises :class:~ract.memory.semantic_index.SemanticStoreCorruptError (Second Pass Q4).

Chunk identity joins on module_02 :attr:~ract.memory.symbol_index.SymbolRow.content_hash and the symbols.id foreign key; no parallel symbol id space is created (module_02 POST inbound constraint 2). The semantic-builder’s initial pass also populates :attr:~ract.memory.symbol_index.SymbolRow.parent_symbol_id for method-kind rows against their class-container line ranges (module_03 POST inbound constraint 2 — the schema column has been unused since module_02 and lands its first writer here).

Offline install path: sentence-transformers is an OPTIONAL extra (pip install ract[embedding]). Callers who want a real BGE / Nomic model set either RACT_EMBED_ONLINE=1 to allow the HuggingFace download or point RACT_EMBED_MODEL_ROOT=<dir> at a directory containing <dir>/bge-small-en-v1.5/ weights. Offline / CI paths use :class:~ract.memory.embedding.SyntheticHashEmbedding which produces deterministic (identity-preserving, not semantic) vectors per text so the store + query API surface fully under test.

LanceDB availability is probed at open time by :func:~ract.memory.cpu_fallback.probe_lancedb; the result is on the store instance as lance_probe for diagnostic use. The RACT_LANCEDB_BACKEND env var forces GPU or CPU regardless of the auto-probe.

Retrieve primitive (v0.5.0 memory discipline)

Master spec: docs/RACT_v0.5.0_MEMORY_DISCIPLINE_SPEC.md §The retrieve primitive + §Retrieval cascade + §Cache layer. Rationale: ADR-0035.

The retrieve primitive is the composition point over the three memory-discipline indexes. It takes a :class:~ract.memory.retrieve.RetrievalQuery (symbol names, keywords, graph seeds, direction, hops, file scope, exclude paths), a list of :class:~ract.memory.retrieve.IndexRef (one per available index), a token :data:~ract.memory.retrieve.TokenBudget, a :class:~ract.memory.chunk.ChunkFormat, and a :class:~ract.memory.retrieve.RetrievalStrategy; returns a :class:~ract.memory.retrieve.RetrievalBundle with the chunks that fit under budget plus a full query trace.

Surface: src/ract/memory/retrieve.py (the retrieve function

Cascade shape (four levels; per master spec §Retrieval cascade):

  1. Level 1. FULL for every match. If under budget, return.
  2. Level 2. FULL for exact and graph; SIGNATURE for keyword and semantic.
  3. Level 3. FULL for exact; SIGNATURE for graph; drop semantic.
  4. Level 4. SIGNATURE for exact; drop everything else. Return with dropped_symbols populated.
  5. Refuse. If Level 4 still exceeds budget, raise :class:~ract.memory.retrieve.BoundedContextError and emit retrieval.refused.

Termination is bounded by construction: the primitive gathers every candidate once at entry, then re-renders one fixed pool per level. Growth is impossible because the per-level format table only drops or shrinks (never adds). test_cascade_never_loops_returns_or_refuses is the sacred-spine anchor.

Cache: SQLite at .rack/cache/retrieval.db (WAL enabled). Key digest is SHA-256 over canonical_json(query) + repo_commit_hash. Each entry records the referenced symbol id list plus file path list so :meth:~ract.memory.cache.RetrievalCache.invalidate_by_symbol and :meth:~ract.memory.cache.RetrievalCache.invalidate_by_file drop matching entries on a watcher save. Different commit hashes produce distinct cache keys, so a cache hit against the old commit can persist under its old key without polluting the new commit’s answers.

Chunk formats: FULL / BODY_ONLY / SIGNATURE / SUMMARY. SUMMARY delegates to a provider summarize(chunk) call; without a provider the returned chunk carries body = "summary unavailable" and :attr:~ract.memory.chunk.Chunk.summary_pending is True. A real provider integration lands in module_06 alongside the four function contracts.

Inbound-constraint honors:

Events emitted (all null-sink until module_09 wires the real sink): retrieval.requested at entry, retrieval.cascaded on every downgrade, retrieval.satisfied on successful return, retrieval.refused on cascade exhaustion.

Function contracts (v0.5.0 memory discipline)

Four verbs at src/ract/memory/functions/ carry a change from user request through to a candidate diff. Master spec §Function contracts; ADR-0036.

The four output contracts (WorkOrder, ResearchBundle, ChangePlan, CandidateDiff) live at src/ract/memory/functions/contracts.py as frozen dataclasses with canonical JSON round-trip via to_json / from_json.

Shared plumbing:

Structured generation for edit output is a lightweight post-generation validator in v0.5.0 (see ADR-0036 §Alternative 3); grammar-constrained generation via Outlines defers to v0.6.

The four v0.6 verbs (verify, review, commit, document) defer per master spec §Bounded scope.

Playbook composition (v0.5.0 memory discipline)

Four playbook YAMLs at src/ract/memory/playbooks/ compose the four function contracts into named workflows. Master spec §Playbooks; ADR-0037.

The composition runner at src/ract/memory/composition_runner.py loads the YAML into a frozen PlaybookSpec. Each phase is a PhaseSpec naming the verb (intake / research / plan / edit / reproduce) plus optional overrides (retrieval hints, budget narrowings, split threshold, per-iteration budget, max iterations, reproduce command). run_playbook(spec, request, repo_root, provider, indexes, ...) sequences the verbs, threads outputs through an optional SessionMemory, and returns a PlaybookResult (work_order, research, plan, edits, phase_records).

Phase types:

Error family (all subclass ract.memory.functions.errors.MemoryFunctionError):

Ambiguity route: when intake returns a WorkOrder with non-empty ambiguity_flags, the runner emits a budget.declared event carrying the flags and adds an ambiguity_flag: proceeding with risk marker note to the intake PhaseRecord. The runner does not halt; the flag is a documented risk marker per master spec §intake failure modes and closes module_06 POST inbound constraint 1.

Adding a fifth playbook is a one-file change: drop a new YAML matching the shipped schema into src/ract/memory/playbooks/. list_playbooks() discovers it via directory scan; no code edit is required to compose the existing four verbs.

The eight deferred playbooks (security audit, feature endpoint, migration, code review, performance, dead-code, schema migration, config change) defer per master spec §Bounded scope; each sits downstream of one or more of the v0.6 verbs.

Self-adjustment probes (v0.5.0 memory discipline)

Three probe suites at src/ract/memory/probes/ measure provider behavior on the actual provider mix. Master spec §Self-adjustment; ADR-0038.

The scheduler at src/ract/memory/probes/scheduler.py invokes the three probes with the same provider and sink, then reduces to a ModelCapability record written atomically to .rack/probes/capability.json (tmp + fsync + os.replace). read_capability_record returns None on missing file (fresh install path — module_01 spec defaults fire); malformed JSON or unsupported schema version raises ValueError so a corrupted file never silently reverts.

Failure records (src/ract/memory/failure_records.py): every function failure appends one FailureRecord JSONL line to .rack/failures/records.jsonl. The record shape excludes prompt / response content by design (privacy invariant enforced at the type). aggregate(root, window_days=7, current_budgets=None) groups by (function, failure_type) inside the window and emits a NarrowingProposal per (function, budget_field) pair whose count reached REPEATED_FAILURE_THRESHOLD (3). Proposals refuse widening at construct time; append_applied_narrowing writes one audit line per applied narrowing to .rack/failures/applied_narrowings.jsonl. failure_from_phase_record bridges module_07’s PhaseRecord.outcome == "raised" into the aggregator input (module_07 POST inbound constraint 1).

Repo fingerprint (src/ract/memory/repo_fingerprint.py): compute(root, symbols, graph, ...) produces a RepoFingerprint with average function tokens, average import depth per file, LSP response-time percentiles, test-suite runtime, and commit frequency per week. Fresh-repo fields (no LSP history, no test runtime) carry the -1 sentinel; the pure-function mapper retrieval_defaults_from_fingerprint translates the fingerprint into RetrievalDefaults (cache TTL, neighborhood cap, per-symbol target). The mapper is pure over the fingerprint (Second Pass Q3 invariant): same input always produces the same output.

Nightly recompilation and the drift detector defer to v0.6 per master spec §Bounded scope. DSPy signature compilation-recompilation is deferred per ADR-0043 (no src/ract/compilation/ directory; no dspy dependency in pyproject.toml). LeWM 23-dim behavioral-vector drift detection is deferred per ADR-0044 (no src/ract/observability/ package; no lewm.py / drift.py). Module_09 wires the manual ract memory apply-narrowings verb.

Memory-discipline integration (v0.5.0 module_09)

Module_09 plumbs the module_01-08 substrate into the existing SubstrateLoop, the Rootknot provenance capability, the closed EventKind vocabulary, and the ALM pre-commit gates. Every wiring is opt-in per caller so v0.4.x runs see no behavior change.

SubstrateStepSpec.metadata is the wiring channel. A caller who constructs SubstrateStepSpec(metadata={"retrieval_bundle": bundle}) triggers SubstrateLoop.run_step to emit a retrieval.satisfied event at step start with the bundle’s total_tokens, budget_used_pct, and call_id. A spec without the key produces no such event. The field is a plain dict (default empty), so v0.4.x constructors that never touch it see no signature change beyond the new keyword default.

Rootknot gains an optional retrieval_attestation: Digest | None field. make_rootknot_v3 accepts the field as a kwarg; canonical_bytes() includes it in the signed payload ONLY when the field is non-None. Older sidecars (v1, v2, v3 without the field) verify unchanged — the sacred-spine test test_older_sidecar_still_verifies pins this compatibility. ract.core.rootknot.bundle_digest(bytes) -> Digest is a small SHA-256 helper the retrieve wiring calls to build the attestation value; kept dependency-free so the sacred spine does not import ract.memory.retrieve.

Closed EventKind vocabulary grows by seven. budget.declared, budget.exceeded, retrieval.requested, retrieval.satisfied, retrieval.cascaded, retrieval.refused, probe.evaluated. LEGAL_EVENT_KINDS auto-recomputes via typing.get_args; the ract.memory.events.MEMORY_EVENT_KINDS frozenset mirrors the new names for the module_01-08 helper surface. docs/EVENTS.md schema_version bumps 2 → 3 with the payload schemas.

ALM gates G6 + G7 extend to the edit function. enforce_g6_edit(diff, plan) refuses when the diff touches a file outside plan.load_manifest. enforce_g7_edit(diff, companion) calls the companion’s .review(diff) and refuses on (False, reason). Both raise LazinessViolatedError(kind=...) and emit laziness.violated. Legacy enforce_g6(transaction, graph, edited_symbols) above is unchanged so v0.3/v0.4 callers keep working. CompanionProvider is a Protocol; concrete bridges (the MemoryFunctionProviderProviderAdapter.complete adapter) live outside pre_commit.py.

Three new CLI verbs. ract memory init <path> runs initial builds over the symbol, graph, and semantic indexes for a repo (--skip-semantic skips the LanceDB build on offline / CI). ract memory apply-narrowings [--dry-run] invokes the failure-record aggregator (module_08) and writes proposed narrowings to .ract/memory/budget_overrides.yaml. ract retrieval query <query> extends the existing ract retrieval verb; the legacy search subverb is unchanged. CLI_VERBS gains memory.

Reference: ADR-0039 for the design decisions and rejected alternatives; _BUILD/ract_v0.5.0_memory_discipline/module_09.md for the module fragment.

Verification

v0.5.1 boundary additions