(tasks) add tasks for harness

This commit is contained in:
Story Crater Bot
2026-08-17 23:05:20 -07:00
parent e2d678b118
commit 5a30d0ffc6
74 changed files with 8204 additions and 1 deletions
+1 -1
View File
@@ -1,8 +1,8 @@
.claude
.pi
tasks
target
rust-agentic-task.md
tasks/artifacts/*
*.stderr
+363
View File
@@ -0,0 +1,363 @@
# poimen — task board
80 tasks — 70 build tasks plus **10 composition gates**, one per phase. One file
per task, **self-contained**: inlined design facts, executable
steps, acceptance criteria, a `Verify` section written for someone who did not
build the thing, and the traps worth naming. Reading `rust-agentic-sys.md` is not
required to do a task — it is linked as background only.
Each `Verify` section names the harness, the integration test with numbered
assertions, the command to run, and the **false pass** — the shape of test that
goes green while the feature is broken. Treat the false-pass list as part of the
acceptance criteria, not commentary.
poimen is a **pluggable unit**. It is embedded by other harnesses, not run as an
application. Two things follow, and they are the spine of this board: the public
surface is built early and frozen deliberately, and every phase after it is
judged partly on whether it broke that surface.
## The customization contract — two front doors, one IR
poimen is customized two ways, and they are different axes rather than
alternatives. Confusing them is how a project ends up with two products.
```
workflow.yaml ─────────┐
├──► WorkflowDef ──► Blake3 canonical hash ──► WorkflowVersion
WorkflowDef::builder() ┘ ▲
│ referenced by id
impl Verifier / Judge / ModelProvider / storage ports
```
**YAML declares. Rust implements.**
- **YAML** names steps, transitions, verifier ids, judge ids, retry policy,
budgets, rubrics. It is data. It ships without a compiler and is the surface a
user touches to change *what runs*.
- **Rust** supplies behaviour behind the ports and registers it under an id.
It is code. It is the surface a user touches to change *how something works*.
Neither is a superset. A YAML file can only name capabilities some Rust impl
registered; a Rust impl is inert until some workflow names it.
Three rules the board enforces, each owned by a task:
1. **Both front doors canonicalize to the same IR.** The same workflow written
as YAML and built with `WorkflowDef::builder()` produces a byte-identical
`WorkflowVersion`. §4.1 hashes the canonicalized IR, not the source text, so
this is testable — and if it ever fails, §12's "did this change affect
results" silently returns nonsense. T3.10 owns it and the P-surface gate
re-checks it.
2. **Unresolved ids fail at load, never at spawn.** YAML naming a `VerifierId`,
`JudgeId` or `ModelId` no impl registered is a load error naming the id and
the file. A workflow that parses and then dies mid-run on a missing verifier
has moved a config error into production. T3.9 owns the registry; T3.3 owns
the failure.
3. **Neither door reaches past the ports.** A YAML key that only one storage
backend understands, or a builder method that assumes `redb`, breaks
embedding. The gates check both doors against both storage modes.
## The embedding contract — sidecar, JSON-RPC over stdio
poimen is embedded by harnesses written in other languages; DeepSeek Harness
(`dsh`, TypeScript) is the reference consumer. The boundary is a **child process
speaking JSON-RPC 2.0 over stdio**, not a native addon.
```
┌──────────────┐ spawn ┌───────────────────┐
│ host harness │──────────► │ poimen serve │
│ any language │ ◄─stdio──► │ own process │
└──────────────┘ JSON-RPC └───────────────────┘
```
Chosen because it containerizes with no change, hosts in any language, and keeps
a Rust panic out of the host's crash domain. The cost is a serialization
boundary and a protocol that must be versioned like any other wire format — so
it is versioned like one, with the same discipline §8.7 applies to the log:
methods are added, never repurposed; fields are added, never removed.
**The protocol is a published artifact, not an implementation detail.** A change
to it is a breaking change to every embedder, and the conformance suite (T9.4)
is what makes that statement enforceable rather than aspirational.
An in-process Rust API exists for Rust embedders and is the same surface; the
sidecar is that surface with a wire format in front of it. An HTTP transport
over the identical method set is the natural pod deployment and is deferred, not
designed away.
## Ordering — declared, never derived
**Phase order is the list below. Task ids are opaque and frozen.**
The board reorders as understanding changes; task ids do not move when it does.
`T3.1` is `T3.1` forever, in whatever phase it currently sits, because its
artifacts, its cost-ledger rows and every cross-reference key on that id. New
tasks take new ids rather than renumbering their neighbours.
This is §4.3's `StepId` rule applied to the board itself: *"insert a step at
position 2 and every positional index shifts, but `StepId` does not… never
parsed, never ordered, never assumed numeric."* A board that renumbers to
reorder has the bug it warns its own users about.
Consequence: **id order is not execution order.** Read the phase list, not the
filenames.
Ordering rule: no phase starts until its predecessor's gate is green. The `gate`
task of each phase **is** that gate — a required CI job proving the phase's tasks
compose and that its swappable parts are genuinely swappable. Every build task is
verified alone; the gate verifies the properties no single task owns. `opt-in`
tasks ship disabled and gate nothing.
- **Engineering Quality Rule:** All implementations must strictly adhere to the
idiomatic, zero-copy, and type-safe architecture standards defined in
`rust-guide-line.md`.
- **Agent Output Rule — caveman full.** Every agent on this board writes caveman
full. Applies to prose only.
Drop: articles (a/an/the), filler (just/really/basically/actually/simply),
pleasantries (sure/certainly/happy to), hedging (might be worth/you could
consider), connectives (however/furthermore/additionally), preamble, recap,
tool-call narration, restating these rules back. Fragments OK. Short synonyms
— big not extensive, fix not "implement a solution for".
Preserve exactly, never abbreviate: code blocks, inline code, file paths,
commands, error strings, test names, crate and API names, version numbers,
env vars, URLs. Never invent abbreviations (cfg/impl/req/fn) — they tokenize
the same as the full word and read worse.
Drop caveman where compression creates ambiguity: multi-step ordering,
destructive-operation warnings, anything a misread would break.
Pattern: `[thing] [action] [reason]. [next step].`
Not: "I'll go ahead and implement the transition function for you, which..."
Yes: "transition() exhaustive match, no catch-all. Terminal states reject all."
## Verification practice — script first, source second
**A task is verified by running a script and diffing its output. Reading the
source happens after that diff is clean, never instead of it.** Reviewing a diff
first is how an assertion that was quietly dropped still gets called done: the
code looks right, and nothing proves the test ran.
Every task already carries the four things this needs — a `Harness`, an
`Integration test` with numbered assertions, a `Command`, and a `False pass`
list. The practice is to make them executable rather than descriptive.
**1. One script per task, committed with it.**
```
verify/<task>.sh # runs the task's Command, prints one line per assertion
verify/expected/<task>.txt # the exact output that script must produce
```
**2. Numbered assertion N in the Verify section is test fn `aN_<slug>`.** The
numbering is the contract. `tests/it_scoped_keys.rs` assertion 4 is
`a4_scan_the_raw_table`, and the script reports it by name:
```
it_scoped_keys::a1_open_one_table_keyed_scoped_runid PASS
it_scoped_keys::a4_scan_the_raw_table MISSING
```
`MISSING` is the point. A test fn that does not exist reports missing instead of
being absent from a green summary — the failure mode where `cargo test` says
`ok` because the assertion was never written.
**3. The expected file is the review artifact.** The script's output is diffed
against it. An empty diff is the only pass. A changed expected file in a diff is
a claim that the task's Verify section changed, and gets read as one.
**4. The `False pass` list is the checklist applied *after* the diff is clean,**
not before. Every line saying `PASS` is exactly the state those traps are written
to survive — that is when a reviewer opens the source, and the false-pass list is
what they open it with.
**5. Gate tasks follow the same rule, at phase scope.** The gate's script covers
the properties no single task owns; a phase is green when its gate script's diff
is empty, not when its member tasks individually passed.
**6. No crate, no verification.** Until a `Cargo.toml` exists the scripts report
blocked rather than passing vacuously. A verification step that cannot fail is
not a verification step.
**7. Surface tasks are verified through both front doors.** Any task in
P-surface or later that touches workflow definition asserts the YAML path and
the builder path independently, then asserts their hashes match. One path
verified is half a feature.
## Progress
**Source of truth is the `Status` field in each task file.** The tables below
mirror it; a status changed here and not there is a lie. Regenerate the mirror:
```sh
for f in T*.md; do
printf '%s\t%s\n' "${f%%-*}" "$(sed -n 's/^| Status | *\(.*[^ ]\) *|$/\1/p' "$f" | head -1)"
done | sort -t. -k1,1 -k2,2n
```
Legend: ⬜ not started · 🟡 in progress · ✅ done · ⛔ blocked
| # | Phase | Ids | Tasks | ✅ | 🟡 | ⬜ | Gate | Tokens | Cost |
|---|---|---|---|---|---|---|---|---|---|
| 1 | Foundations | T0.x | 9 | 3 | 0 | 6 | ⬜ T0.9 | 313.0k | — |
| 2 | Walking skeleton | T1.x | 8 | 0 | 0 | 8 | ⬜ T1.8 | — | — |
| 3 | Public surface | T3.x | 10 | 0 | 0 | 10 | ⬜ T3.7 | — | — |
| 4 | Verification | T4.x | 5 | 0 | 0 | 5 | ⬜ T4.5 | — | — |
| 5 | Embedding | T9.x | 6 | 0 | 0 | 6 | ⬜ T9.6 | — | — |
| 6 | Durability hard parts | T2.x | 7 | 0 | 0 | 7 | ⬜ T2.7 | — | — |
| 7 | Grading | T5.x | 12 | 0 | 0 | 12 | ⬜ T5.12 | — | — |
| 8 | Learning loop | T6.x | 8 | 0 | 0 | 8 | ⬜ T6.8 | — | — |
| 9 | Distribution | T7.x | 8 | 0 | 0 | 8 | ⬜ T7.8 | — | — |
| 10 | Operability | T8.x | 7 | 0 | 0 | 7 | ⬜ T8.7 | — | — |
| | **Total** | | **80** | **3** | **0** | **77** | 0/10 green | **313.0k** | **—** |
**What changed in this revision.** The public surface moved ahead of the
durability hard parts, and an embedding phase was added after verification.
Reason: pluggable-first. Under the previous order nobody could write a plugin or
embed poimen until 36 tasks in, which meant the surface others depend on would
have been designed with no user and validated with none either.
The cost that buys: the log format can still churn under a published surface.
That is what T0.4's upcasters are insurance for — §8.7, versioned records,
upcasters on read, variants never removed — and it is why the durability phase
sits immediately after embedding rather than last.
## 1 — Foundations · T0.x
| Task | Title | Size | Flags | Status |
|---|---|---|---|---|
| [T0.1](T0.1-identity-newtypes.md) | Identity newtypes | S | — | ✅ |
| [T0.2](T0.2-kernel-attemptstate.md) | Kernel `AttemptState` | S | — | ✅ |
| [T0.3](T0.3-workevent-and-schemaversion.md) | `WorkEvent` and `SchemaVersion` | M | — | ✅ |
| [T0.4](T0.4-upcaster-framework.md) | Upcaster framework | M | — | ⬜ |
| [T0.5](T0.5-eventlog-port-redb-implementation.md) | `EventLog` port + `redb` implementation | L | — | ⬜ |
| [T0.6](T0.6-atomic-commit-protocol.md) | Atomic commit protocol | M | — | ⬜ |
| [T0.7](T0.7-blobstore-port-redb-implementation.md) | `BlobStore` port + `redb` implementation | M | — | ⬜ |
| [T0.8](T0.8-fold-and-re-derive.md) | Fold and re-derive | M | — | ⬜ |
| [T0.9](T0.9-p0-composition-gate.md) | **Foundations composition gate** | M | gate | ⬜ |
## 2 — Walking skeleton · T1.x
| Task | Title | Size | Flags | Status |
|---|---|---|---|---|
| [T1.1](T1.1-ctx-and-runscope.md) | `Ctx` and `RunScope` | M | — | ⬜ |
| [T1.2](T1.2-stub-model-provider.md) | Stub model provider | S | — | ⬜ |
| [T1.3](T1.3-run-executor.md) | Run executor | L | — | ⬜ |
| [T1.4](T1.4-attempt-lifecycle-and-retry.md) | Attempt lifecycle and retry | M | — | ⬜ |
| [T1.5](T1.5-context-partition-capture.md) | Context partition capture | S | — | ⬜ |
| [T1.6](T1.6-prompt-and-output-blob-capture.md) | Prompt and output blob capture | S | — | ⬜ |
| [T1.7](T1.7-episode-query-surface.md) | Episode query surface | M | — | ⬜ |
| [T1.8](T1.8-p1-composition-gate.md) | **Skeleton composition gate** | M | gate | ⬜ |
## 3 — Public surface · T3.x
The customization contract, built once and then defended by every later gate.
Execution order within the phase is the row order below — note it is not id
order, because T3.8T3.10 were added after the originals and the gate stays last.
| Task | Title | Size | Flags | Status |
|---|---|---|---|---|
| [T3.1](T3.1-workflowdef-ir-canonicalization.md) | `WorkflowDef` IR + canonicalization | L | — | ⬜ |
| [T3.2](T3.2-workflowformat-trait-yaml-and-json.md) | `WorkflowFormat` trait + YAML and JSON | M | — | ⬜ |
| [T3.8](T3.8-workflowdef-builder.md) | `WorkflowDef` builder — the Rust front door | M | new | ⬜ |
| [T3.9](T3.9-capability-registry.md) | Capability registry — ids YAML can name | M | new | ⬜ |
| [T3.3](T3.3-load-time-validation.md) | Load-time validation | M | — | ⬜ |
| [T3.4](T3.4-stepid-stability-checks.md) | `StepId` stability checks | S | — | ⬜ |
| [T3.5](T3.5-interpreter-over-the-ir.md) | Interpreter over the IR | L | — | ⬜ |
| [T3.6](T3.6-version-pinning-at-spawn.md) | Version pinning at spawn | S | — | ⬜ |
| [T3.10](T3.10-front-door-equivalence.md) | Front-door equivalence — YAML ≡ builder | M | new | ⬜ |
| [T3.7](T3.7-p3-composition-gate.md) | **Surface composition gate** | M | gate | ⬜ |
## 4 — Verification · T4.x
| Task | Title | Size | Flags | Status |
|---|---|---|---|---|
| [T4.1](T4.1-verifier-port.md) | `Verifier` port | S | — | ⬜ |
| [T4.2](T4.2-verifierctx-and-the-snapshot-barrier.md) | `VerifierCtx` and the snapshot barrier | M | — | ⬜ |
| [T4.3](T4.3-lazy-blob-access.md) | Lazy blob access | S | — | ⬜ |
| [T4.4](T4.4-retention-ordering-guard.md) | Retention ordering guard | S | — | ⬜ |
| [T4.5](T4.5-p4-composition-gate.md) | **Verification composition gate** | M | gate | ⬜ |
## 5 — Embedding · T9.x
Makes poimen a unit another harness mounts. The reference consumer is `dsh`,
whose architecture already expects capabilities to arrive as swappable providers
— so if the sidecar cannot be mounted as one, the boundary is wrong, not `dsh`.
| Task | Title | Size | Flags | Status |
|---|---|---|---|---|
| [T9.1](T9.1-poimen-sdk-crate.md) | `poimen-sdk` — ports without the engine | M | new | ⬜ |
| [T9.2](T9.2-sidecar-protocol.md) | Sidecar protocol — JSON-RPC 2.0 over stdio | L | new | ⬜ |
| [T9.3](T9.3-serve-stdio.md) | `poimen serve --stdio` | M | new | ⬜ |
| [T9.4](T9.4-protocol-conformance-suite.md) | Protocol conformance suite | L | new | ⬜ |
| [T9.5](T9.5-dsh-reference-plugin.md) | `dsh` reference plugin | M | new | ⬜ |
| [T9.6](T9.6-embedding-composition-gate.md) | **Embedding composition gate** | L | gate | ⬜ |
## 6 — Durability hard parts · T2.x
| Task | Title | Size | Flags | Status |
|---|---|---|---|---|
| [T2.1](T2.1-write-ahead-intent.md) | Write-ahead intent | L | — | ⬜ |
| [T2.2](T2.2-effect-class-recovery.md) | Effect-class recovery | M | — | ⬜ |
| [T2.3](T2.3-rewind-as-fork.md) | Rewind as fork | L | — | ⬜ |
| [T2.4](T2.4-schema-evolution-end-to-end.md) | Schema evolution end-to-end | M | — | ⬜ |
| [T2.5](T2.5-checkpoints.md) | Checkpoints | M | — | ⬜ |
| [T2.6](T2.6-crash-matrix.md) | Crash matrix | L | — | ⬜ |
| [T2.7](T2.7-p2-composition-gate.md) | **Durability composition gate** | L | gate | ⬜ |
## 7 — Grading · T5.x
| Task | Title | Size | Flags | Status |
|---|---|---|---|---|
| [T5.1](T5.1-taskid-at-spawn.md) | `TaskId` at spawn | S | — | ⬜ |
| [T5.2](T5.2-capacitylimits-and-the-residency-invariant.md) | `CapacityLimits` and the residency invariant | M | — | ⬜ |
| [T5.3](T5.3-evaluationstrategy-port-resourceprofile.md) | `EvaluationStrategy` port + `ResourceProfile` | S | — | ⬜ |
| [T5.4](T5.4-deterministicgrader.md) | `DeterministicGrader` | S | — | ⬜ |
| [T5.5](T5.5-pairwisesequential-reference-and-comparison.md) | `PairwiseSequential`: reference and comparison | M | — | ⬜ |
| [T5.6](T5.6-sequential-test-and-stopping.md) | Sequential test and stopping | M | — | ⬜ |
| [T5.7](T5.7-order-alternation-and-sampled-consistency.md) | Order alternation and sampled consistency | S | — | ⬜ |
| [T5.8](T5.8-swiss-pairing.md) | Swiss pairing | M | opt-in | ⬜ |
| [T5.9](T5.9-bradley-terry-fit.md) | Bradley-Terry fit | L | opt-in | ⬜ |
| [T5.10](T5.10-attempt-tournaments.md) | Attempt tournaments | M | — | ⬜ |
| [T5.11](T5.11-degradation-reasons.md) | Degradation reasons | M | — | ⬜ |
| [T5.12](T5.12-p5-composition-gate.md) | **Grading composition gate** | L | gate | ⬜ |
## 8 — Learning loop · T6.x
| Task | Title | Size | Flags | Status |
|---|---|---|---|---|
| [T6.1](T6.1-challenger-allocation.md) | Challenger allocation | M | — | ⬜ |
| [T6.2](T6.2-judge-calibration-set.md) | Judge calibration set | M | — | ⬜ |
| [T6.3](T6.3-promotion-gates.md) | Promotion gates | M | — | ⬜ |
| [T6.4](T6.4-per-variant-aggregation.md) | Per-variant aggregation | M | opt-in | ⬜ |
| [T6.5](T6.5-sandboxed-replay.md) | Sandboxed replay | L | — | ⬜ |
| [T6.6](T6.6-held-out-split.md) | Held-out split | M | — | ⬜ |
| [T6.7](T6.7-drift-check.md) | Drift check | S | — | ⬜ |
| [T6.8](T6.8-p6-composition-gate.md) | **Learning composition gate** | L | gate | ⬜ |
## 9 — Distribution · T7.x
| Task | Title | Size | Flags | Status |
|---|---|---|---|---|
| [T7.1](T7.1-postgres-eventlog.md) | Postgres `EventLog` | L | — | ⬜ |
| [T7.2](T7.2-object-store-blobstore.md) | Object-store `BlobStore` | M | — | ⬜ |
| [T7.3](T7.3-leases-and-fencing.md) | Leases and fencing | L | — | ⬜ |
| [T7.4](T7.4-outbox-relay.md) | Outbox relay | M | — | ⬜ |
| [T7.5](T7.5-partition-keys-on-adapters.md) | Partition keys on adapters | S | — | ⬜ |
| [T7.6](T7.6-tournament-as-a-join-stage.md) | Tournament as a join stage | L | — | ⬜ |
| [T7.7](T7.7-turmoil-suite.md) | `turmoil` suite | L | parallel-ok | ⬜ |
| [T7.8](T7.8-p7-composition-gate.md) | **Distribution composition gate** | L | gate | ⬜ |
## 10 — Operability · T8.x
| Task | Title | Size | Flags | Status |
|---|---|---|---|---|
| [T8.1](T8.1-metering.md) | Metering | M | — | ⬜ |
| [T8.2](T8.2-metrics.md) | Metrics | M | — | ⬜ |
| [T8.3](T8.3-keyed-capability.md) | Keyed capability | L | — | ⬜ |
| [T8.4](T8.4-reduction-and-tiering.md) | Reduction and tiering | L | — | ⬜ |
| [T8.5](T8.5-embedded-mode-smoke.md) | Embedded-mode smoke | S | parallel-ok | ⬜ |
| [T8.6](T8.6-capacity-admission-control.md) | Capacity admission control | M | — | ⬜ |
| [T8.7](T8.7-p8-composition-gate.md) | **Operability composition gate** | L | gate | ⬜ |
+104
View File
@@ -0,0 +1,104 @@
# T0.1 — Identity newtypes
| Field | Value |
|---|---|
| Phase | P0 — Foundations |
| Size | S — under 1 day |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | everything |
## Goal
Every id in the system as a distinct newtype, plus the `Scoped<T>` tenant wrapper
that makes an unscoped storage key a compile error.
## Facts (inlined — no spec read needed)
```rust
pub struct TenantId(Uuid);
pub struct WorkflowId(SmolStr); // logical workflow, stable across versions
pub struct WorkflowVersion(Blake3Hash); // content hash of canonicalized IR (T3.1)
pub struct StepId(SmolStr); // author-assigned, stable across versions
pub struct TaskId(Blake3Hash); // comparison-group key; hash of task input
pub struct RunId(Ulid); // one execution of one workflow
pub struct BranchId(u32); // rewind fork
pub struct AttemptNo(u32);
pub struct Lsn(u64); // per (run, branch) sequence, not global
pub struct GroupEpoch(u32); // comparison-group generation
pub struct Scoped<T> { pub tenant: TenantId, pub inner: T }
```
- `RunId` is `Ulid` so it sorts lexicographically by creation time. Recent-run
scans become a prefix scan instead of a secondary index.
- Multi-tenant from commit one. **Every** table key is `Scoped<_>` — not most.
One unprefixed table is a cross-tenant read that a customer finds first.
- No `Default` on `TaskId` or `WorkflowVersion`. Prior implementation hardcoded a
`"current"` version string: it compiled, passed tests, and made every result
unattributable. A newtype without `Default` refuses to compile instead.
- No `From<RunId> for TaskId`. `TaskId` hashes the task input **before any
workflow touches it**; deriving it from a run id silently makes every run its
own group of one, and it cannot be backfilled.
- `StepId` is opaque: never parsed, never ordered, never assumed numeric.
## Steps
1. Create the ids crate. Dependencies: `uuid`, `ulid`, `smol_str`, `blake3`, `serde`.
2. Declare the ten newtypes above. Derive `Clone, Copy` (where the inner type is
`Copy`), `PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize`.
3. Do **not** derive or hand-write `Default` for `TaskId` or `WorkflowVersion`.
Do not add `From<RunId> for TaskId`.
4. `RunId::new()` wraps `Ulid::new()`; `Ord` delegates to the inner `Ulid` so
ordering is creation order.
5. Define `Scoped<T>`. Define the storage-key trait (whatever the table API
requires) **only** for `Scoped<_>` and `BranchKey` — never a blanket impl for
`T`, or the guard disappears.
6. Add `trybuild` as a dev-dependency with `tests/compile_fail/`:
`default_task_id.rs` (calls `TaskId::default()`), `unscoped_key.rs` (opens a
table keyed on a bare `RunId`). Record the expected `.stderr` files.
## Acceptance
- `trybuild` compile-fail suite: an unscoped table key and a defaulted `TaskId`
both fail to compile.
- Run the suite before writing the impls and watch it fail for the right reason
(missing type, not missing test file).
## Verify
**Harness:** `trybuild` for the compile-fail cases; a scratch `redb` file for the
key round trip.
**Integration test**`tests/it_scoped_keys.rs`:
1. Open one table keyed `Scoped<RunId>`.
2. Write a value under tenant A and a different value under tenant B, using the
**same** inner `RunId`.
3. Read back each; assert each tenant sees only its own value.
4. Scan the raw table and assert exactly two distinct keys exist.
5. Create 1000 `RunId`s across at least 3 distinct milliseconds; assert sorted
order equals creation order.
**Command:** `cargo test -p ids && cargo test -p ids --test compile_fail`
**False pass:**
- Regenerating `.stderr` with `TRYBUILD=overwrite` after the guard broke. Assert
the expected stderr **names `Default` and the key trait** — a test that only
checks "does not compile" passes on a typo.
- ULID ordering asserted over ids minted inside one millisecond, where ordering
comes from the random suffix rather than time. Force distinct milliseconds.
- Step 3 passing because the read path filters by tenant. Step 4 (raw scan) is
what proves the key, not the query.
## Traps
- Blanket `#[derive(Default)]` on the whole module out of habit.
- `Scoped<T>` as a type alias instead of a struct — an alias produces no compile
error, so the guard is decorative.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §3, §19 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+113
View File
@@ -0,0 +1,113 @@
# T0.2 — Kernel `AttemptState`
| Field | Value |
|---|---|
| Phase | P0 — Foundations |
| Size | S — under 1 day |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
The closed kernel attempt enum and its transition function, matching the
normative transition table arm for arm.
## Facts (inlined — no spec read needed)
```rust
/// Kernel. Closed. Users never extend this.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum AttemptState {
Pending,
Running,
Succeeded,
Failed,
/// Crashed mid-side-effect; recovery could not determine what happened.
Indeterminate,
/// Stopped by decision, no dispatched intent outstanding.
Cancelled,
TimedOut,
}
```
Normative legal set — the match has exactly these arms and no others:
| From | To | When |
|---|---|---|
| `Pending` | `Running` | admitted |
| `Pending` | `Cancelled` | run cancelled before the attempt started |
| `Pending` | `TimedOut` | queue deadline passed before admission |
| `Running` | `Succeeded` | completed |
| `Running` | `Failed` | completed with an error |
| `Running` | `TimedOut` | step deadline passed |
| `Running` | `Cancelled` | run cancelled, **no intent was `Dispatched`** |
| `Running` | `Indeterminate` | an intent was `Dispatched` and did not resolve |
| terminal | — | nothing leaves a terminal state |
- `Cancelled` and `Indeterminate` are **separate states**. Cancellation is a
decision; indeterminacy is an unknown. The deciding fact is the intent record
(T2.1): no `Dispatched` intent means nothing external happened, so the attempt
is cleanly `Cancelled`.
- Why it matters beyond taxonomy: operations alerts on `Indeterminate` count and
expects near zero. Route ordinary cancellations there and the alert acquires a
noisy floor, which is the same as not having the alert.
- Domain states are the opposite — open, declared by the workflow as data
(`pub struct StepState(SmolStr)`), validated at load. Not this task.
## Steps
1. Declare `AttemptState` exactly as above.
2. Write `fn transition(from: AttemptState, cause: TransitionCause) -> Result<AttemptState, IllegalTransition>`
as an **exhaustive match** — no `_ =>` catch-all, so adding a state becomes a
compile error at this function.
3. Model the `Running → {Cancelled, Indeterminate}` split on an explicit input:
`TransitionCause::Cancel { intent_dispatched: bool }`. Never infer it from the
drop site.
4. Make every terminal state reject all outgoing transitions with
`IllegalTransition`.
5. Property test with `proptest`: enumerate all `(from, to)` pairs, assert the
accepted set equals the table above exactly — reject-set included.
## Acceptance
- Property test over all state pairs: legal set matches the table exactly.
- Targeted test: cancelling an attempt with **no** `Dispatched` intent yields
`Cancelled`; cancelling one **with** a `Dispatched` intent yields
`Indeterminate`.
## Verify
**Harness:** `proptest`, plus a recorded log fixture once P1 exists.
**Integration test**`tests/it_transition_table.rs`:
1. Build the **cartesian product** of all 7 states × all 7 states, written out by
hand from the table in this file — not generated by the code under test.
2. For each pair, assert accept/reject matches the table.
3. Replay a real recorded run's log (P1 fixture) and feed every observed state
change through `transition`; assert none is rejected. This proves the executor
and the table agree.
4. Two targeted cases: cancel with `intent_dispatched: false``Cancelled`;
cancel with `true``Indeterminate`.
**Command:** `cargo test -p kernel transition`
**False pass:**
- A property test whose generator produces states **by calling `transition`**.
It then agrees with itself and cannot see a missing arm. The pair list must be
written independently.
- Asserting only the accepted set. The rejected set is half the table, and a
permissive `_ => Ok(to)` passes every accept-only test.
## Traps
- Collapsing `Cancelled` into `Indeterminate` "because the tool might have been
mid-something". That "might" is exactly what the intent record answers.
- A `_ =>` arm. It compiles forever and silently absorbs a new state.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §2, §5.1, §15 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+104
View File
@@ -0,0 +1,104 @@
# T0.3 — `WorkEvent` and `SchemaVersion`
| Field | Value |
|---|---|
| Phase | P0 — Foundations |
| Size | M — 1 to 3 days |
| Status | ✅ Done |
| Flags | — |
| Spec | inlined below |
| Blocks | T0.4, T2.4 |
## Goal
The event enum plus the wire-version discipline that keeps a two-year-old log
decodable. Must land before the second event variant is ever written.
## Facts (inlined — no spec read needed)
```rust
pub struct LogRecord {
pub key: BranchKey, // (TenantId, RunId, BranchId)
pub lsn: Lsn,
/// Wire-format version of `event`. Never removed, never reused.
pub schema: SchemaVersion,
pub at: Timestamp,
pub event: WorkEvent,
}
pub struct BranchKey { pub tenant: TenantId, pub run: RunId, pub branch: BranchId }
```
Rules, from record one:
- Every record carries `SchemaVersion`. Written always, even at v1.
- `WorkEvent` is `#[non_exhaustive]`; decode is version-dispatched.
- **Variants are never removed or repurposed.** Deprecated variants stay
decodable forever. Storage is cheap; an undecodable log is not.
- Migrations are upcasters applied on read (T0.4), never by rewriting history.
- A round-trip test per version — a stored fixture of every historical version
still folds to the expected state. **That test is the whole guarantee**;
without it the rules above are aspirational.
- `BranchId` is in the key, not implied. LSNs are **per branch**, not global: a
global counter serializes every run through one atomic, and the ordering
contract only promises per-run total order.
## Steps
1. Declare `SchemaVersion(u16)` and `LogRecord` / `BranchKey` as above.
2. Declare `WorkEvent` as `#[non_exhaustive]` with the v1 variant set the walking
skeleton needs: attempt transitions, run-lifecycle transitions, prompt/output
blob refs, context partition, usage, intent records, and
`Reduced { original: BlobRef, summary: BlobRef }`.
3. Pick a self-describing codec (`serde` + CBOR or similar). Write
`encode(&LogRecord) -> Vec<u8>` and `decode(&[u8]) -> Result<LogRecord>`, with
decode dispatching on the `schema` field read **before** the event body.
4. Write a fixture generator that serializes one record of every variant at v1
into `tests/fixtures/v1/`. Commit the bytes.
5. Write the round-trip test: walk `tests/fixtures/*/`, decode each, assert the
expected value. A directory walk means adding a version folder automatically
extends coverage.
6. Add a test that fails if a fixture directory exists for a version with no
registered upcaster — wire this once T0.4 lands.
## Acceptance
- A serialized v1 fixture checked into the repo, plus a test that decodes it.
## Verify
**Harness:** committed fixture bytes under `tests/fixtures/v1/`, loaded from
disk. A regeneration path exists only behind an env var.
**Integration test**`tests/it_fixture_roundtrip.rs`:
1. Walk `tests/fixtures/*/`, decode every file.
2. Assert each decodes to its expected value, loaded from a committed
expectation file — not reconstructed in the test body.
3. Assert every fixture's `schema` field is present and non-zero.
4. Assert the variant coverage: every `WorkEvent` variant appears in at least one
fixture. A `match` over the decoded set with no `_` arm forces this to fail
when a variant is added without a fixture.
**Command:** `cargo test -p log fixtures` — and in CI, with
`FIXTURE_REGEN` **unset**; assert the test fails loudly if it is set.
**False pass:**
- The fixture being serialized by the code under test at test start, then
immediately decoded. That tests nothing across versions. Bytes must come from
git.
- Coverage drift: a new variant is added, no fixture is written, and the
directory walk still passes because it only checks what is there. Step 4 is the
guard.
## Traps
- Renaming one variant two years in and losing drop-and-re-fold silently.
- Non-self-describing formats where a field reorder decodes to garbage rather
than erroring.
- Omitting `schema` at v1 "because there is only one version".
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §8.2, §8.7 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+86
View File
@@ -0,0 +1,86 @@
# T0.4 — Upcaster framework
| Field | Value |
|---|---|
| Phase | P0 — Foundations |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | T2.4 |
## Goal
Read-time migration chain so old log records fold correctly after the event enum
changes. Never rewrite stored records.
## Facts (inlined — no spec read needed)
- Migrations are **upcasters**: `fn upcast(vN) -> vN+1`, applied on read.
- Rewriting an append-only log is a contradiction. History stays as written; the
reader adapts.
- Registry keyed by version. Reading a v1 record under a v3 binary runs
`v1→v2→v3` in sequence.
- Deprecated variants stay decodable forever — an upcaster maps them forward, it
does not delete them.
- The guarantee is the fixture test: a stored fixture of every historical version
still folds to the expected state.
## Steps
1. Keep the per-version wire types as real types — `WorkEventV1`, `WorkEventV2`,
… — with the current one aliased to `WorkEvent`.
2. Define the upcaster shape: `fn(vN) -> vN+1`, either as function pointers or a
`trait Upcast { type From; type To; fn upcast(from: Self::From) -> Self::To; }`.
3. Build the registry keyed by `SchemaVersion`. Registration is explicit; a
missing link is an error at registry construction, not at read time.
4. `read_record(bytes)`: decode `schema`, decode the matching `vN` body, then
apply each upcaster in order up to `CURRENT_SCHEMA`.
5. Return typed errors — `UnknownSchemaVersion`, `MissingUpcaster { from, to }`.
A corrupt or future-version record must not panic the fold.
6. Build the synthetic v1→v2 migration as the test vehicle: add a field or split
a variant in `WorkEventV2`, write the upcaster, commit fixtures for both.
## Acceptance
- Synthetic v1 → v2 migration with a fixture per version.
- Folding a v1 log through the upcaster yields the **same state** as a
natively-v2 log. Assert on the folded state, not on intermediate events.
## Verify
**Harness:** committed v1 and v2 fixtures, plus T0.8's fold.
**Integration test**`tests/it_upcast_equivalence.rs`:
1. Fold the committed **v1** log through the upcaster chain → `state_a`.
2. Fold the committed **native v2** log directly → `state_b`.
3. Assert `serialize(state_a) == serialize(state_b)` byte for byte.
4. Instrument the registry with a counter; assert **at least one** upcaster
actually ran on the v1 path.
5. Feed a record stamped with an unknown future version; assert
`UnknownSchemaVersion` is returned and **no panic** escapes.
**Command:** `cargo test -p log upcast`
**False pass:**
- `CURRENT_SCHEMA` still equal to v1, so the chain is empty and both folds
trivially agree. Step 4 is the guard.
- Comparing folded states with a derived `PartialEq` over `HashMap`, which
ignores ordering. Compare serialized bytes.
- The v2 change being cosmetic (a renamed local, an added comment) so no upcaster
logic is exercised. The v2 fixture must contain a record whose shape actually
differs.
## Traps
- Lossy upcasters that default a new field to a value the fold treats as
meaningful. If v1 cannot supply the field, the fold handles `Option` — it does
not receive a fabricated value.
- A registry populated by iteration order rather than an explicit chain. The gap
only shows at v3.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §8.7 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
@@ -0,0 +1,143 @@
# T0.5 — EventLog port + redb implementation
| Field | Value |
|---|---|
| Phase | P0 — Foundations |
| Size | L — over 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | T0.6, T1.x |
## Goal
The full `EventLog` port and its embedded `redb` implementation. Keys
`(TenantId, RunId, BranchId, Lsn)`. Async signatures even though the local
implementation is synchronous underneath.
## Facts (inlined — no spec read needed)
```rust
#[async_trait]
pub trait EventLog: Send + Sync {
/// Append records, apply derived state, advance consumer position, enqueue
/// the outbox — all four or none. Returns the assigned LSNs.
async fn commit(&self, batch: CommitBatch) -> Result<Vec<Lsn>>;
async fn read(&self, key: BranchKey, from: Lsn, limit: usize) -> Result<Vec<LogRecord>>;
async fn put_checkpoint(&self, key: BranchKey, upto: Lsn, state: &[u8]) -> Result<()>;
/// Newest checkpoint at or below `upto`. `None` means fold from LSN 0.
async fn latest_checkpoint(&self, key: BranchKey, upto: Lsn)
-> Result<Option<Checkpoint>>;
/// Committed-but-unshipped export intents, in `(BranchKey, Lsn)` order.
/// Read by the relay, never from the execution path.
async fn drain_outbox(&self, tenant: TenantId, limit: usize) -> Result<Vec<OutboxEntry>>;
async fn ack_outbox(&self, shipped: &[(BranchKey, Lsn)]) -> Result<()>;
}
pub struct CommitBatch {
/// Carries the tenant. Never a separate parameter beside a key that already
/// holds one — two sources for one fact is one too many.
pub key: BranchKey,
pub records: Vec<WorkEvent>, // LSNs assigned by the implementation
pub state: Vec<StateDelta>,
pub position: Option<ConsumerPosition>,
pub outbox: Vec<ExportIntent>,
}
```
- **`commit` is one method, not four, because atomicity is the contract.** A port
exposing `append` alone puts the other three writes outside the transaction —
the durability guarantee gone, and gone invisibly, since each write
individually succeeds.
- Everything is `async`. An async signature over a local call costs a negligible
poll; a sync signature over a network call is impossible, and the Postgres
backend (T7.1) implements this same trait. The prior revision declared a
synchronous store and documented in the same file that a networked
implementation could not honour the signature — a port finished while already
known to be unimplementable.
- `redb` specifics: pure Rust, ACID, MVCC, copy-on-write shadow paging, so a torn
write cannot corrupt the file — it simply does not take effect. The durability
enum is `#[non_exhaustive]`, so set `Durability::Immediate` **explicitly**
rather than relying on the default. (Avoid `sled` — years at 0.34 beta with
known space amplification.)
- LSNs are per branch, monotonic, gap-free. Not a global counter: a global one
serializes every run through a single atomic, and the ordering contract only
promises per-run total order.
- `latest_checkpoint` and `drain_outbox`/`ack_outbox` exist from the start. A
checkpoint that can be written and not read is an optimization nobody can use;
writes without their matching reads are how T2.5 and T7.4 discover the port is
short.
## Steps
1. Define the port trait, `CommitBatch`, `Checkpoint`, `OutboxEntry`,
`ExportIntent`, `StateDelta`, `ConsumerPosition` exactly as above.
2. Open the four `redb` tables: `EVENT_LOG`, `RUN_STATE`, `CONSUMER_POSITION`,
`OUTBOX`. Every key is `BranchKey`-derived or `Scoped<_>` (T0.1).
3. Implement LSN allocation **inside** the write transaction: read the current
max LSN for the `BranchKey`, assign `max+1..` across the batch. Being inside
the transaction is what makes it gap-free under concurrency.
4. Implement `commit` as a single `begin_write` → four inserts → `commit()`, with
`Durability::Immediate` set explicitly.
5. Implement `read` as a range scan bounded by `(key, from)..`, honouring `limit`.
6. Implement the checkpoint pair and the outbox pair. `drain_outbox` returns in
`(BranchKey, Lsn)` order — that ordering is the only one the relay is promised.
7. Write the conformance suite as a **generic function over `impl EventLog`**, so
T7.1's Postgres backend runs the identical tests. This is where the
abstraction becomes real or does not.
## Acceptance
- Append 10k records across 3 branches of 2 runs in 2 tenants; read back in order
per branch; assert no cross-tenant visibility.
- Port audit: compile stub call sites for T2.5 (checkpoints), T7.4 (outbox relay)
and T8.4 (reduction) against the trait. Every method they need already exists.
## Verify
**Harness:** the conformance suite written as
`fn conformance<L: EventLog>(make: impl Fn() -> L)`. It is the deliverable T7.1
reuses unmodified — write it as a library function, not as `#[test]` bodies.
**Integration test**`tests/it_eventlog_conformance.rs`:
1. Append 10k records across 3 branches × 2 runs × 2 tenants.
2. Read back per branch from LSN 0; assert contiguous `1..n` with no gaps and no
duplicates.
3. **Concurrency:** spawn 16 tasks committing to the *same* `BranchKey`
simultaneously; assert the union of returned LSNs is exactly `1..=total` with
no repeats. Gap-freedom only breaks under contention.
4. **Isolation:** scan the raw table with no tenant predicate and assert every
key carries the expected tenant — do not rely on the filtered read path.
5. Round-trip a checkpoint: `put_checkpoint`, then `latest_checkpoint` at an LSN
above and below it.
6. Outbox: commit with entries, `drain_outbox`, assert `(BranchKey, Lsn)`
ascending order, `ack_outbox`, assert a second drain returns empty.
7. **Port audit:** a `tests/port_audit.rs` with stub call sites for T2.5, T7.4
and T8.4 that must compile against the trait.
**Command:** `cargo test -p storage --test it_eventlog_conformance --test port_audit`
**False pass:**
- Single-threaded LSN allocation test. It passes against a
read-then-write-outside-the-transaction implementation, which is the actual bug.
- Cross-tenant isolation checked through a query that already filters by tenant —
it passes against a completely unscoped table. Step 4 is the real check.
- A conformance suite written inline as `#[test]`s, which then cannot be reused
for Postgres and quietly becomes redb-only.
## Traps
- Adding a convenience `append()` that skips the other three tables. It will be
on the hot path within a week.
- Passing `tenant` as a second parameter beside `BranchKey`.
- Letting `Durability` default. The enum is `#[non_exhaustive]`; the default can
change underneath you.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §7, §8.3 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+104
View File
@@ -0,0 +1,104 @@
# T0.6 — Atomic commit protocol
| Field | Value |
|---|---|
| Phase | P0 — Foundations |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | T2.1 |
## Goal
Four table writes in one transaction behind `EventLog::commit`. All four land or
none do.
## Facts (inlined — no spec read needed)
```rust
let txn = db.begin_write()?;
{
let mut log = txn.open_table(EVENT_LOG)?;
let mut state = txn.open_table(RUN_STATE)?;
let mut position = txn.open_table(CONSUMER_POSITION)?;
let mut outbox = txn.open_table(OUTBOX)?;
log.insert((branch_key, lsn), &record)?; // append: the durable fact
state.insert((branch_key, attempt_no), &attempt)?; // apply: the derived view
position.insert(Scoped::new(tenant, stream), pos)?; // advance: where to resume
outbox.insert((branch_key, lsn), &intent)?; // relay separately
}
txn.commit()?; // all four, or none
```
- **Every key carries `BranchKey` or `Scoped<_>`.** The outbox is the one that
invites the mistake: an outbox keyed on `Lsn` alone reads naturally and is
wrong, because LSNs are per branch — a bare LSN collides across every branch of
every run of every tenant. The same key shape also gives the relay its only
promised ordering: per `BranchKey`, ascending `Lsn`.
- **Commit per event, not per run.** A projection that accumulates in memory and
writes at run end loses the whole run on a crash. Episodes are small and
append-mostly; one fsync per transition is cheap next to model latency.
- The engine supplies crash-atomicity (shadow paging in `redb`, transactions in
Postgres). The log supplies history. Because the engine's transaction is
atomic, append and apply happen together: no torn records, no redo/undo pass,
no checkpoint-consistency problem.
## Steps
1. Route every write through `EventLog::commit`. There is no second write path —
grep for direct `open_table(...).insert` outside the implementation and delete
what you find.
2. Confirm each of the four keys: `(BranchKey, Lsn)` for log and outbox,
`(BranchKey, AttemptNo)` for state, `Scoped<StreamId>` for position.
3. Build the crash harness: a test-only hook that panics after write *i* of the
four, for `i in 0..4`. Reopen the database and assert all-or-nothing.
4. Add the collision test: two runs in two tenants both writing LSN 1, both
outbox entries present and distinct after commit.
5. Measure one commit's fsync cost and record it in the test output, so the
per-event decision stays visible rather than being re-litigated later.
## Acceptance
- Harness panics between each pair of table writes; on reopen either all four
landed or none did.
- Two runs in two tenants writing the same LSN both keep their outbox entries.
## Verify
**Harness:** a test-only fault hook inside the commit path, `panic_after_write(i)`
for `i in 0..4`, compiled under a `test-hooks` feature.
**Integration test**`tests/it_commit_atomicity.rs`:
1. For each `i in 0..4`: run a commit that writes all four tables with the hook
armed at `i`.
2. Reopen the database in a **fresh process** (or at minimum a fresh `Database`
handle — reusing an open handle can mask a durability bug).
3. Count rows in all four tables. Assert the count vector is either
"all four present" or "none present". Any mixed vector fails.
4. Collision case: two runs in two tenants both commit LSN 1 with outbox
entries; assert both entries exist and are distinct after reopen.
5. Print the measured fsync duration for one commit, so the per-event cost stays
visible rather than being re-litigated from memory later.
**Command:** `cargo test -p storage --features test-hooks commit_atomicity`
**False pass:**
- The hook checked **before** `begin_write`, so no partial state is ever possible
and every `i` trivially passes.
- Asserting on the in-memory result rather than after reopen. The transaction
object will happily report success.
- Testing with `Durability::None` in the test config for speed. It passes and
proves nothing about the shipped path.
## Traps
- Outbox keyed on `Lsn` alone. It reads naturally and silently loses entries.
- Buffering state deltas in memory "for efficiency" and flushing at run end.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §8.1, §8.3 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
@@ -0,0 +1,97 @@
# T0.7 — `BlobStore` port + `redb` implementation
| Field | Value |
|---|---|
| Phase | P0 — Foundations |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Content-addressed blob storage, deduplicated **within** a tenant and never
across. `delete` ships now, not later.
## Facts (inlined — no spec read needed)
```rust
#[async_trait]
pub trait BlobStore: Send + Sync {
async fn put(&self, tenant: TenantId, content: Bytes) -> Result<BlobRef>;
async fn get(&self, tenant: TenantId, r: &BlobRef) -> Result<Option<Bytes>>;
/// Reduction and tenant deletion both require this. A store that cannot
/// delete cannot honour either, and both are obligations.
async fn delete(&self, tenant: TenantId, r: &BlobRef) -> Result<()>;
}
```
- **Blobs are namespaced per tenant even though they are content-addressed.**
Global dedup of prompt and output blobs is tempting — identical system prompts
across tenants are common — and it is a leak: a shared blob makes one tenant's
storage accounting depend on another's, and a hash becomes an oracle for "does
anyone else have this content".
- `get` returning `None` is a normal outcome, not an error. After reduction
(T8.4) the original body is deleted and the log carries a
`Reduced { original, summary }` event explaining what stands in its place.
- Content addressing means the ref is a hash of the body. A body must never be
rewritten under an existing ref — that makes the ref a lie.
## Steps
1. Define `BlobRef` as a Blake3 hash newtype. Define the trait as above.
2. `redb` table keyed on `Scoped<BlobRef>` — tenant in the key is what enforces
per-tenant namespacing structurally rather than by convention.
3. `put`: hash the content, insert if absent, return the ref. Insert-if-absent is
the dedup, and it is scoped by the key, so no cross-tenant path exists.
4. `get`: point lookup, `Ok(None)` on miss.
5. `delete`: remove the key. Deleting a ref one tenant holds must not touch
another tenant's identical content — which follows from the key shape, and the
test below is what proves it.
6. Write the conformance suite generic over `impl BlobStore` so T7.2's
object-store backend reuses it unchanged.
## Acceptance
- Identical content put under two tenants produces two independent blobs.
- Deleting tenant A's blob leaves tenant B's readable.
## Verify
**Harness:** conformance suite generic over `impl BlobStore`, reused verbatim by
T7.2's object store.
**Integration test**`tests/it_blobstore_conformance.rs`:
1. `put` identical bytes under tenant A and tenant B. Assert the returned
`BlobRef`s are equal (same content hash) **and** that a raw table scan shows
**two** stored entries.
2. `delete` tenant A's ref. Assert `get(A, ref)` is `Ok(None)` and
`get(B, ref)` still returns the bytes.
3. `put` the same bytes twice under one tenant; assert one stored entry (dedup
within the tenant) and a stable ref.
4. `get` a ref that was never written: `Ok(None)`, not an error.
5. `delete` a ref that does not exist: `Ok(())`, so reduction can run twice.
6. Re-hash every stored body and assert it matches its key.
**Command:** `cargo test -p storage --test it_blobstore_conformance`
**False pass:**
- Step 1 asserting only that both `get`s return the right bytes. A single shared
blob passes that. The **raw scan showing two entries** is the isolation proof.
- Step 2 passing because `delete` is a no-op stub. Assert the `None` on A
explicitly, not just B's survival.
## Traps
- A global content-addressed table with tenant tracked in a side index. It
dedups across tenants by construction, which is the leak.
- Deferring `delete` because nothing calls it yet. Reduction and tenant deletion
both need it, and retrofitting a delete path into a store designed without one
is a rewrite.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §3, §7, §8.6 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+92
View File
@@ -0,0 +1,92 @@
# T0.8 — Fold and re-derive
| Field | Value |
|---|---|
| Phase | P0 — Foundations |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | P1 gate |
## Goal
The projection from log to materialized state, as a pure function. This task
establishes the property every later phase re-runs.
## Facts (inlined — no spec read needed)
- **Nothing derived is authoritative.** Materialized state is a cache of the log.
If it cannot be dropped and rebuilt byte-identically, it has hidden inputs and
that is a bug.
- The fold is pure: `fold(state, record) -> state`. No clock reads, no random,
no filesystem, no map iteration order leaking into the output.
- Reduction (T8.4) is designed around this property: it appends a
`Reduced { original, summary }` event rather than editing the log or rewriting
a blob, precisely so the fold still reaches the same state from LSN 0.
- Restart folds forward from the newest checkpoint (T2.5); `None` means fold from
LSN 0. Checkpoints are an optimization only — deleting every checkpoint costs
startup time and nothing else.
## Steps
1. Define the materialized state type: runs, branches, attempts, refs, intent
status. Whatever the query surface (T1.7) needs and nothing more.
2. Write `fn fold(state: &mut State, record: &LogRecord)` as a pure function.
Take no `&self`, no store handle, no `Instant::now()`.
3. Use ordered collections (`BTreeMap`) anywhere the state is serialized, so
byte-identity does not depend on hash seed.
4. Write `rebuild(log, key) -> State`: read from LSN 0 (or from the newest
checkpoint once T2.5 lands), fold every record in order.
5. Write the property test as a **reusable helper**, not a one-off: drop the
state tables, `rebuild`, assert the serialized bytes match. Every subsequent
phase calls this helper on its own fixtures.
6. Wire that helper into P1P8 fixture suites as they land.
## Acceptance
- Drop the state tables, re-fold from LSN 0, assert byte-identical state.
- The helper is exported and used by at least one later phase's fixture set.
## Verify
**Harness:** export `assert_refold_identical(&log, key)` as a **public test
helper from this crate**. Every later phase calls it on its own fixtures; that
reuse is the deliverable, not the one-off test.
**Integration test**`tests/it_refold.rs`:
1. Build a log containing a retry, a rewind (once T2.3 lands) and a reduction
(once T8.4 lands) — the three cases with non-trivial fold logic.
2. Snapshot `serialize(state)`.
3. Drop the state tables entirely.
4. `rebuild` from LSN 0.
5. Assert **byte equality** of the serialized state, not `PartialEq`.
6. Repeat the fold twice in the same process and assert both runs produce
identical bytes — catches hash-seed and iteration-order leakage.
7. Purity check: run the fold with the system clock moved forward and with a
different `TZ`; assert the bytes are unchanged.
**Command:** `cargo test -p projection refold`
**False pass:**
- Comparing states with derived `PartialEq`. `HashMap` compares order-insensitively,
so byte-identity can be broken while the test is green — and byte-identity is
the actual property.
- Testing on a log with one linear run and no branches, retries or reductions.
Every implementation passes that.
- Running the fold once and comparing to the live state that was built by the
same code path incrementally — assert against a committed expected snapshot for
at least one fixture.
## Traps
- `HashMap` in serialized state — iteration order varies and byte-identity fails
intermittently, which reads as flakiness rather than as the bug it is.
- Any hidden input: a timestamp taken at fold time, a config value read from the
environment, a default filled in from the current schema version.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §1, §8.6 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+109
View File
@@ -0,0 +1,109 @@
# T0.9 — P0 composition gate
| Field | Value |
|---|---|
| Phase | P0 — Foundations |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | gate |
| Spec | inlined below |
| Blocks | P1 — nothing in P1 starts until this is green |
## Goal
Prove T0.1T0.8 compose. Each was verified alone; this task verifies they work
**together and interchangeably** — that the ports are real seams, not shapes.
**Phase gate criterion:** types compile, log round-trips, schema fixture test
green, drop-and-re-fold byte-identical.
## Facts (inlined — no spec read needed)
- A port is only real if a **second implementation** can be dropped in without
editing the suite. P0 ships one implementation of each port; the seam is
therefore unproven until a second one exists. T7.1/T7.2 supply the production
second implementation much later — too late to learn the port is short.
- The four properties that must hold **jointly**, not individually:
1. every key is tenant-scoped (T0.1) — including keys written by T0.6's commit;
2. every record carries `SchemaVersion` and decodes through the upcaster chain
(T0.3, T0.4);
3. all four table writes are atomic (T0.6);
4. dropping derived state and re-folding from LSN 0 is byte-identical (T0.8).
- The failure this gate exists to catch: each task passing its own test while the
**composition** violates a property none of them owns. A commit path that
writes a correctly-scoped log key and an unscoped outbox key passes T0.1's
tests and T0.6's tests, and leaks across tenants.
## Steps
1. Build a **second `EventLog` implementation**: a deliberately naive in-memory
one, correct but sharing no code with the `redb` backend.
2. Build a second `BlobStore` the same way.
3. Run T0.5's and T0.7's conformance suites against **both** implementations,
unmodified. Any edit needed is a finding about the port, not the test.
4. Write the cross-invariant matrix (below) as a single test binary that
exercises every P0 task in one path.
5. Wire T0.8's `assert_refold_identical` into that path so composition and
re-derivability are asserted together.
6. Make this binary a **required CI job** and the P1 entry condition.
## Acceptance
- Both `EventLog` implementations and both `BlobStore` implementations pass the
conformance suites **unmodified**.
- The cross-invariant matrix is green.
- P0's stated gate holds: types compile, log round-trips, schema fixture test
green, re-fold byte-identical.
## Verify
**Harness:** the two extra port implementations from steps 12 — they exist only
for this gate and stay in the tree as the anti-drift device.
**Integration test**`tests/it_p0_composition.rs`:
1. **Port interchange:** parameterize the whole test over
`[redb, in_memory] × [redb_blobs, in_memory_blobs]`. All four combinations
must pass. A combination that fails means a caller depends on an
implementation detail rather than the trait.
2. **Scope sweep:** after a full workload across 2 tenants, scan **every** raw
table — log, state, position, outbox, blobs — and assert every key carries the
expected tenant. Not through the read path; through a raw scan.
3. **Atomicity under composition:** arm T0.6's fault hook while a commit carries
records, state deltas, a position advance and an outbox entry
**simultaneously**. Assert all-or-nothing across all four.
4. **Schema + fold:** write records at v1, upgrade to v2, then drop state and
re-fold. Assert byte-identical state. This is T0.4 and T0.8 composed, and it
is the pair most likely to break each other.
5. **LSN under contention across branches:** 16 concurrent writers across 3
branches of 2 runs; assert per-branch `1..=n` gap-free and no cross-branch
interference.
6. **Blob/log linkage:** put a blob, reference it from a committed record, drop
state, re-fold, resolve the ref. Assert the bytes.
7. Re-run every P0 task's own test suite in the same job, so this gate catches a
regression rather than only a composition bug.
**Command:** `cargo test -p foundations --test it_p0_composition`
**False pass:**
- Running the matrix against one implementation "because the second is only a
test double". The second implementation **is** the test — it is the only thing
that proves the port is a seam before T7.1 finds out the expensive way.
- Step 2 through the read path, which filters by tenant and passes against a
completely unscoped table.
- Step 4 with a v2 change that touches no recorded variant, so no upcaster runs
and the fold is trivially identical.
- Treating this gate as optional because the eight underlying suites are green.
Every property here is one no single task owns.
## Traps
- Deleting the in-memory implementations once T7.1 lands. They are the fast CI
path and the drift detector; the Postgres backend is neither.
- Letting the conformance suite grow implementation-specific branches
(`if backend == redb`). At that point it no longer tests the port.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §3, §7, §8 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+102
View File
@@ -0,0 +1,102 @@
# T1.1 — `Ctx` and `RunScope`
| Field | Value |
|---|---|
| Phase | P1 — Walking skeleton |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | T1.3, T7.x |
## Goal
Explicit context threaded through every call, and a run-scoped task tree that
cannot be escaped through the public API.
## Facts (inlined — no spec read needed)
- Runtime is **tokio**. Chosen for ecosystem access (`sqlx`, `rdkafka`,
`aws-sdk`, `tonic`, `axum`, `reqwest`, object-store clients) — for a
distributed framework those are the distribution layer, not optional deps.
- `RunScope` wraps `TaskTracker` + `CancellationToken` from `tokio-util`, one
tracker per run. It exists because tokio has no structural task tree: without
the guard, a detached spawn outlives its run.
- `Ctx` is an **explicit struct** passed to every call. Never `task_local!` for
anything causal — that is the `AsyncLocalStorage` mistake in different clothes.
Trace context propagates through `Ctx` too.
- Cancellation is **cooperative**: a `select!`-dropped future stops at its next
await and not before. Kernel code must never hold a lock or a half-applied
state across an await that can be cancelled.
- A dropped call resolves `Cancelled` if no intent reached `Dispatched`, and
`Indeterminate` if one did. **The intent record decides, not the drop.**
Collapsing the two is the easy implementation and it puts a permanent noisy
floor under the `Indeterminate` alert, which is meant to sit near zero.
## Steps
1. Define `Ctx { tenant, run, branch, cancel: CancellationToken, deadline, trace,
log: Arc<dyn EventLog>, blobs: Arc<dyn BlobStore> }`. Pass by `&Ctx`.
2. Define `RunScope` owning a `TaskTracker` and a `CancellationToken`. Expose
`spawn` only as a method on `RunScope`; keep the inner tracker private so no
caller reaches `tokio::spawn` through the public API.
3. `RunScope::shutdown()` / `Drop`: cancel the token, `tracker.close()`, then
await `tracker.wait()`. Returning before every child is complete or aborted is
the bug this type exists to prevent.
4. In kernel code, `select!` the cancellation token at every await. Audit for
locks or partially-applied state held across those awaits.
5. Give user tool calls a hard timeout in addition to the token — a
non-cooperative call must still be bounded.
6. On cancellation, read the attempt's latest intent state to choose
`Cancelled` vs `Indeterminate`. Do not decide at the drop site.
## Acceptance
- Cancel a run mid-step; assert every spawned task has completed or aborted
before `RunScope::drop` returns.
- The cancelled attempt lands in `Cancelled`, **not** `Indeterminate` — asserted
directly, since collapsing the two is the easy implementation and silently
ruins the alert.
## Verify
**Harness:** `tokio::time::pause` for deadlines; a scoped task that records its
own completion into a shared `Arc<Mutex<Vec<_>>>` so "aborted" and "completed"
are distinguishable.
**Integration test** — `tests/it_runscope_cancel.rs`:
1. Start a run whose step spawns three children through `RunScope::spawn`, each
parked on a long await.
2. Cancel mid-step.
3. Assert `RunScope::drop` (or `shutdown().await`) returns **only after** all
three have recorded completion-or-abort. Assert the recorded set has size 3 —
a scope that returns early leaves it at 0 or 1.
4. Assert the attempt landed in `Cancelled`, **not** `Indeterminate`, with no
intent dispatched.
5. Second run: dispatch an intent, then cancel. Assert `Indeterminate`.
6. Grep test (or a lint): assert no `tokio::spawn` outside `RunScope`, and no
`task_local!` anywhere in kernel crates.
**Command:** `cargo test -p runtime runscope`
**False pass:**
- Step 3 asserting only that `drop` returned. It always returns. The recorded
child set is the evidence.
- Steps 4 and 5 written against the same fixture with `intent_dispatched`
hardcoded — they must run through the real intent record, or the distinction is
asserted against itself.
- A child that finishes on its own before cancellation lands, making the test
green regardless of the tracker. Park children on a token that only the cancel
releases.
## Traps
- A public `spawn` helper "for convenience" that bypasses the tracker.
- Storing tenant or run id in a task-local. It works until a spawn boundary, then
attributes work to the wrong run.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §5.1, §6, §15 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+77
View File
@@ -0,0 +1,77 @@
# T1.2 — Stub model provider
| Field | Value |
|---|---|
| Phase | P1 — Walking skeleton |
| Size | S — under 1 day |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
A deterministic scripted model provider so the walking skeleton runs end to end
with no network and no spend.
## Facts (inlined — no spec read needed)
- Nothing is built before the record is trustworthy: no dashboards and no
learning loop until one full run works end to end **against a stub model**.
- The stub is not a test fixture that gets deleted — it is the fixed point every
later determinism assertion is measured against, including T0.8's re-fold
property and T5.x's grading tests.
- Failure injection here is what T1.4 (retry), T2.1 (crash windows) and T2.6
(crash matrix) drive their scenarios with.
## Steps
1. Define the model port the executor calls: request in, response plus `Usage`
out, `async`, cancellation-aware.
2. Implement the stub over a script: an ordered list of responses, optionally
keyed by step id so a workflow with branches stays readable.
3. Add configurable latency per response, driven by `tokio::time::sleep` so
`tokio::time::pause` in tests makes it instant.
4. Add failure injection: error responses, timeouts, and a hang that outlives the
deadline. Each selectable per script entry.
5. Make every field of the emitted response deterministic — no timestamps from
the wall clock, no generated ids that are not seeded.
6. Test: run the same script twice, serialize both episodes, assert equal bytes.
## Acceptance
- The same script yields byte-identical episodes across runs.
## Verify
**Harness:** the stub itself plus T0.8's `assert_refold_identical`.
**Integration test**`tests/it_stub_determinism.rs`:
1. Run the same script twice, in two fresh databases, in the same process.
2. Serialize both episodes; assert **byte equality**.
3. Run a third time in a **separate process** and compare against a committed
expected serialization — catches anything seeded from process state.
4. With `tokio::time::pause`, assert a script declaring 30s latency completes in
near-zero wall time. If it does not, the stub is sleeping off the tokio clock.
5. Failure injection: assert an error entry produces a failed attempt, a timeout
entry produces `TimedOut`, and a hang entry is cut off by the deadline.
**Command:** `cargo test -p testkit stub`
**False pass:**
- Steps 12 in one process with a lazily-initialized global seed: both runs share
it and agree, while a fresh process differs. Step 3 is the guard.
- Comparing episodes with a `PartialEq` that skips timestamps. That hides exactly
the nondeterminism being hunted — compare the full serialized bytes.
## Traps
- A wall-clock timestamp or a random id in the stub response. It defeats every
byte-identity assertion downstream and looks like a fold bug.
- Sleeping with `std::thread::sleep`, which `tokio::time::pause` cannot skip.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §16 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+129
View File
@@ -0,0 +1,129 @@
# T1.3 — Run executor
| Field | Value |
|---|---|
| Phase | P1 — Walking skeleton |
| Size | L — over 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Drive a hardcoded three-step workflow through the full run lifecycle, emitting
every kernel transition to the log. T3.5 later replaces the hardcoded workflow
with the IR interpreter.
## Facts (inlined — no spec read needed)
Run lifecycle — the states and the legal moves:
```
spawn
v
Scheduled ─────────────────┐
│ admitted │
v │
Running ⇄ Suspended ──────┤
│ all steps terminal │ cancel
v │
Verifying ─────────────────┤ ◄── rests here while verifier
│ │ futures are outstanding
v │
Verified{pass|fail} │
│ │
v v
Grading ──────────────► Cancelled ●
│ ◄── may wait for a tournament group to fill
├──► Graded ● ──┐
│ ├──► Archived ●
└──► Ungraded ● ──┘
```
- **Cancel is reachable from every non-terminal state**, not only `Scheduled`. A
run cancelled mid-step is the ordinary case — it is what a user clicking stop
does. Cancelling during `Verifying` or `Grading` is rarer and still legal.
- **`Verifying` and `Grading` are states the run rests in**, not synchronous
branches. Resolving pass/fail inside `verify()` collapsed this in the prior
implementation and blocked three separate features at once: async verifiers,
mid-run UI, and the snapshot barrier (T4.2).
- **`Ungraded` is terminal and sits beside `Graded`, not below it.** Grading can
legitimately end with no score. Without a terminal state saying so, those runs
rest in `Grading` forever and become permanently irreducible (T4.4).
- `Suspended` releases the worker: a run awaiting human approval or a webhook
must not hold an executor slot across a human decision.
- Concurrency shape: unbounded across runs; **serial** steps within a run;
strictly serial attempts within a step; fan-out only for declared `Parallel`
branches and for verifiers of one attempt. The serial spine is
`(TenantId, RunId)`.
## Steps
1. Define the run-state enum and its transition function, exhaustive like T0.2's.
Include `Cancelled` as reachable from every non-terminal state.
2. Build the executor loop: take the hardcoded three-step workflow, run steps
serially, each step producing one or more attempts (T1.4).
3. Emit a `WorkEvent` for **every** transition through `EventLog::commit` — run
level and attempt level. One commit per transition, not one per run.
4. Enter `Verifying` only when every step is terminal. Park there; do not resolve
verification inline.
5. Enter `Grading` as a resting state likewise; allow it to terminate as
`Graded` **or** `Ungraded`.
6. Run everything inside a `RunScope` (T1.1) so cancel propagates and no task
outlives the run.
7. Add `Suspended` with lease release wired as a no-op stub in embedded mode —
the state must exist now, since T7.3 depends on it.
## Acceptance
- One run completes end to end against the stub model.
- `Verifying` and `Grading` are observable as **distinct states in the log**, not
skipped or collapsed.
## Verify
**Harness:** embedded `redb`, stub model (T1.2), one throwaway verifier, one stub
grader. This is the first test that exercises T0.5, T0.6, T0.8, T1.1 and T1.2
together — treat it as the P1 integration point.
**Integration test**`tests/it_full_run.rs`:
1. Spawn one run of the three-step workflow; drive to completion.
2. Read the log back and assert the **run-state sequence** contains
`Scheduled, Running, Verifying, Grading` and a terminal state, in that order.
Assert `Verifying` and `Grading` each appear as their own record — not
inferred, not skipped.
3. Assert every step produced attempt records and that no state appears out of
order against T0.2's table.
4. Call `assert_refold_identical` on the finished run.
5. Cancel matrix: start a run, cancel it from each of `Scheduled`, `Running`,
`Verifying`, `Grading` in turn; assert each reaches `Cancelled`.
6. Ordering: launch 20 runs concurrently, assert each run's own records are
totally ordered and that no two steps of one run overlap in time.
**Command:** `cargo test -p executor --test it_full_run`
**False pass:**
- Asserting `Verifying` "happened" by checking a boolean on the run record. That
passes against a synchronous `verify()` call, which is the collapse this task
exists to prevent. The **log record** is the assertion.
- A cancel test that only covers `Scheduled`. That is the one case a broken
implementation gets right.
- Step 6 with a single run, where serial execution is indistinguishable from
accidental parallelism.
## Traps
- Making cancel legal only from `Scheduled`. Nobody would ship that lifecycle.
- Treating `Verifying` as a function call. It removes the seam T4.2 needs.
- Interleaving steps within a run "since they look independent". The serial spine
is `(TenantId, RunId)`; parallelism lives between runs and inside declared
fan-out only.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §5.2, §5.3 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+84
View File
@@ -0,0 +1,84 @@
# T1.4 — Attempt lifecycle and retry
| Field | Value |
|---|---|
| Phase | P1 — Walking skeleton |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Retry creates attempt N+1 as a new record. Attempt N is never mutated.
## Facts (inlined — no spec read needed)
- Each step execution is one or more kernel attempts. **Retry creates attempt
N+1 and never mutates attempt N.** Two payoffs: "did the retry do better, and
why" becomes answerable, and log replay is idempotent for free.
- Attempts within a step are **strictly serial** — a retry needs the prior
failure to exist first.
- The immutability is also the foundation of the cheapest grading signal there
is: the attempts of one step are a comparison group on identical context, free
and already on disk (T5.10).
- A failure and its successful retry are **not** a judge comparison. That pair is
consumed structurally — what differed between attempt N and N+1 is attributed
to the `StepId`. Only same-outcome attempts go to a judge.
## Steps
1. Key attempt state on `(BranchKey, AttemptNo)`. `AttemptNo` starts at 1 and
increments; no reuse within a branch.
2. On retry, allocate `AttemptNo + 1` and write a fresh record. No update path to
an existing attempt row exists — do not write one.
3. Apply the step's `RetryPolicy` (count and backoff) between attempts, awaiting
the cancellation token so a cancel during backoff is honoured.
4. Record on each attempt what differs from its predecessor: context partition
(T1.5), tool selection, prompt ref (T1.6). That delta is what T5.10 attributes.
5. Test: script the stub to fail twice then succeed. Snapshot attempts 1 and 2
before attempt 3 runs, snapshot again after, assert byte equality.
## Acceptance
- A step failing twice then succeeding produces **three** attempt records.
- Attempts 1 and 2 are byte-identical before and after attempt 3.
## Verify
**Harness:** stub model scripted `fail, fail, succeed` for one step.
**Integration test**`tests/it_retry_immutability.rs`:
1. Run the step; pause after attempt 2 completes.
2. Snapshot the **serialized bytes** of attempt records 1 and 2.
3. Let attempt 3 run to success.
4. Re-read attempts 1 and 2; assert byte equality with the snapshot.
5. Assert exactly three attempt records exist, numbered 1, 2, 3.
6. Assert the attempts ran **serially**: attempt N's terminal timestamp precedes
attempt N+1's start.
7. Assert the per-attempt delta fields (context partition, tool selection, prompt
ref) are present, so T5.10 has something to attribute.
8. Cancel during retry backoff; assert the run stops rather than sleeping out the
full delay.
**Command:** `cargo test -p executor retry`
**False pass:**
- Comparing attempt records through the query surface, which may reconstruct them
identically from the log even if the state table was mutated. Compare the
stored state rows **and** the log records.
- Asserting "three records exist" alone — an implementation that appends a new
row and *also* updates row 2 passes that. Step 4 is what catches it.
## Traps
- "Updating" the attempt row with the final outcome to keep the table small. It
destroys the retry evidence, and the learning loop exists to consume it.
- Retrying in parallel to save latency. Attempt N+1 needs N's failure.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §5.1, §5.3, §11.5, §11.8 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+83
View File
@@ -0,0 +1,83 @@
# T1.5 — Context partition capture
| Field | Value |
|---|---|
| Phase | P1 — Walking skeleton |
| Size | S — under 1 day |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Record, per model step, which context was packed, which was available but not
packed, and which was dropped — as identifiers.
## Facts (inlined — no spec read needed)
- Three buckets: **packed**, **available-not-packed**, **dropped**.
- They hold **identifiers**, not text. That keeps them small enough to inline in
the attempt view rather than going by blob reference, which is what lets a
verifier read them without paying for prompt text.
- This is the signal behind "retried three times because context was missing X" —
the thing a pass rate cannot see and a learning loop needs.
- It is **not derivable after the fact** from the conversation. If it is not
captured at pack time it is gone, which is why its absence is a test failure
rather than a gap.
## Steps
1. Define `ContextPartition { packed: Vec<ContextItemId>, available: Vec<ContextItemId>,
dropped: Vec<ContextItemId> }`. Ordered collections, so serialization is stable.
2. Capture at the point the prompt is assembled — the packer already knows all
three sets; recording them costs a clone of id vectors.
3. Attach the partition to the attempt record and emit it in the same
`EventLog::commit` as the rest of the attempt transition.
4. Surface it on `AttemptView.context: Option<ContextPartition>` for verifiers
(T4.2) and graders.
5. Test: assert every model-step attempt in a completed run carries a partition;
a missing one fails the test rather than being tolerated as `None`.
## Acceptance
- Partition present on **every** model step.
- Not derivable from the conversation, so its absence is a test failure.
## Verify
**Harness:** a workflow with a packer configured to drop known items, so the
expected partition is known in advance.
**Integration test** — `tests/it_context_partition.rs`:
1. Configure the packer with 10 candidate context items and a budget admitting 6.
2. Run a model step.
3. Assert the recorded partition has `packed.len() == 6`, `available` +
`dropped` covering the remaining 4, and that the three sets are **disjoint**
and their union is the full candidate set.
4. Assert `dropped` is non-empty — a capture that only ever records `packed` is
the common half-implementation and passes any "partition present" check.
5. Iterate every model-step attempt in a completed multi-step run; assert
`context.is_some()` for **each**. A `None` fails the test.
6. Assert the partition survives `assert_refold_identical`.
**Command:** `cargo test -p executor context_partition`
**False pass:**
- Asserting only `context.is_some()`. An empty partition is `Some`. Step 3's set
arithmetic is the real check.
- Testing on a workflow whose budget admits everything, so `dropped` is
legitimately empty and step 4 cannot fail.
## Traps
- Storing the packed context text here. It belongs in the prompt blob (T1.6);
inlining text blows broker payload limits and penalizes verifiers that need
none of it.
- Capturing only `packed`. The interesting signal is usually in `dropped`.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §10.1 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
@@ -0,0 +1,82 @@
# T1.6 — Prompt and output blob capture
| Field | Value |
|---|---|
| Phase | P1 — Walking skeleton |
| Size | S — under 1 day |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Write the prompt to the blob store when it is built, and the output when the
model responds. Record both as `BlobRef` on the attempt.
## Facts (inlined — no spec read needed)
- Two capture points: `prompt-built` and model response. **The prior
implementation had neither** — every downstream consumer worked from
identifiers alone and could not answer "what did the agent actually see".
- Prompts and outputs are large, so they go **by reference**; context partitions
are identifiers and go inline (T1.5). That split is what makes lazy blob access
(T4.3) possible.
- Blobs are content-addressed within a tenant, never across (T0.7).
- A `BlobRef` whose body later returns `None` is normal after reduction (T8.4) —
the log carries a `Reduced { original, summary }` event saying what replaced it.
## Steps
1. At prompt assembly, serialize the final prompt, `BlobStore::put`, keep the
returned `BlobRef`.
2. On model response, `put` the raw output, keep its `BlobRef`.
3. Attach both refs to the attempt record; commit them in the same
`EventLog::commit` as the attempt transition, so a crash cannot leave a
dangling ref in state with no log record.
4. Surface as `AttemptView.prompt: Option<BlobRef>` and
`AttemptView.output: Option<BlobRef>`.
5. Test across a process restart: complete a run, drop the process, reopen the
store, fetch the prompt by ref, assert the text.
## Acceptance
- Prompt text retrievable from the blob store by ref **after a process restart**.
## Verify
**Harness:** embedded store on a temp path that survives process exit; the test
runs the agent in a **child process**, then reopens the store in the parent.
**Integration test**`tests/it_blob_capture_restart.rs`:
1. Child process: run one model step to completion, print the store path, exit.
2. Parent: reopen the store, read the attempt, take `prompt` and `output` refs.
3. `BlobStore::get` both; assert the prompt bytes equal the **assembled** prompt
(with template variables substituted), not the template.
4. Re-hash both bodies; assert each matches its ref.
5. Crash case: arm a fault hook between the blob `put` and the commit; assert on
reopen there is no attempt record pointing at a body-less ref, and no orphan
body that no record points at.
**Command:** `cargo test -p executor blob_capture -- --test-threads=1`
**False pass:**
- Reading the blob back in the same process from a warm cache. The restart is the
point — an in-memory blob map passes everything else.
- Asserting the prompt is non-empty rather than comparing to the expected
assembled text. Capturing the template instead of the rendered prompt passes a
non-empty check.
## Traps
- Putting the blob outside the commit transaction and recording the ref inside
it, or the reverse. Either way a crash leaves a ref with no body or a body no
record points at.
- Capturing the prompt template instead of the assembled prompt. The template is
in the workflow definition; what the model saw is not.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §8.6, §10.1 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+103
View File
@@ -0,0 +1,103 @@
# T1.7 — Episode query surface
| Field | Value |
|---|---|
| Phase | P1 — Walking skeleton |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Read an episode — run, branches, attempts, refs — with identical results whether
served from materialized state or a cold re-fold.
## Facts (inlined — no spec read needed)
```rust
pub struct AttemptView {
pub step: StepId,
pub attempt: AttemptNo,
pub state: AttemptState,
pub workflow_version: WorkflowVersion,
pub context: Option<ContextPartition>, // identifiers, small, inline
pub prompt: Option<BlobRef>, // large, by reference
pub output: Option<BlobRef>,
pub tools: Option<ToolInfo>,
pub usage: Option<Usage>,
}
```
- **Include failed attempts.** "Retried three times because context was missing
X" is the learning signal; shipping only the winning attempt discards it.
- Queries default to the **live branch**; grading may read all branches. Only the
live branch is exported.
- Materialized state is a cache of the log. If the query answers differently from
a cold re-fold, the state has hidden inputs — that is T0.8's property applied
to the read path.
- Blob bodies are **not** included. The view carries refs; the caller fetches
what it needs (T4.3).
## Steps
1. Define `EpisodeView` — run metadata, branch list with fork points, attempts
per branch — and `AttemptView` exactly as above.
2. Implement the query against materialized state, defaulting to the live branch
with an explicit `all_branches` option for grading.
3. Implement the same query against a cold re-fold (T0.8's `rebuild`), used as
the fallback when state is absent and as the test oracle.
4. Include every attempt regardless of outcome. Sort by `(BranchId, AttemptNo)`
so ordering is stable.
5. Test: build a run with a retry and a rewind; serialize the view from state and
from a cold re-fold; assert equal.
## Acceptance
- Query returns the same structure whether served from materialized state or a
cold re-fold.
- Failed attempts are present in the returned view.
## Phase gate
P1 closes when a full run is recorded and re-derives byte-identically.
## Verify
**Harness:** one recorded run containing a retry **and** a rewind — the two cases
where the two read paths can diverge.
**Integration test**`tests/it_episode_query_equivalence.rs`:
1. Build the run: 3 steps, one step failing twice, then a rewind at step 2
producing a second branch.
2. Query the episode from **materialized state**`view_a`.
3. Drop the state tables; query again, served by cold re-fold → `view_b`.
4. Assert `serialize(view_a) == serialize(view_b)`.
5. Assert `view_a` contains **all** attempts including the two failures — count
them explicitly, do not just check non-empty.
6. Assert the default query returns only the **live** branch, and that the
explicit all-branches mode returns both.
7. Instrument `BlobStore::get`; assert the query itself makes **zero** calls.
**Command:** `cargo test -p query episode_equivalence`
**False pass:**
- Testing on a linear run with no retry and no rewind. Both paths trivially
agree, and the divergence lives exactly in the cases omitted.
- Step 5 as `assert!(!attempts.is_empty())`. A view returning only the winner
passes that.
- Step 4 with a `PartialEq` derived over unordered collections.
## Traps
- Filtering to the successful attempt "because that is what the UI shows". The
learning loop is the other consumer and it needs the failures.
- Eagerly resolving `BlobRef` into text inside the view. That is T4.3's
regression, introduced one layer earlier.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §8.5, §10.1 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+103
View File
@@ -0,0 +1,103 @@
# T1.8 — P1 composition gate
| Field | Value |
|---|---|
| Phase | P1 — Walking skeleton |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | gate |
| Spec | inlined below |
| Blocks | P2 |
## Goal
Prove T1.1T1.7 compose into one recorded run that re-derives byte-identically,
and that the pluggable parts of the skeleton are genuinely pluggable.
**Phase gate criterion:** one full run is recorded and re-derives
byte-identically.
## Facts (inlined — no spec read needed)
- The walking skeleton is **one full run of one workflow against a stub model, in
embedded mode, with no network — recorded, verified, and re-derivable from the
log.** Nothing after it is worth starting until it runs.
- Nothing is built before the record is trustworthy: no dashboards and no
learning loop until this passes.
- The composition properties no single P1 task owns:
- cancellation from **any** state leaves a legal attempt state and a complete
log (T1.1 + T1.3 + T0.2);
- a retry, a cancel and a normal completion all produce episodes that survive
drop-and-re-fold (T1.4 + T1.7 + T0.8);
- context partitions and blob refs are present on **every** model step of
**every** attempt, including failed ones (T1.5 + T1.6 + T1.4).
- The serial spine is `(TenantId, RunId)`. Parallelism lives between runs. A
composition test with one run cannot see a violation of this.
## Steps
1. Build the run matrix: `{clean, one retry, two retries, cancelled mid-step,
cancelled during Verifying}` × `{served from state, served from cold re-fold}`.
2. Assert every cell produces a consistent episode and a legal state sequence.
3. Swap the stub model script and the verifier implementation without touching
executor code — the skeleton must not know which is installed.
4. Run 50 runs concurrently across 2 tenants; assert per-run ordering and
cross-run independence.
5. Make this the required CI job gating P2.
## Acceptance
- Every matrix cell is green.
- A full run is recorded and re-derives **byte-identically**.
- Swapping the stub script or the verifier requires no executor change.
## Verify
**Harness:** the P0 in-memory ports (T0.9) for speed, plus the real `redb` path
for at least one cell — a composition that only works in memory is not a
composition.
**Integration test** — `tests/it_p1_composition.rs`:
1. **Matrix:** for each of the 10 cells, assert the episode from materialized
state and from cold re-fold are **byte-identical**, and that the run's state
sequence is legal under T0.2's table.
2. **Cancel sweep:** cancel from `Scheduled`, `Running`, `Verifying`, `Grading`.
For each, assert the attempt lands in `Cancelled` (no dispatched intent),
every spawned task terminated before `RunScope` returned, and the log is
complete up to the cancel.
3. **Capture completeness:** across all cells, assert **every** model-step
attempt — including failed ones — carries a context partition and prompt and
output refs. Iterate, do not sample.
4. **Immutability under composition:** in the two-retry cell, snapshot attempts 1
and 2, run to completion, assert byte equality. Then drop state, re-fold, and
assert the attempts are still identical.
5. **Isolation:** 50 concurrent runs across 2 tenants. Assert each run's records
are totally ordered, no two steps of one run overlap in time, and no record
carries the wrong tenant.
6. **Pluggability:** run the same matrix with a second stub script and a second
verifier. Assert zero changes to executor code — enforce by keeping the
executor crate's test-only surface empty.
7. **Regression:** re-run every P1 and P0 task suite in the same job.
**Command:** `cargo test -p skeleton --test it_p1_composition`
**False pass:**
- Testing only the clean cell. Retry and cancel are where the fold, the scope and
the capture paths interact, and each is individually green already.
- Step 3 sampling one attempt. A capture path that skips failed attempts passes a
sample and discards the entire learning signal.
- Step 5 with one run, where serial execution and accidental parallelism are
indistinguishable.
- Step 1 comparing episodes with `PartialEq` rather than serialized bytes.
## Traps
- Declaring the gate green with `Verifying`/`Grading` collapsed into function
calls. The states must appear in the log, or T4.2's barrier has nowhere to live.
- Building any dashboard or grading work before this is green.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §5.2, §5.3, §16, §20 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+123
View File
@@ -0,0 +1,123 @@
# T2.1 — Write-ahead intent
| Field | Value |
|---|---|
| Phase | P2 — Durability hard parts |
| Size | L — over 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | T2.2, T6.4 |
## Goal
Three intent records around every external effect, so a crash can be classified
rather than guessed at.
## Facts (inlined — no spec read needed)
```
1. append Intent{Pending} ──► commit + fsync
◄── window A: crash here, the call was never issued
2. append Intent{Dispatched} ──► commit + fsync
3. perform the call
◄── window B: crash here, the call may have landed
4. append outcome, Intent{Committed} ──► commit + fsync
```
- **Three records, not two.** Two cannot separate the windows: a crash before the
call and a crash after it both leave a lone `Pending` with no outcome, which
makes every interrupted effect maximally suspicious and pushes recoverable work
into `Indeterminate`. The second fsync buys the distinction, is paid only on
steps with external effects, and is small next to the call it guards.
- Appending after the fact records history but does not make a failed step
resumable — the dangerous window is *before* the record exists.
- Restart classification, from the last committed intent state:
| Last state | Meaning | Resolution |
|---|---|---|
| `Pending` | the call was never issued | retry freely, whatever the effect class |
| `Dispatched` | the call *may* have been issued | by effect class (T2.2) |
| `Committed` | outcome already recorded | nothing to do |
- Intents always commit with `Durability::Immediate`. **Batching them defeats
their only purpose.**
- Tool calls need this more than model calls. A model call is a metered read; a
tool call writes files, pushes commits, and touches the world.
- The intent record is also what decides `Cancelled` vs `Indeterminate` on a
cancelled attempt (T0.2, T1.1).
## Steps
1. Define `IntentState { Pending, Dispatched, Committed }` and an `IntentId`
scoped to `(BranchKey, AttemptNo, step)`. Include the idempotency key or
request id the effect class will need at recovery (T2.2).
2. Wrap every external effect in the four-step sequence above. Each append is its
own `EventLog::commit` with `Immediate` durability — three separate fsyncs, by
design.
3. Make the wrapper the **only** way to reach a tool. If a call site can issue an
effect without an intent, the protocol is decorative.
4. Build the crash harness: a test hook that aborts the process at a named point.
Two named points minimum — after `Pending` commit, and after `Dispatched`
commit but before outcome commit.
5. On restart, scan for the newest intent per `(BranchKey, AttemptNo)` and
classify per the table. Emit the classification as a log event so the decision
itself is recorded.
6. Assert the fsync count on the intent path in a test — a batching "optimization"
introduced later must fail loudly.
## Acceptance
- Crash harness kills the process in both windows: between `Pending` and
`Dispatched`, and between `Dispatched` and outcome-commit.
- Restart classifies the first as "never sent" and the second as "may have been
sent". A two-phase implementation cannot pass this — both crashes leave one
`Pending` with no outcome and are indistinguishable. That is the point of the
test.
## Verify
**Harness:** a real child-process kill (`kill -9`), not a panic. Named abort
points compiled under a `test-hooks` feature. A recording tool that appends to a
file outside the database, so "did the call actually happen" is observable
independently of what the log claims.
**Integration test**`tests/it_intent_windows.rs`:
1. **Window A:** arm the abort after the `Pending` commit, before `Dispatched`.
Kill. Reopen.
- Assert the last intent state is `Pending`.
- Assert restart classifies it **"never sent"**.
- Assert the side-channel file is **empty** — the call really did not happen.
2. **Window B:** arm the abort after the `Dispatched` commit, before the
outcome commit. Kill. Reopen.
- Assert the last intent state is `Dispatched`.
- Assert restart classifies it **"may have been sent"**.
3. Assert the two classifications **differ**. A two-phase implementation makes
them identical, which is the whole point of the test.
4. Count fsyncs on the intent path for one guarded effect; assert exactly 3
commits, so a later batching "optimization" fails loudly.
5. Bypass audit: assert no tool can be invoked except through the intent wrapper —
a compile-level guard if the tool handle is only constructible inside it.
**Command:** `cargo test -p durability --features test-hooks intent_windows -- --test-threads=1`
**False pass:**
- Simulating the crash with `panic!` and catching the unwind. Destructors run and
buffered writes flush — precisely what a real crash does not do.
- Asserting only that both windows "recover". Both recover under a two-phase
implementation too; they just recover **identically**. Step 3 is the test.
- Trusting the log to tell you whether the call happened. Step 1's side-channel
file is the independent witness.
## Traps
- Batching the two pre-call fsyncs into one commit. It compiles, it is faster,
and it deletes the distinction the task exists to create.
- Recording the intent after dispatch "since that is when we know the request
id". Derive the request id before dispatch instead.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §8.4 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+127
View File
@@ -0,0 +1,127 @@
# T2.2 — Effect-class recovery
| Field | Value |
|---|---|
| Phase | P2 — Durability hard parts |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | T6.5, T8.3 |
## Goal
Resolve a `Dispatched`-but-unresolved intent by the tool's declared effect class.
`Indeterminate` becomes a real terminal state with an operator path.
## Facts (inlined — no spec read needed)
```rust
pub struct ToolRegistration {
pub id: ToolId,
/// No Default. The author is the only party who knows this.
pub effects: EffectClass,
pub caps: CapabilitySet,
pub timeout: Duration,
}
/// Each class carries what its recovery path actually needs. A bare
/// discriminant would let a tool claim `Idempotent` while withholding the one
/// thing that makes the claim actionable.
pub enum EffectClass {
Idempotent { key: KeyDerivation },
Queryable { lookup: RequestIdLookup },
Unsafe,
}
```
Resolution for a `Dispatched` intent:
| Class | Recovery |
|---|---|
| `Idempotent` | retry with the same idempotency key; the provider deduplicates |
| `Queryable` | ask the provider whether the request id landed, then complete or retry |
| `Unsafe` | **never auto-retry.** Attempt becomes `Indeterminate`, operator notified |
- The third row is the honest one. Some effects cannot be made safe by any
protocol. The intent log's value is converting an invisible unknown into a
recorded one: `Indeterminate` is a fact a grader and an operator can both use;
a silently retried payment is not.
- A tool that cannot state whether it is safe to retry **does not register**.
- Declaration is not enforcement — that is T8.3's keyed capability, and the
sandbox (T6.5) is the third layer. This task builds layer one plus recovery.
- Until built-in tools are audited, default `Unsafe` and never auto-retry.
## Steps
1. Define `EffectClass` with payloads as above — no bare discriminants. A
registration declaring `Idempotent` without a `KeyDerivation` must not compile.
2. Extend `ToolRegistration` and reject registration of a tool with no declared
effect class. No `Default` impl.
3. Implement the recovery pass, driven by T2.1's restart scan: for each
`Dispatched` intent, dispatch on the registered class.
4. `Idempotent`: re-derive the key from the recorded arguments, re-issue, record
the outcome. Same key, so the provider deduplicates.
5. `Queryable`: call the registered `RequestIdLookup` with the recorded request
id; complete from the provider's answer or retry if it never landed.
6. `Unsafe`: transition the attempt to `Indeterminate`, emit the operator
notification, and **stop**. No retry path exists for this arm — do not add one
behind a flag.
7. Build three test tools, one per class, each with an injectable crash point
mid-call.
## Acceptance
- Three tools, one per class; crash mid-call for each.
- `Idempotent` retries and produces no duplicate effect.
- `Queryable` reconciles against the provider and completes or retries correctly.
- `Unsafe` becomes `Indeterminate` and raises, rather than retrying.
## Verify
**Harness:** three real test tools, one per class, each writing to an
**external** side-effect ledger (a file or a counter service) so effect count is
observable independently of the log. Plus T2.1's child-process kill.
**Integration test**`tests/it_effect_recovery.rs`:
1. **`Idempotent`:** tool appends to the ledger keyed by its idempotency key.
Kill mid-call, restart, let recovery retry. Assert the ledger holds
**exactly one** entry and the attempt completes.
2. **`Queryable`:** tool records a request id; the fake provider is configurable
to answer "landed" or "never arrived".
- Provider says landed → assert recovery **completes** without re-issuing;
ledger count stays 1.
- Provider says never arrived → assert recovery **retries**; ledger count
becomes 1 (from the retry, not two).
3. **`Unsafe`:** kill mid-call. Assert the attempt becomes `Indeterminate`, the
operator notification fires, and the ledger count is **unchanged** — no retry
was issued.
4. Registration: a tool with no declared effect class is rejected; a
`Idempotent` registration without a `KeyDerivation` fails to compile
(`trybuild`).
5. Key stability: run recovery twice; assert the derived key is identical both
times.
**Command:** `cargo test -p durability --features test-hooks effect_recovery -- --test-threads=1`
**False pass:**
- Counting effects only in the framework's own log. The log is what you are
testing; the external ledger is the witness.
- The `Idempotent` case passing because the provider stub deduplicates on
*content* rather than on the supplied key — it then passes even when the key
derivation is broken. Make the stub key-only.
- Testing `Unsafe` by asserting an error is returned. `Indeterminate` is a state,
not an error, and the distinction is the deliverable.
## Traps
- A "retry anyway with a warning" path for `Unsafe`. The class exists to say the
answer is no.
- Deriving the idempotency key at recovery time from something that changed — it
must derive from the recorded arguments, deterministically.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §8.4, §13.1, §13.2 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+104
View File
@@ -0,0 +1,104 @@
# T2.3 — Rewind as fork
| Field | Value |
|---|---|
| Phase | P2 — Durability hard parts |
| Size | L — over 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Rewind allocates a new `BranchId` and starts its LSNs at zero. Nothing is
deleted.
## Facts (inlined — no spec read needed)
```
lsn 0 ─ 1 ─ 2 ─ 3 ─ 4 ─ 5 ─ 6(failed) branch 0, retained
└─ 0 ─ 1 ─ 2 ─ ... branch 1, forked at (0, 3)
```
- Same rule as attempt N+1 never mutating attempt N, for the same reason: **the
discarded branch is the evidence.** Truncating it destroys the failure that
motivated the rewind, which is what the learning loop exists to consume.
- Queries default to the live branch; grading may read all of them. **Only the
live branch is exported.**
- Rewinding past a `Committed` intent with a non-idempotent effect is a
**compensation** problem, not a replay problem. The log records what happened;
it cannot un-happen it. Flag rather than pretend.
- A rewind is also one of the three mutators the verifier snapshot barrier
defends against (T4.2) — it can fork a new `BranchId` while verifiers hold a
view of the old one.
## Steps
1. Allocate the next `BranchId` for the run. Record a fork event carrying
`(parent_branch, parent_lsn)` — the fork point is data, not inference.
2. Start the new branch's LSNs at 0. They are per branch already (T0.3), so this
falls out of the key shape rather than needing a reset.
3. Seed the new branch's state by folding the parent branch up to the fork LSN.
Do not copy the parent's records into the new branch.
4. Mark which branch is live. Default every query to it; expose an explicit
all-branches mode for grading (T1.7).
5. Restrict export to the live branch — check this where the outbox entry is
built (T0.6), not at the relay.
6. Before rewinding past a `Committed` intent, look up the effect class. If it is
not idempotent, emit a compensation-required flag on the fork event and
surface it. Do not block the rewind and do not silently proceed.
## Acceptance
- Rewind a 6-step run to step 3, run a different path.
- The original branch is **fully readable**.
- The live-branch query returns only the new path.
## Verify
**Harness:** a 6-step run driven by the stub model, plus the raw log reader so
branch 0 can be inspected directly rather than through the query surface.
**Integration test**`tests/it_rewind_fork.rs`:
1. Run 6 steps to a failure at step 6. Snapshot branch 0's serialized records.
2. Rewind to step 3; run a different path on branch 1.
3. Assert branch 0's records are **byte-identical** to the snapshot — nothing
truncated, nothing rewritten.
4. Assert branch 1's LSNs start at 0 and its fork event carries
`(parent_branch: 0, parent_lsn: 3)`.
5. Assert the default query returns only branch 1's path; the all-branches query
returns both.
6. Assert branch 1 does **not** contain copies of branch 0's records — count
records per branch and compare against expected.
7. Export path: assert outbox entries exist only for branch 1.
8. Compensation flag: rewind past a `Committed` non-idempotent intent; assert the
fork event carries the compensation-required flag and the rewind still
proceeds.
9. `assert_refold_identical` on both branches.
**Command:** `cargo test -p durability rewind`
**False pass:**
- Step 3 checked through the query surface, which defaults to the live branch and
will happily report "branch 0 unchanged" without reading it. Read the raw log.
- Step 6 omitted: an implementation that copies parent records into the child
passes every other assertion here and makes every later "what happened" query
ambiguous.
- Asserting the fork point by re-deriving it from LSNs rather than reading the
recorded fork event.
## Traps
- Truncating the log at the fork point "since that history is dead". It is the
evidence.
- Copying parent records into the child branch. It duplicates history and makes
every later "what actually happened" query ambiguous.
- Exporting all branches. Downstream consumers then see two contradictory
timelines for one run.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §8.5 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+88
View File
@@ -0,0 +1,88 @@
# T2.4 — Schema evolution end-to-end
| Field | Value |
|---|---|
| Phase | P2 — Durability hard parts |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Change the event enum for real and prove a log recorded in P1 still folds. This
is T0.4's framework exercised against actual history rather than a synthetic
fixture.
## Facts (inlined — no spec read needed)
- Depends on T0.4 (upcaster registry) and T0.3 (`SchemaVersion` on every record).
- The rules being tested: variants are **never removed or repurposed**;
deprecated variants stay decodable forever; migrations are upcasters applied on
read, never a rewrite of stored records.
- The failure this prevents: two years of records, one `WorkEvent` variant
renamed, and the drop-and-re-fold property is silently gone. Nothing fails at
the time of the rename — it fails at the first cold rebuild.
- The v1 fixture must be a **real P1 log**, not a hand-written one. A synthetic
fixture only tests the variants whoever wrote it remembered.
## Steps
1. Capture a full P1 run's log as a committed fixture, at v1, with its blobs.
Store the expected folded state alongside it.
2. Make a genuine v2 change: add a field to an existing variant, or split one
variant into two. Do not rename or delete anything.
3. Bump `CURRENT_SCHEMA` to v2 and register the v1→v2 upcaster.
4. Run the fold over the v1 fixture under the v2 binary; assert the folded state
equals the recorded expectation.
5. Record a fresh v2 fixture from the same workflow and assert the two folded
states agree — an upcast v1 log and a native v2 log must be
indistinguishable downstream.
6. Extend T0.3's directory-walk fixture test so both version folders are covered
permanently, not just in this task's test.
## Acceptance
- A v1 log fixture recorded in P1 still folds correctly after the event enum
changes.
## Verify
**Harness:** a **real P1 run's** log committed as a fixture, with its expected
folded state committed beside it. Not hand-written.
**Integration test**`tests/it_schema_evolution.rs`:
1. Under the v2 binary, fold the committed v1 fixture → `state_a`.
2. Assert `state_a` equals the committed expected state, byte for byte.
3. Record a fresh v2 run of the same workflow → fold → `state_b`.
4. Assert `state_a` and `state_b` are equivalent under the fields that should not
have changed. An upcast v1 log and a native v2 log must be indistinguishable
downstream.
5. Assert the v1 fixture file's bytes are unchanged by the test run — the fixture
is read-only history.
6. Variant-removal guard: a test that enumerates v1's variant set and asserts
every one is still decodable under v2.
**Command:** `cargo test -p log schema_evolution`
**False pass:**
- Regenerating the fixture when the test goes red. That is the one move that
destroys the guarantee — the fixture is the historical record, the upcaster is
what changes. Make the fixture path read-only in the test and assert on that.
- A v2 change that touches no variant present in the P1 fixture, so no upcaster
logic runs. Step 6 catches the removal case; choose a v2 change that provably
affects a recorded variant.
## Traps
- "Fixing" the fixture bytes when the test fails. The fixture is the historical
record; the upcaster is what changes.
- A v2 change that removes a variant, which passes this test today because
nothing in the P1 fixture used it, and breaks the moment an older log does.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §8.7 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+91
View File
@@ -0,0 +1,91 @@
# T2.5 — Checkpoints
| Field | Value |
|---|---|
| Phase | P2 — Durability hard parts |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Materialized state snapshots tagged with their LSN, so restart folds forward from
the newest one instead of from zero.
## Facts (inlined — no spec read needed)
- A checkpoint is a materialized state snapshot tagged with its LSN. Restart
folds forward from the newest one at or below the target LSN.
- **An optimization only.** Deleting every checkpoint costs startup time and
nothing else. That property is the acceptance test, and it is what keeps
checkpoints from quietly becoming a second source of truth.
- Port surface already exists from T0.5:
`put_checkpoint(key, upto, state)` and `latest_checkpoint(key, upto) -> Option<Checkpoint>`.
`None` means fold from LSN 0.
- Checkpoints are per `BranchKey`, like everything else in the log.
## Steps
1. Serialize the materialized state (T0.8) deterministically — the same ordered
collections, so a checkpoint written twice from the same state is byte-equal.
2. Write checkpoints on a policy: every N records or every M seconds per branch.
Keep the policy in one place and make it configurable rather than scattered.
3. `rebuild(key, upto)`: call `latest_checkpoint`, deserialize if present, then
fold records from `checkpoint.lsn + 1`. On `None`, fold from 0.
4. Never let the checkpoint be the only holder of a fact. Validate this by making
the `None` path the default in tests.
5. Add a prune path — old checkpoints for a branch are deletable at any time
without coordination.
6. Test both directions: with checkpoints and after deleting them all, comparing
final state and reporting both startup times.
## Acceptance
- Deleting all checkpoints changes startup time and **nothing else** — final
state identical.
## Verify
**Harness:** a run long enough to trigger the checkpoint policy several times —
at least 3 checkpoints, so "newest at or below" is a real choice.
**Integration test**`tests/it_checkpoints.rs`:
1. Run to completion with checkpointing on. Record final state bytes and startup
time for a rebuild.
2. Delete **every** checkpoint row. Rebuild from LSN 0.
3. Assert the final state bytes are **identical**; assert startup time differs
(log both, do not assert a threshold — that is flaky).
4. `latest_checkpoint(key, upto)` with `upto` between two checkpoints: assert it
returns the **lower** one, not the newest overall.
5. Determinism: write a checkpoint twice from the same state; assert byte
equality of the two serializations.
6. Crash between checkpoint write and subsequent commits; assert rebuild still
lands on the same state.
7. Prune: delete an older checkpoint while a newer exists; assert rebuild is
unaffected.
**Command:** `cargo test -p durability checkpoints`
**False pass:**
- A run short enough to produce zero or one checkpoint. Then step 2 deletes
nothing and the test is vacuous — assert the checkpoint count is ≥ 3 before
deleting.
- Step 4 omitted: an implementation returning the globally newest checkpoint
passes everything else and breaks the moment rewind or point-in-time rebuild
needs an earlier LSN.
- Comparing state with `PartialEq` rather than bytes.
## Traps
- Writing a checkpoint inside the same transaction as the commit path and then
depending on it for correctness. It is a cache; keep it separable.
- A checkpoint that serializes a `HashMap`. Byte-identity fails intermittently
and reads as flakiness.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §7, §8.6 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+108
View File
@@ -0,0 +1,108 @@
# T2.6 — Crash matrix
| Field | Value |
|---|---|
| Phase | P2 — Durability hard parts |
| Size | L — over 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Systematic `kill -9` at every kernel transition, in a loop, verifying
recoverability after each. This is the phase gate.
## Facts (inlined — no spec read needed)
- P2 is the phase most likely to be skipped and most expensive to retrofit. The
matrix is what proves the three preceding tasks actually hold together.
- What "recoverable" means concretely: on restart, every attempt resolves to a
legal state per T0.2's table; every `Dispatched` intent resolves per T2.2's
class table; the fold from LSN 0 reproduces the state exactly (T0.8).
- "Zero double-applied effects" is the second, independent assertion. An
idempotent tool retried after a crash must leave one effect, not two.
- Lessons this suite exists to catch:
- *A green suite says nothing about coverage.* A prior idempotency feature
generated its own keys and checked for duplicates among them — unreachable
for a whole phase, with tests asserting the count was zero.
- *A test run that prints nothing cannot distinguish slow from hung.* Per-test
progress and per-test timeouts from the first commit.
## Steps
1. Enumerate the crash points: every kernel transition emitted by T1.3/T1.4, plus
T2.1's intra-intent windows, plus each of T0.6's four table writes.
2. Build the harness as a **real process kill** (`kill -9` on a child), not a
panic-and-catch. Shadow paging behaviour under a hard kill is part of what is
being tested.
3. Drive a workload with a scripted stub model (T1.2) and the three effect-class
tools (T2.2), so both durability and effect recovery are exercised.
4. Seed the crash-point selection from a recorded seed. Print the seed on
failure — an unreproducible crash matrix failure is not a finding.
5. After each restart assert: state is legal, intents resolve, the cold re-fold
matches, effects applied exactly once.
6. Emit per-test progress and enforce a per-test timeout so a hang is
distinguishable from a slow case.
7. Run 500 randomized crash points in CI; keep the exhaustive enumeration as a
longer nightly job.
## Acceptance
- 500 randomized crash points, **zero unrecoverable states, zero double-applied
effects**.
## Phase gate
P2 closes on a green crash matrix.
## Verify
**Harness:** the P2 capstone. Child process under `kill -9`; the external
side-effect ledger from T2.2; the three effect-class tools; the stub model; a
recorded seed printed on every run.
**Integration test**`tests/it_crash_matrix.rs`:
1. Enumerate crash points: every kernel transition, T2.1's two intent windows,
T0.6's four table writes. Assert the enumerated count matches an expected
constant, so a newly added transition without a crash point fails the test.
2. For each of 500 seeded random points: run the workload, `kill -9` at the
point, restart, then assert **all four** properties:
- every attempt is in a legal state per T0.2's table;
- every `Dispatched` intent resolved per T2.2's class table;
- `assert_refold_identical` passes;
- the external ledger shows **exactly one** effect per intended effect.
3. Print the seed and the crash point on failure; a failure that cannot be
replayed is not a finding.
4. Per-test progress output and a per-test timeout, so a hang is distinguishable
from a slow case.
5. Keep the **exhaustive** enumeration as a nightly job; the 500-point random
sample runs in CI.
**Command:**
`cargo test -p durability --features test-hooks --test it_crash_matrix -- --nocapture`
**False pass:**
- `panic!` instead of `kill -9`. Destructors run, buffers flush, and the whole
matrix passes against an implementation with no durability at all.
- Counting effects only among those the harness itself generated. That is the
documented prior failure: an idempotency feature generated its own keys and
checked for duplicates among them — unreachable for a whole phase, with tests
asserting the count was zero. Count against the **external ledger**.
- Step 1 omitted, so new transitions silently escape the matrix.
- Asserting recoverability as "the process restarted without error". Restarting
cleanly into wrong state is the failure being hunted.
## Traps
- Simulating a crash with `panic!` and unwinding. Destructors run, which is
exactly what a real crash does not do.
- Counting only effects the harness itself created — the coverage failure quoted
above, repeated.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §8, §19 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+109
View File
@@ -0,0 +1,109 @@
# T2.7 — P2 composition gate
| Field | Value |
|---|---|
| Phase | P2 — Durability hard parts |
| Size | L — over 3 days |
| Status | Not started |
| Flags | gate |
| Spec | inlined below |
| Blocks | P3 |
## Goal
Prove intents, effect classes, forks, checkpoints and schema evolution survive
each other — not just crashes in isolation. T2.6 crashes a simple workload; this
crashes a **compound** one.
**Phase gate criterion:** `kill -9` at every transition leaves recoverable state.
## Facts (inlined — no spec read needed)
- Each P2 task was verified against a simple run. The interactions are where the
cost lives, and each is individually plausible-looking:
- a **crash during a rewind** — a fork event committed, the new branch's LSN 0
not yet written;
- a **`Dispatched` intent on a branch that was subsequently forked** — recovery
must resolve it on the branch that owns it, not the live one;
- a **checkpoint written before a schema upgrade** — the checkpoint holds v1
state, the log above it holds v2 records;
- **reduction ordering** interacting with a fork — dead-branch text is
reducible, the live branch's is not yet.
- Recovery is defined jointly: legal states (T0.2), intents resolved by class
(T2.2), byte-identical re-fold (T0.8), and exactly-once effects measured
**externally**.
- The discarded branch is evidence. A recovery path that "cleans up" a partial
fork destroys the failure that motivated the rewind.
## Steps
1. Build the compound workload: a run that retries, dispatches one intent per
effect class, rewinds at least once, and crosses a checkpoint boundary.
2. Extend T2.6's crash-point enumeration to cover the compound path, including
points **inside** a rewind and **inside** recovery itself.
3. Add the schema dimension: run half the seeds with a v1→v2 upgrade applied
between crash and restart.
4. Assert the four recovery properties jointly after every restart.
5. Run recovery **twice** per seed — recovery must itself be idempotent, since a
crash during recovery is an ordinary event.
6. Make this the required CI job gating P3.
## Acceptance
- 500 seeded crash points on the **compound** workload: zero unrecoverable
states, zero double-applied effects, byte-identical re-fold, all intents
resolved by class.
- Recovery run twice produces the same state as recovery run once.
- Half the seeds pass with a schema upgrade interposed.
## Verify
**Harness:** T2.6's child-process `kill -9` rig, the external side-effect ledger,
the three effect-class tools, and a seed printed on every run.
**Integration test**`tests/it_p2_composition.rs`:
1. **Crash inside a rewind:** abort between the fork event commit and the new
branch's first record. On restart assert the parent branch is **byte-identical
to its pre-rewind snapshot** and the run is in a legal state — either the fork
completed or it did not, never half.
2. **Intent on a forked branch:** dispatch an `Unsafe` intent on branch 0, fork
to branch 1, crash. Assert recovery marks the **branch-0** attempt
`Indeterminate` and does not touch branch 1.
3. **Checkpoint across schema versions:** write a checkpoint at v1, append v2
records, crash, restart under the v2 binary. Assert rebuild from that
checkpoint equals a rebuild from LSN 0.
4. **Recovery idempotence:** run recovery, snapshot state, run recovery again,
assert byte equality and assert the external ledger count is unchanged.
5. **Effect exactness under compounding:** across all seeds, assert the ledger
holds exactly one entry per intended effect — including effects issued before
a fork.
6. **Crash during recovery:** abort partway through the recovery pass itself;
restart; assert convergence.
7. **Regression:** re-run T2.1T2.6 plus P0/P1 gates in the same job.
**Command:**
`cargo test -p durability --features test-hooks --test it_p2_composition -- --nocapture`
**False pass:**
- Running the compound matrix on the simple workload. T2.6 already covers that;
this gate exists for the interactions, and every one of them is green in
isolation.
- Step 4 omitted. A recovery pass that is correct once and destructive twice
passes every test in P2 and fails the first time a machine crashes while
recovering — which is exactly when it runs.
- Step 1 asserting only that the run recovered. Recovering by truncating the
parent branch also recovers, and destroys the evidence.
- Measuring effect counts in the framework's log rather than the external ledger.
## Traps
- Treating a crash during recovery as out of scope. It is the ordinary case in a
crash loop, and a crash loop is what a nonzero `Indeterminate` rate means.
- Letting the compound workload drift out of sync with new transitions. Assert
the enumerated crash-point count against a constant, as T2.6 does.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §8.4, §8.5, §8.6, §8.7 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
@@ -0,0 +1,125 @@
# T3.1 — `WorkflowDef` IR + canonicalization
| Field | Value |
|---|---|
| Phase | P3 — Workflow as data |
| Size | L — over 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | T3.2T3.6 |
## Goal
The validated intermediate representation every workflow format parses into, and
the canonicalization that makes `WorkflowVersion` a content hash of *meaning*
rather than of source text.
## Facts (inlined — no spec read needed)
```rust
pub struct WorkflowDef {
pub id: WorkflowId,
pub schema: SchemaVersion,
pub steps: Vec<StepDef>,
pub transitions: Vec<Transition>,
pub rubric: RubricDef,
pub budget: BudgetDef,
}
pub struct StepDef {
/// Author-assigned, stable across versions.
pub id: StepId,
pub kind: StepKind,
pub tools: ToolSelector,
pub verify: Vec<VerifierRef>,
pub retry: RetryPolicy,
pub timeout: Duration,
}
pub enum StepKind {
Model { prompt: PromptTemplate, effort: ReasoningEffort },
Tool { tool: ToolId, args: ArgTemplate },
Parallel { branches: Vec<StepId>, join: JoinPolicy },
Conditional { on: Predicate, then: StepId, otherwise: Option<StepId> },
SubWorkflow { workflow: WorkflowId, version: VersionSelector },
}
```
- **`WorkflowVersion` is the Blake3 hash of the canonicalized IR, not of the
source text.** Two YAML files differing only in key order produce the same
version — which is what makes "did this change affect results" answerable.
- Validation and canonicalization live in the **kernel** and run on the IR, so a
new format (T3.2) inherits every check without reimplementing one.
- Domain states are open: a workflow declares its own step names, ordering and
transitions as data. The kernel validates the declaration, then executes it.
Adding a domain state is a config change, never a recompile.
- Versions form a DAG: content-addressed, parent-pointered, **never edited**.
Editing a version in place destroys every result already attributed to it.
## Steps
1. Define the IR types above. Keep them free of parser concerns — no source
spans, no format-specific fields.
2. Write the canonicalizer: sort every unordered collection by a defined key,
normalize whitespace inside templates only where semantically irrelevant,
drop optional fields that equal their default, and emit a deterministic byte
encoding.
3. Hash the canonical bytes with Blake3 into `WorkflowVersion`.
4. Add the parent pointer: a new version records its parent's hash. Nothing
mutates an existing version record.
5. Decide and document what is *not* canonicalized — prompt template text is
semantic and must hash as written. Put that decision in a comment at the
canonicalizer, since the next person will otherwise "improve" it.
6. Test: build two structurally identical IRs from different field orders and
assert equal hashes; change one prompt character and assert different hashes.
## Acceptance
- Two YAML files differing only in key order and whitespace produce identical
`WorkflowVersion`.
## Verify
**Harness:** pairs of source files that differ only in ways that must not matter,
and pairs that differ in ways that must.
**Integration test**`tests/it_canonical_version.rs`:
1. **Must match:** two YAML files with reordered top-level keys, reordered map
entries, different indentation, and trailing whitespace. Assert identical
`WorkflowVersion`.
2. **Must differ:** change one character inside a prompt template. Assert a
different version. This is the boundary — prompt text is semantic.
3. **Must differ:** change a `timeout`, a `retry` count, a `StepId`. One test per
field, table-driven, so a canonicalizer that drops a field is caught by the
field it drops.
4. Round trip: canonicalize twice; assert the byte output is stable.
5. Parent pointer: build v2 from v1; assert v2 records v1's hash and that v1's
record is unchanged on disk.
6. Property test: generate random IRs, permute the order of every unordered
collection, assert hash invariance.
**Command:** `cargo test -p workflow canonical`
**False pass:**
- Only testing step 1. A canonicalizer that discards fields it does not
understand passes every "these should match" test and silently makes two
different workflows the same version. Step 3's per-field table is the guard.
- Hashing the source bytes, which passes step 2 and fails step 1 — check both
directions.
- A property test that permutes only the collection the implementation already
sorts.
## Traps
- Hashing the source bytes. It is one line simpler and makes every reformat look
like a behaviour change.
- Canonicalizing prompt text. A whitespace change inside a prompt *is* a
behaviour change.
- Allowing a version record to be updated in place.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §2, §4.1, §12.2 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
@@ -0,0 +1,95 @@
# T3.2 — `WorkflowFormat` trait + YAML and JSON
| Field | Value |
|---|---|
| Phase | P3 — Workflow as data |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Formats are plugins. Ship YAML and JSON; prove a third format needs no kernel
change.
## Facts (inlined — no spec read needed)
```rust
pub trait WorkflowFormat: Send + Sync {
fn extensions(&self) -> &[&str];
fn parse(&self, src: &[u8]) -> Result<WorkflowDef, ParseError>;
}
```
- A user wanting a DSL, Starlark, or a database row implements the trait.
- **Validation and canonicalization live in the kernel and run on the IR** (T3.1,
T3.3), so a new format inherits every check without reimplementing one. A
format that does its own validation has forked the rules.
- Because the version hash is over the canonical IR, the same workflow in two
formats must produce the same `WorkflowVersion` — that equality is the test
that the boundary is in the right place.
- Defaults ship working; every default is a port. A user who wants built-in
behaviour writes no code; a user who wants their own writes an impl, not a fork.
## Steps
1. Define the trait exactly as above. `parse` returns the IR and nothing else —
no side effects, no registration, no validation.
2. Implement `YamlFormat` and `JsonFormat` over `serde`. Both target the same IR
types from T3.1.
3. Build the format registry keyed by extension. Resolution is by extension, with
a clear error when two formats claim the same one.
4. Make `ParseError` carry position information where the format supplies it —
this is the only error type that legitimately knows about source text.
5. Write the same workflow in YAML and in JSON; assert equal `WorkflowVersion`.
6. In a **separate test crate**, implement a third trivial format (for example
TOML or a line-based DSL) against the public API only. If it needs anything
`pub(crate)`, the trait is short — fix the trait, not the test.
## Acceptance
- The same workflow expressed in both formats produces the same
`WorkflowVersion`.
- A third format added in a test crate needs **no kernel change**.
## Verify
**Harness:** a **separate test crate outside the workspace** depending only on
the published API — that crate's existence is the deliverable, not a convenience.
**Integration test**`tests/it_format_equivalence.rs` plus
`third-format-crate/`:
1. Express one non-trivial workflow (all five `StepKind`s) in YAML and in JSON.
2. Parse both; assert identical `WorkflowVersion`.
3. Assert the parsed IRs are byte-identical after canonicalization.
4. In the external crate, implement a third format (TOML or a line DSL) against
the public API only. Build it. If it needs any `pub(crate)` item, the trait is
short — that is a finding, not a test workaround.
5. Register the third format and run the **same** validation suite (T3.3) against
it; assert it inherits every check with no new code.
6. Extension collision: register two formats claiming `.yaml`; assert a clear
error rather than last-one-wins.
**Command:** `cargo test -p workflow format_equivalence && cargo test --manifest-path third-format-crate/Cargo.toml`
**False pass:**
- The third-format crate living inside the workspace, where it can reach
internals. It then passes while the public API is unusable — which is the
entire thing being tested.
- Step 5 omitted: a format that does its own validation passes steps 14 and has
quietly forked the rules.
## Traps
- Format-specific validation creeping into `parse`. Two formats then disagree
about what is legal.
- Leaking `serde` types into the IR, which makes the third-format test need a
serde dependency it should not have.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §1, §4.2 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+101
View File
@@ -0,0 +1,101 @@
# T3.3 — Load-time validation
| Field | Value |
|---|---|
| Phase | P3 — Workflow as data |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Every structural check the kernel makes on a workflow IR, each producing a
specific diagnostic. Runs at load, never at execution.
## Facts (inlined — no spec read needed)
Checks, all on the IR so every format inherits them:
- **DAG check.** The domain machine is validated as a DAG with explicit loop
bounds. An unbounded loop is a rejection, not a runtime concern.
- **`StepId` uniqueness** within a version.
- **Sub-workflow cycle detection at load, not at execution**, with recursion
depth bounded by the kernel.
- **Capability requirements resolvable** — every `ToolSelector` and
`VerifierRef` resolves to something registered.
- Sandbox eligibility belongs here too: a workflow whose steps cannot run
sandboxed is ineligible for shadow evaluation and **must say so at load time
rather than at 3am** (enforced fully in T6.5).
- Every rejection names the specific problem. A generic parse error moves the
diagnosis cost onto the user, who has less context than the validator.
- Related lesson: *suspect the guards before the mechanism* — a prior "batching"
failure turned out to be a depth guard, because two unrelated limits shared a
default value. Give each limit its own named constant.
## Steps
1. Write the validator as a pure function `validate(&WorkflowDef) -> Result<(), Vec<ValidationError>>`.
Collect all errors, do not stop at the first.
2. `ValidationError` is an enum with one variant per check, each carrying the
offending ids. No stringly-typed messages.
3. DAG check over `transitions`, with loop bounds required and verified finite.
4. `StepId` uniqueness by set insertion; report the duplicate id.
5. Sub-workflow graph walk with a kernel depth bound; report the cycle path, not
just "cycle detected".
6. Resolve every tool and verifier reference against the registries; report the
unresolved name and what registry was searched.
7. Give each numeric limit its own named constant with its own value, even where
two currently coincide.
8. Build the table-driven suite: one malformed workflow per check, asserting the
**specific** error variant.
## Acceptance
- A table-driven suite of malformed workflows, each rejected with a specific
diagnostic — not a generic parse error.
## Verify
**Harness:** a table of malformed workflow files, one per check, each paired with
the **exact `ValidationError` variant** it must produce.
**Integration test**`tests/it_validation_table.rs`:
1. For each fixture, assert the returned error list **contains the expected
variant** and that the variant carries the offending ids.
2. Multi-error fixture: a workflow with three independent defects; assert **all
three** are reported in one pass, not just the first.
3. Cycle case: assert the error carries the **cycle path**, not just a boolean.
4. Depth case: a sub-workflow chain exceeding the kernel bound; assert rejection
at load and that no execution was attempted.
5. Unresolved capability: assert the error names both the missing name and the
registry searched.
6. Positive control: a valid workflow produces **zero** errors — otherwise a
validator that rejects everything passes the whole table.
7. Constant audit: assert each limit reads a distinct named constant; a test that
changes one constant and asserts only the matching check moves.
**Command:** `cargo test -p workflow validation`
**False pass:**
- Asserting `is_err()` per fixture. A validator returning one generic parse error
for everything passes the entire table — which is exactly the outcome the
acceptance criterion forbids. Match on the **variant**.
- Step 6 omitted, so a reject-everything implementation is green.
- Step 2 omitted, so fail-fast looks correct until a user with two mistakes has to
make two round trips.
- Two limits sharing a constant: step 7 is what surfaces the documented "batching
failure was actually the depth guard" class of bug.
## Traps
- Fail-fast on the first error. A user fixes one thing per load cycle.
- Deferring cycle detection to execution, where it becomes a stack overflow.
- Two limits sharing one constant. When one trips, you will debug the other.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §4.4, §5.1, §12.4, §19 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+95
View File
@@ -0,0 +1,95 @@
# T3.4 — `StepId` stability checks
| Field | Value |
|---|---|
| Phase | P3 — Workflow as data |
| Size | S — under 1 day |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Report added, removed and retained `StepId`s across a version bump, and reject a
bump that retains nothing unless it is explicitly marked a rewrite.
## Facts (inlined — no spec read needed)
- Credit assignment attributes outcomes to steps **across workflow versions**.
That requires a step identity surviving edits: insert a step at position 2 and
every positional index shifts, but `StepId` does not.
- **The framework cannot infer this.** It is a documented obligation on the
workflow author, enforced by three load-time checks:
1. `StepId` unique within a version.
2. On a version bump, report added, removed and retained ids. A version
retaining **no** ids from its parent is almost certainly a renumbering
accident and is rejected unless explicitly marked as a rewrite.
3. `StepId` is opaque to the framework — never parsed, never ordered, never
assumed numeric.
- This is why the stability contract is load-bearing rather than cosmetic: per-step
credit (T5.10) and the whole attribution path depend on it.
## Steps
1. Take parent and child `WorkflowDef`s; compute the three sets: added, removed,
retained.
2. Emit the report as structured data, not a log line — it belongs on the version
record so it is auditable later.
3. Reject when `retained.is_empty()` and the child is not marked as a rewrite.
Name the count of added and removed ids in the error.
4. Add the rewrite marker as an explicit field on the workflow definition, so
marking it is a deliberate edit in the user's own file.
5. Audit for any code that parses, sorts numerically, or ranges over `StepId`.
Delete it — ordering comes from `transitions`, not from the id.
6. Test both directions: renumbering every id rejected by default, accepted with
the marker.
## Acceptance
- Renumbering every step id is **rejected by default** and **accepted with the
explicit marker**.
## Verify
**Harness:** version pairs committed as fixtures — parent and child — covering
each diff shape.
**Integration test**`tests/it_stepid_stability.rs`:
1. **Renumber everything, no marker:** assert **rejected**, and that the error
names the added/removed counts.
2. **Renumber everything, marker set:** assert **accepted**.
3. **Insert a step at position 2, ids unchanged:** assert accepted, and that the
report shows 1 added, 0 removed, N retained. This is the case the whole
contract exists for.
4. **Rename one of five ids:** assert accepted with 1 added, 1 removed, 4
retained — a partial rename must not trip the all-or-nothing rule.
5. Duplicate id within one version: rejected.
6. Assert the report is persisted **on the version record**, readable after a
restart — not emitted only as a log line.
7. Opacity audit: grep the codebase for numeric parsing, sorting or ranging over
`StepId`; assert none. Add a `StepId` in a fixture that is non-numeric and
contains a `.`, and assert nothing breaks.
**Command:** `cargo test -p workflow stepid_stability`
**False pass:**
- Only testing steps 1 and 2. The rule "reject when retained is empty" is
trivially satisfiable by an implementation that computes nothing else — steps
3 and 4 are what prove the diff is real.
- Asserting the report exists in stdout. Credit assignment reads it later from
storage; step 6 is the check that matters.
- Fixtures whose ids are `step1`, `step2`, … so accidental numeric ordering is
indistinguishable from correct behaviour. Step 7's odd id is the guard.
## Traps
- Sorting steps by `StepId` for display and then depending on that order.
- Auto-generating `StepId` from position when the author omits one. That
guarantees the renumbering accident the check exists to catch.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §4.3, §11.7 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+123
View File
@@ -0,0 +1,123 @@
# T3.5 — Interpreter over the IR
| Field | Value |
|---|---|
| Phase | P3 — Workflow as data |
| Size | L — over 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Execute a validated `WorkflowDef` directly. Replaces T1.3's hardcoded workflow.
## Facts (inlined — no spec read needed)
Five step kinds to support:
```rust
pub enum StepKind {
Model { prompt: PromptTemplate, effort: ReasoningEffort },
Tool { tool: ToolId, args: ArgTemplate },
Parallel { branches: Vec<StepId>, join: JoinPolicy },
Conditional { on: Predicate, then: StepId, otherwise: Option<StepId> },
SubWorkflow { workflow: WorkflowId, version: VersionSelector },
}
```
- Concurrency shape is fixed and the interpreter must not widen it:
| Scope | Parallel |
|---|---|
| across runs | unbounded |
| steps within a run | **serial by default** — step N+1 reads N's output |
| `Parallel` step branches | fan-out/join, declared explicitly in the IR |
| attempts within a step | **strictly serial** — a retry needs the prior failure |
| verifiers for one attempt | fan-out/join |
- The serial spine is `(TenantId, RunId)`. Parallelism lives between runs and
inside declared fan-out. Nothing else may interleave.
- **Tool calls appear in the episode as first-class steps.** The hostcall
boundary already knows the identity, arguments and capabilities; recording them
as opaque invocations discards information the runtime is holding anyway.
- Every external effect goes through the write-ahead intent wrapper (T2.1) — the
interpreter is where that is easy to bypass.
- `SubWorkflow` version resolution is T3.6's task; the interpreter consumes an
already-resolved hash.
## Steps
1. Replace T1.3's hardcoded step list with a walk over `WorkflowDef.steps` driven
by `transitions`. Keep the run lifecycle from T1.3 unchanged.
2. `Model`: render `PromptTemplate`, capture the context partition (T1.5), put
prompt and output blobs (T1.6), record `Usage`.
3. `Tool`: resolve `ToolId`, render `ArgTemplate`, call through the intent
wrapper (T2.1), record the tool call as its own step with identity, arguments
and capabilities.
4. `Parallel`: fan out inside the `RunScope` (T1.1) so cancellation still
propagates; apply `JoinPolicy` at the join.
5. `Conditional`: evaluate `Predicate` against recorded state only — no ambient
inputs, or the fold stops being reproducible.
6. `SubWorkflow`: execute against the resolved version, within the kernel's
recursion bound, recording the child run's linkage.
7. Port P1's hardcoded workflow to YAML and diff the resulting episode structure
against the P1 fixture.
## Acceptance
- P1's hardcoded workflow, re-expressed as YAML, produces an **identical episode
structure**.
## Verify
**Harness:** P1's episode fixture as the oracle, plus the stub model and the
external side-effect ledger from T2.2.
**Integration test**`tests/it_interpreter_parity.rs`:
1. Express P1's hardcoded workflow as YAML. Run it through the interpreter.
2. Assert the resulting **episode structure** matches the P1 fixture: same steps,
same attempt counts, same event sequence. Compare the serialized episode with
volatile fields (ids, timestamps) normalized — and list the normalized fields
explicitly, so the normalizer cannot quietly grow.
3. One test per `StepKind`:
- `Model` — prompt rendered, partition and blobs captured.
- `Tool` — appears as a **first-class step** with identity, arguments and
capabilities recorded; the effect went through the intent wrapper (assert 3
intent records exist).
- `Parallel` — branches run concurrently, `JoinPolicy` applied; cancel the run
mid-fan-out and assert every branch task terminated.
- `Conditional` — both arms exercised; run the same workflow twice and assert
identical predicate outcomes.
- `SubWorkflow` — child linkage recorded; recursion past the bound rejected.
4. **Serialization guard:** instrument step start/end times; assert no two
non-`Parallel` steps of one run overlap.
5. **Bypass audit:** assert every tool invocation in the run has a matching
`Pending`/`Dispatched`/`Committed` intent triple. A count mismatch means a
direct call path exists.
6. `assert_refold_identical` on the interpreted run.
**Command:** `cargo test -p executor interpreter`
**False pass:**
- Step 2 with an over-broad normalizer that blanks anything differing. Then any
two episodes "match". Enumerate the normalized fields.
- Step 5 omitted: calling a tool directly works, produces a correct-looking
episode, and fails T2.6's crash matrix later in a way that looks unrelated.
- `Parallel` tested with branches that complete instantly, where concurrency is
indistinguishable from serial execution.
## Traps
- Running independent-looking steps concurrently. Serial-by-default is the
contract; fan-out is declared, never inferred.
- Calling a tool directly rather than through the intent wrapper. It works, and
T2.6's crash matrix then fails somewhere unrelated-looking.
- A predicate that reads the clock or the environment.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §4.1, §5.3, §13.1 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+96
View File
@@ -0,0 +1,96 @@
# T3.6 — Version pinning at spawn
| Field | Value |
|---|---|
| Phase | P3 — Workflow as data |
| Size | S — under 1 day |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
`VersionSelector::Latest` resolves exactly once, at run spawn, and the resolved
hash is recorded.
## Facts (inlined — no spec read needed)
- `SubWorkflow` pins by `VersionSelector`: `Exact(hash)` or `Latest`.
- **`Latest` resolves once, at run spawn**, and the resolved hash is recorded. A
run whose sub-workflow version can change mid-execution is a run whose results
attribute to nothing.
- The same rule applies to the top-level workflow: the run records the exact
`WorkflowVersion` it executed, and a version published mid-run does not affect
it.
- Related failure the type system prevents: a placeholder that type-checks is
invisible. A hardcoded `"current"` version hash compiled, passed tests, and
made every result unattributable — which is why `WorkflowVersion` has no
`Default` (T0.1).
- Versions are never edited in place; a new version is a new hash with a parent
pointer.
## Steps
1. At spawn, resolve the top-level workflow and every reachable `SubWorkflow`
selector to concrete hashes.
2. Record the resolved set on the run's spawn event — the top-level version plus
a map of `(WorkflowId → WorkflowVersion)` for sub-workflows.
3. Make the interpreter (T3.5) read only from that recorded map. It must have no
path back to the registry's current `Latest`.
4. Resolve `Latest` against the registry once; if a new version is published
afterwards, nothing re-reads it.
5. Test: start a run, publish a new version of the same workflow mid-run, assert
the running run's recorded version is unchanged and it finishes on the pinned
one.
## Acceptance
- Publishing a new version mid-run does not change the running run's recorded
version.
## Phase gate
P3 closes when a user-authored YAML workflow runs with no framework recompile.
## Verify
**Harness:** a workflow registry that can publish a new version while a run is
in flight, plus a run long enough to straddle the publish (stub latency on a
mid-run step).
**Integration test**`tests/it_version_pinning.rs`:
1. Register workflow `W` v1, containing a `SubWorkflow` pinned `Latest`.
2. Spawn a run; park it mid-execution **before** the sub-workflow step.
3. Publish `W` v2 and a new version of the sub-workflow.
4. Release the run.
5. Assert the run's recorded top-level `WorkflowVersion` is v1.
6. Assert the sub-workflow executed is the version resolved **at spawn**, not the
newly published one — check the recorded resolution map, then check which
version actually ran (they must agree).
7. Assert the spawn event carries the full `(WorkflowId → WorkflowVersion)` map,
not a selector.
8. Registry-access audit: assert the interpreter makes **zero** registry lookups
after spawn — instrument the registry with a call counter.
**Command:** `cargo test -p executor version_pinning`
**False pass:**
- Parking the run **after** the sub-workflow step, so nothing could have
re-resolved. The park point must precede the step.
- Asserting only step 5. The top-level version is usually recorded correctly; the
lazy-resolution bug lives in the sub-workflow path. Steps 6 and 8 are the test.
- Step 6 checking the recorded map only. A map that is recorded and then ignored
passes — assert what actually ran.
## Traps
- Resolving `Latest` lazily at the point the sub-workflow is entered. It is the
natural implementation and it makes long runs non-attributable.
- Storing the selector rather than the resolved hash on the run record.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §4.4, §12.2, §19 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+105
View File
@@ -0,0 +1,105 @@
# T3.7 — P3 composition gate
| Field | Value |
|---|---|
| Phase | P3 — Workflow as data |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | gate |
| Spec | inlined below |
| Blocks | P4 |
## Goal
Prove the IR, the format plugins, validation, `StepId` stability, the interpreter
and version pinning compose — and that **format is genuinely interchangeable**.
**Phase gate criterion:** a user-authored YAML workflow runs with no framework
recompile.
## Facts (inlined — no spec read needed)
- **The workflow is data, not code.** Users define workflows in their own format
and version them; the framework executes and grades them without recompiling.
- Validation and canonicalization live in the kernel and run on the IR, so a new
format inherits every check without reimplementing one. That claim is only
proven by running the **same** validation suite against a format the kernel has
never seen.
- The composition properties no single P3 task owns:
- the same workflow in **any** format produces the same `WorkflowVersion` and
the same episode;
- all five `StepKind`s **nest**`Parallel` inside `Conditional` inside
`SubWorkflow` — and the concurrency contract still holds;
- a version bump that retains `StepId`s keeps credit attributable across the
bump (T3.4), while the running run stays pinned (T3.6).
- Recursion depth is bounded by the kernel and cycles are detected at load, not
at execution — including cycles that only appear through a nested composition.
## Steps
1. Author one **non-trivial reference workflow** exercising all five step kinds,
nested at least two levels, with loops at their declared bounds.
2. Express it in YAML, in JSON, and in the third format from T3.2's external
crate.
3. Assert identical `WorkflowVersion` and identical episode structure across all
three.
4. Run the full T3.3 validation suite against all three formats.
5. Bump the workflow version retaining `StepId`s; assert credit keys survive and
in-flight runs stay pinned.
6. Make this the required CI job gating P4.
## Acceptance
- The reference workflow, expressed in three formats, produces one
`WorkflowVersion` and one episode structure.
- The validation suite passes identically against all three formats.
- No framework recompile is needed to run a user-authored workflow.
## Verify
**Harness:** the reference workflow as a committed fixture in three formats;
T3.2's third-format crate living **outside** the workspace.
**Integration test**`tests/it_p3_composition.rs`:
1. **Format interchange:** parse all three; assert equal `WorkflowVersion`, then
**run** all three and assert equal episode structure with volatile fields
normalized against an enumerated list.
2. **Nesting:** assert the nested `Parallel`-inside-`Conditional`-inside-
`SubWorkflow` executes correctly, that fan-out is concurrent, and that
cancelling the run terminates every nested branch.
3. **Serial spine under nesting:** assert no two non-`Parallel` steps overlap,
even across the sub-workflow boundary.
4. **Validation parity:** run every malformed fixture from T3.3 through all three
formats; assert the **same `ValidationError` variant** each time. A format
producing a different error has forked the rules.
5. **Cycle through composition:** build a cycle that exists only via a nested
sub-workflow. Assert it is rejected **at load**.
6. **Version bump:** bump retaining `StepId`s; assert the added/removed/retained
report is recorded, and that a run started before the bump completes on the
**pinned** version.
7. **Recompile check:** add a fourth workflow file at runtime, in a directory the
test writes to, and run it. Assert no rebuild occurred — the binary under test
is the one built before the file existed.
8. **Regression:** re-run P0P2 gates and all P3 suites.
**Command:** `cargo test -p workflow --test it_p3_composition && cargo test --manifest-path third-format-crate/Cargo.toml`
**False pass:**
- Step 1 with a trivial single-step workflow. Every format handles that; the
divergence lives in nesting and in optional fields.
- Step 4 omitted: a format doing its own validation passes the equal-version test
and silently accepts workflows the kernel would reject.
- Step 7 conflated with "the test passes". The claim is **no recompile** — assert
the workflow file was created after the binary, not just that it ran.
- Running the third format from inside the workspace, where it reaches internals.
## Traps
- Letting the reference workflow shrink over time as steps are removed to make a
test pass. Its coverage is the point; assert its step-kind coverage explicitly.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §4, §5.3 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+103
View File
@@ -0,0 +1,103 @@
# T4.1 — `Verifier` port
| Field | Value |
|---|---|
| Phase | P4 — Verification |
| Size | S — under 1 day |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | T4.2 |
## Goal
The verification port with a fail-closed contract enforced by the framework, not
by the verifier author.
## Facts (inlined — no spec read needed)
```rust
#[async_trait]
pub trait Verifier: Send + Sync {
fn id(&self) -> VerifierId;
/// Any error, panic or timeout MUST resolve to `Fail`. A verifier that
/// throws or hangs can never report `Pass`.
async fn verify(&self, cx: &VerifierCtx) -> VerifierOutcome;
}
```
- **Verification returns ground truth. Grading attributes cause and ranks.**
Collapse them and the system grades its own homework.
- **The framework cannot break the agent it runs.** Observation, verification and
grading failures degrade the record, never the work. Any code path where a
verifier or grader can fail into an agent's execution is a defect.
- Fail-closed means the *framework* converts panic, error and timeout into
`Fail`. A contract documented in a doc comment and implemented by each author
is not a contract.
- Never let a rubric judge what a verifier can check. Every criterion that can be
made mechanical should be a `Verifier` — deterministic, cheap, and not subject
to judge drift.
## Steps
1. Define `VerifierId`, `VerifierOutcome` (pass/fail plus detail) and the trait
above.
2. Write the framework-side runner that wraps every `verify` call:
- `tokio::time::timeout` at `cx.deadline``Fail`
- `FutureExt::catch_unwind` (or a spawn-and-join boundary) for panics → `Fail`
- any `Err``Fail`
3. Make the runner the only call path. `Verifier::verify` is never invoked
directly from the executor.
4. Record which failure mode produced a `Fail` — timeout, panic and error are the
same outcome but different operational signals.
5. Run verifiers for one attempt as a fan-out/join; they are independent checks.
6. Fault-inject all three modes in tests with real verifiers that panic, hang and
error.
## Acceptance
- Verifiers that panic, hang past deadline, and return an error **all resolve to
`Fail`** — fault-injected, not asserted by comment.
## Verify
**Harness:** three real misbehaving verifiers — one that panics, one that loops
past the deadline, one that returns `Err`. Fault-injected, not mocked.
**Integration test**`tests/it_verifier_fail_closed.rs`:
1. Run a real run with each bad verifier in turn.
2. Assert each resolves to `Fail`.
3. Assert **the run itself completes** and reaches a terminal state — the
framework must not break the agent it runs. A panicking verifier taking the
run down is the primary failure being tested for.
4. Assert the recorded failure mode distinguishes timeout / panic / error, since
they are the same outcome but different operational signals.
5. Assert a verifier that panics does **not** poison shared state: run a second,
healthy verifier afterwards in the same process and assert it returns `Pass`.
6. Fan-out: three verifiers on one attempt, one of them panicking; assert the
other two still return their own results.
7. Assert no code path can produce `Pass` from an `Err` — a `trybuild` case if
`verify` is only reachable through the framework runner.
**Command:** `cargo test -p verify fail_closed`
**False pass:**
- Testing the runner in isolation with a mock verifier rather than in a real run.
Step 3 — the run surviving — only means something end to end.
- Catching the panic at the test boundary with `catch_unwind` in the *test*,
which makes a framework that does not catch it look fine.
- Step 5 omitted: a panic that leaves a mutex poisoned passes steps 14 and
breaks every subsequent verification in the process.
## Traps
- Letting a panic unwind into the executor task. It takes the run with it, which
is the exact inversion the first principle forbids.
- A `Result<VerifierOutcome, E>` signature that pushes the decision onto callers.
One caller will map `Err` to `Pass` by accident.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §1, §10, §11.7 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
@@ -0,0 +1,119 @@
# T4.2 — `VerifierCtx` and the snapshot barrier
| Field | Value |
|---|---|
| Phase | P4 — Verification |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Freeze the episode view on entry to `Verifying`, so state stops moving beneath a
verifier that is mid-flight.
## Facts (inlined — no spec read needed)
```rust
pub struct VerifierCtx {
pub run: RunId,
pub step: StepId,
pub attempt: AttemptNo,
/// Frozen on entry to `Verifying`. Never mutates while a verifier holds it.
pub episode: EpisodeView,
/// Lazy. Verifiers needing no text never pay for it.
pub blobs: Arc<dyn BlobStore>,
pub deadline: Instant,
}
```
- A verifier seeing only identifiers can answer "did it work". Answering "did the
agent have what it needed" requires the **context and the prompt** — so the
view carries the context partition inline and prompt/output by reference.
- **Include failed attempts.** "Retried three times because context was missing
X" is the learning signal; shipping only the winning attempt discards it.
- **What the barrier defends against is not a concurrent retry.** `Verifying` is
entered only when every step is terminal, so no attempt can still be running. A
test built around a concurrent retry passes against no barrier at all. The
three real mutators arrive from **outside** the run's own execution:
1. a **rewind** forking a new `BranchId` while verifiers hold a view of the old
one;
2. a **cancel**, which is legal from `Verifying`;
3. **recovery** resolving a `Dispatched` intent left by an earlier crash, which
writes an outcome into an attempt a verifier is already reading.
- Each is rare enough to be missed in testing and ordinary enough to happen in
production.
- This is why `Verifying` must be a real resting state and not a synchronous
branch (T1.3).
## Steps
1. Define `VerifierCtx` as above. `EpisodeView` is an owned snapshot or an
immutable `Arc` — never a handle that re-reads the store.
2. Build the snapshot at the transition into `Verifying`: capture the branch, the
attempts (including failed ones), refs, partitions, usage, at a fixed LSN.
3. Record the snapshot LSN in the context. A verifier's answer is about a
specific point in the log, and later analysis needs to know which.
4. Ensure the three mutators cannot reach into a held view: rewind writes to a
new `BranchId` (T2.3), cancel changes run state not the snapshot, recovery
appends new records above the snapshot LSN.
5. Pass `blobs` as a handle, not resolved bodies — laziness is T4.3.
6. Write one test per mutator, each firing while a verifier is deliberately
parked mid-`verify`, asserting the view is unchanged.
## Acceptance
- A verifier holding a context sees an unchanged view across each of the three
mutators: a rewind forking a new `BranchId`, a cancel arriving during
`Verifying`, and recovery resolving a `Dispatched` intent into an attempt the
verifier is reading.
- Explicitly **not** a concurrent-retry test — every step is terminal before
`Verifying` is entered, so that test would pass against no barrier at all.
## Verify
**Harness:** a verifier that parks on a channel mid-`verify`, so a mutator can be
fired while it demonstrably holds the context. One test per mutator — they are
different code paths and a single combined test proves the weakest.
**Integration test**`tests/it_snapshot_barrier.rs`:
1. Enter `Verifying`. Park the verifier. Snapshot
`serialize(cx.episode)` from inside it.
2. **Mutator A — rewind:** fork a new `BranchId` from outside the run. Release
the verifier. Assert its view is byte-identical to the snapshot and still
points at the old branch.
3. **Mutator B — cancel:** cancel the run during `Verifying`. Assert the held
view is unchanged and the verifier still completes.
4. **Mutator C — recovery:** have recovery resolve a `Dispatched` intent into an
attempt the verifier is reading. Assert the held view is unchanged, and that
the new outcome **is** visible to a freshly built view.
5. Assert `cx` records the snapshot LSN, and that a view built at that LSN by
cold re-fold equals the held view.
6. Assert failed attempts are present in the snapshot — count them.
**Command:** `cargo test -p verify snapshot_barrier -- --test-threads=1`
**False pass:**
- **A concurrent-retry test.** `Verifying` is entered only when every step is
terminal, so no attempt can still be running — that test passes against no
barrier at all. It is the obvious test to write and it verifies nothing. All
three mutators must come from outside the run's own execution.
- An `EpisodeView` that lazily queries state: the snapshot comparison passes if
the mutator happens to touch a different key. Step 4 targets the same attempt
the verifier is reading, on purpose.
- Firing the mutator before the verifier has actually parked. Use a
rendezvous channel, not a sleep.
## Traps
- An `EpisodeView` that lazily queries materialized state. It looks frozen and
is not.
- Filtering failed attempts out of the view.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §10.1, §10.2 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+85
View File
@@ -0,0 +1,85 @@
# T4.3 — Lazy blob access
| Field | Value |
|---|---|
| Phase | P4 — Verification |
| Size | S — under 1 day |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
A verifier that needs no text performs no blob reads.
## Facts (inlined — no spec read needed)
- Context partitions hold **identifiers** — packed, available-but-not-packed,
dropped — and are small enough to inline. Prompts and outputs are large and go
**by reference**.
- Laziness matters concretely: a verifier that shells out and checks an exit code
needs none of this. Making every verifier carry prompt text penalizes the
common case and blows broker payload limits.
- `VerifierCtx.blobs: Arc<dyn BlobStore>` is the mechanism — the verifier calls
`get` if and only if it wants a body.
- `get` may return `None` after reduction (T8.4); that is a normal answer, and a
verifier must not treat it as an error.
## Steps
1. Confirm `EpisodeView` carries `BlobRef` only. No `prompt_text` field, no
eagerly-resolved cache.
2. Pass the store handle through `VerifierCtx.blobs`.
3. Instrument the store with a call counter behind a test-only wrapper —
counting is what turns this from an intention into an assertion.
4. Write the exit-code verifier used as the acceptance case: it reads the attempt
state and nothing else.
5. Write a second, text-reading verifier and assert its call count is exactly the
number of refs it asked for — no over-fetch, no prefetch-all.
## Acceptance
- An exit-code verifier records **zero** `BlobStore::get` calls.
## Verify
**Harness:** a counting `BlobStore` decorator wrapping the real store, injected
through `VerifierCtx.blobs`.
**Integration test** — `tests/it_lazy_blobs.rs`:
1. Run a real run with an exit-code verifier that reads only attempt state.
2. Assert the counter is **exactly 0**.
3. Run a text-reading verifier that resolves 2 refs. Assert the counter is
**exactly 2** — not more (no prefetch) and not fewer (no silent cache hit
masking a missing read).
4. Assert `EpisodeView` has no field holding blob **bytes** — a compile-level
check if the type is closed, otherwise a serialization size assertion on a run
with a 1 MB prompt: the view must stay small.
5. Reduction interaction: delete a blob body, then run the text verifier; assert
`get` returns `Ok(None)` and the verifier handles it without erroring.
6. Assert the counter is per-run, not global, so parallel tests do not mask each
other.
**Command:** `cargo test -p verify lazy_blobs`
**False pass:**
- Counting at the store level while a caching layer sits above it. The count then
measures cache misses, not verifier behaviour. Wrap the handle the verifier
actually receives.
- Step 4 omitted: a `prompt_text` convenience field makes every verifier pay, and
steps 13 still pass if the resolution happens at view-build time rather than
through `cx.blobs`. The size assertion catches that.
## Traps
- A "convenience" `EpisodeView::prompt_text()` that resolves on access. Every
verifier then pays, invisibly.
- Prefetching all refs when the view is built, which is the same regression one
layer earlier.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §10.1 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+91
View File
@@ -0,0 +1,91 @@
# T4.4 — Retention ordering guard
| Field | Value |
|---|---|
| Phase | P4 — Verification |
| Size | S — under 1 day |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Reduction must not outrun verification or grading. Eligibility is *grading has
terminated*, not *grading succeeded*.
## Facts (inlined — no spec read needed)
- Eligible states for reduction: **`Graded`, `Ungraded`, or `Archived`**.
- **Never a step-level finish timestamp.** A step can finish, be reduced, and
then run-level verification finds nothing left to check.
- `Ungraded` belongs in that set, and the tighter-looking `Graded`-only rule is
the one that gets written. A run that never gets a score — a novel `TaskId`
with no reference, a tournament group that closed without it, a tenant over its
grading ceiling — is **finished**. Gating on `Graded` alone leaves it
irreducible forever.
- The tenants that hit this are the low-volume ones and the cost-capped ones: the
two populations least able to absorb unbounded storage, and the two least
likely to have anyone watching for it.
- `Graded`-only passes every other test in this phase and strands storage only in
production. So the `Ungraded`-is-reducible case must be asserted directly.
## Steps
1. Write `fn is_reducible(run_state) -> bool` matching exactly
`Graded | Ungraded | Archived`. Exhaustive match, no `_` arm.
2. Make reduction (T8.4) call it as a precondition and **reject** — a typed
error naming the current state — rather than skipping silently. A silent skip
is indistinguishable from success and leaves no trace to investigate.
3. Delete any path that reads a step-level finish time for retention decisions.
4. Wire the rejection into metrics so refusals are countable.
5. Test both halves: a run resting in `Grading` is rejected; a run terminal in
`Ungraded` is reduced.
## Acceptance
- A run still resting in `Grading` is **not reducible**, and attempting it is a
**rejected operation** rather than a silent skip.
- A run that reached terminal `Ungraded` **is** reducible — asserted directly.
## Verify
**Harness:** runs parked in each lifecycle state, plus the reduction entry point
from T8.4 (or a stub of it that calls the same guard).
**Integration test** — `tests/it_retention_guard.rs`:
1. Table-driven over **every** run state. For each, call reduce and assert:
- `Graded`, `Ungraded`, `Archived`**permitted**;
- every other state, including `Grading` and `Verifying` → **rejected with a
typed error naming the current state**.
2. Assert the rejection is an `Err`, not `Ok(())`. A silent skip is
indistinguishable from success and leaves nothing to investigate.
3. **Assert `Ungraded` is reducible directly**, with a run that reached
`Ungraded { NoReference }`. This is the case a `Graded`-only implementation
fails, and it is the only assertion that catches it.
4. Assert no code path reads a step-level finish timestamp for retention —
grep, plus a test where a step finished long ago while the run is still
`Verifying`: reduction must be refused.
5. Assert refusals increment a metric, so a stuck reducer is visible.
**Command:** `cargo test -p retention guard`
**False pass:**
- Testing only the `Grading`-is-refused half. `if state == Graded` passes that,
passes every other test in P4, and strands storage in production for exactly
the low-volume and cost-capped tenants least able to absorb it. Step 3 is the
whole point of this task's acceptance criterion.
- A table covering only the states someone remembered. Enumerate the run-state
enum exhaustively with no `_` arm so a new state forces a decision.
## Traps
- `if state == Graded` — passes this phase, strands storage in production.
- Returning `Ok(())` on ineligible runs "because there is nothing to do". Then a
stuck reducer looks healthy.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §5.2, §10.3 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+108
View File
@@ -0,0 +1,108 @@
# T4.5 — P4 composition gate
| Field | Value |
|---|---|
| Phase | P4 — Verification |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | gate |
| Spec | inlined below |
| Blocks | P5 |
## Goal
Prove the verifier port, the snapshot barrier, lazy blob access and the retention
guard hold **simultaneously**, and that any verifier implementation is
interchangeable.
**Phase gate criterion:** fail-closed proven by fault injection.
## Facts (inlined — no spec read needed)
- **The framework cannot break the agent it runs.** Verification failures degrade
the record, never the work. Any path where a verifier can fail into an agent's
execution is a defect — and the composition is where such a path appears, since
no single verifier test runs a full fleet.
- The properties no single P4 task owns:
- a **panicking** verifier must not disturb a **concurrent** healthy verifier's
snapshot, nor the run;
- the barrier must hold while **all three** external mutators fire, including
at once;
- laziness must survive fan-out — one text-reading verifier must not cause the
exit-code verifiers beside it to fetch anything;
- retention must refuse while **any** verifier is outstanding, which is the
`Verifying` case T4.4 checks and the composition makes real.
- Verification decides, grading explains. A gate that lets a verifier outcome be
influenced by grading state has collapsed the two.
## Steps
1. Assemble a verifier fleet on one attempt: exit-code, text-reading, panicking,
hanging, erroring — five implementations, one fan-out.
2. Fire all three snapshot mutators concurrently while the fleet is mid-flight.
3. Assert every fail-closed outcome, every unaffected outcome, the blob call
count, and the retention refusal, in one test.
4. Swap the fleet for a completely different set of verifier implementations;
assert the run's outcome shape is unchanged.
5. Make this the required CI job gating P5.
## Acceptance
- With the five-verifier fleet on one attempt: the three bad verifiers resolve to
`Fail`, the two good ones return their own results, and **the run completes**.
- The snapshot holds across all three mutators fired concurrently.
- Exit-code verifiers record **zero** blob reads even while a sibling reads text.
- Reduction is refused for the whole duration of `Verifying`.
## Verify
**Harness:** five verifier implementations; a counting `BlobStore` wrapper per
verifier, not one shared counter; rendezvous channels so mutators fire only once
every verifier has demonstrably parked.
**Integration test** — `tests/it_p4_composition.rs`:
1. **Fleet fail-closed:** assert panic → `Fail`, hang → `Fail`, error → `Fail`,
and that the exit-code and text verifiers return their **own** correct
results. A fleet where one bad verifier poisons the others is the failure.
2. **Run survives:** assert the run reaches a terminal state and that agent work
was never blocked — compare run duration against a no-verifier baseline.
3. **Concurrent mutators:** with the fleet parked, fire a rewind, a cancel and an
intent recovery **at once**. Assert every held `EpisodeView` is byte-identical
to its entry snapshot.
4. **Per-verifier laziness:** assert the exit-code verifiers' counters are
**exactly 0** while the text verifier's is exactly the number of refs it
requested. A single shared counter hides this.
5. **Retention interlock:** attempt reduction at three points — during
`Verifying`, after `Verifying` but during `Grading`, and after terminal
`Ungraded`. Assert refuse, refuse, permit.
6. **Verifier interchange:** re-run the entire test with a second, unrelated set
of five verifier implementations. Assert identical run-level outcomes.
7. **Isolation after panic:** run a healthy verifier in the same process **after**
the panicking one; assert `Pass`. A poisoned mutex passes every earlier step.
8. **Regression:** re-run P0P3 gates and all P4 suites.
**Command:** `cargo test -p verify --test it_p4_composition -- --test-threads=1`
**False pass:**
- Running the five verifiers **sequentially**. Fan-out is the declared
concurrency shape, and cross-contamination between verifiers only exists
concurrently.
- Step 3 firing the mutators one at a time. Each is already covered by T4.2; the
gate is about them arriving together.
- One shared blob counter in step 4 — the sibling's reads mask the exit-code
verifiers' zero.
- Step 5 testing only the `Grading` refusal, leaving the `Ungraded`-is-permitted
half untested. That is the half that strands storage in production.
## Traps
- Catching the verifier panic in the test rather than asserting the framework
catches it.
- Asserting the run "did not error" instead of asserting it reached a terminal
state with a complete log.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §1, §10 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+90
View File
@@ -0,0 +1,90 @@
# T5.1 — `TaskId` at spawn
| Field | Value |
|---|---|
| Phase | P5 — Grading |
| Size | S — under 1 day |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | T5.5 onward. Cannot be retrofitted |
## Goal
Every run carries a `TaskId` from spawn. Required, no default, no derivation from
`RunId`.
## Facts (inlined — no spec read needed)
- `TaskId` is the **comparison-group key**: a content hash of the task input
**before any workflow touches it**.
- Hashing the prompt does not work — the workflow changes the prompt by
construction, which is the entire point of a variant.
- **`TaskId` cannot be backfilled.** A run recorded without one is permanently
ungroupable and useless to the learning loop. Hence: required at spawn, no
`Default`, no `From<RunId>` (T0.1).
- **Land this during P1**, even though nothing consumes it until P5. It is a
small task deep in P5 and it is the one item on the critical path that a later
phase cannot repair.
- What defines `TaskId` for a given user is open — ticket id, input fixture, or a
hash of the pre-workflow goal. The framework provides the type and a default
hasher and lets it be overridden. Settle it before any run is recorded.
## Steps
1. Add `task: TaskId` as a **required positional field** of the spawn parameters.
Not `Option`, not a builder method that can be omitted.
2. Provide a default hasher over the pre-workflow task input, and a hook to
override it. Document that the choice is permanent for existing data.
3. Record `TaskId` on the run's spawn event so it lands in the log, not only in
materialized state.
4. Expose it on `EpisodeView` for the grading path.
5. Add the `trybuild` compile-fail case: a spawn call omitting `TaskId`.
## Acceptance
- **Spawning without a `TaskId` fails to compile.**
## Verify
**Harness:** `trybuild`, plus a full run to prove the id reaches storage.
**Integration test** — `tests/it_taskid_required.rs`:
1. `trybuild` compile-fail: a spawn call omitting `TaskId`. Assert the expected
stderr **names the missing field**, not merely "does not compile".
2. Run a real run; read the **log** (not materialized state) and assert the spawn
event carries the `TaskId`.
3. Assert `EpisodeView` exposes it, so the grading path can group on it.
4. Determinism: hash the same task input twice, in two processes; assert equal
`TaskId`.
5. Independence: assert two runs of the **same** task input share a `TaskId`, and
that two different inputs do not. Both directions.
6. Assert the `TaskId` is **not** derived from the prompt: change the workflow
version (which changes the prompt) and assert the `TaskId` is unchanged for
the same input.
7. `trybuild`: `TaskId::default()` and `TaskId::from(run_id)` both fail.
**Command:** `cargo test -p kernel taskid && cargo test -p kernel --test compile_fail`
**False pass:**
- Step 2 read from materialized state. If the id lives only in state it cannot be
re-derived after a cold fold, and it cannot be backfilled — read the log.
- Step 6 omitted: hashing the prompt passes steps 15 completely and makes every
variant its own group, which is the exact failure that renders the learning
loop inert.
- A builder with a runtime `expect("task_id required")`. It passes a test that
checks for a panic and still allows runs to be recorded without one in any code
path the test did not cover.
## Traps
- A builder with `.task_id(...)` optional and a runtime check. Runs recorded
before someone notices are unrecoverable.
- Deriving it from the prompt or from `RunId`. Both compile; both make every run
its own group of one.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §3, §11.5, §18 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+113
View File
@@ -0,0 +1,113 @@
# T5.10 — Attempt tournaments
| Field | Value |
|---|---|
| Phase | P5 — Grading |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Grade the attempts of one step as a comparison group. On by default, even though
T5.8/T5.9 are not, because it costs no extra agent runs.
## Facts (inlined — no spec read needed)
Where comparison groups come from:
| Source | Group | Cost | Needs |
|---|---|---|---|
| **Attempts** | attempts 1..N of one step, identical context | free, already recorded | nothing |
| **Recurring tasks** | runs sharing a `TaskId` over time | free, slow to fill | `TaskId` |
| **Replay** | one task re-executed under N variants | N full runs | blobs, sandbox |
- **Attempt tournaments first.** Retries are already on disk and attempt N+1
never mutates attempt N, so the attempts of one step are a group on identical
context — the cheapest per-step credit signal available, and the **only** source
of per-step credit the system has.
- **Two paths, not one:**
- **Same-outcome attempts** go to the judge. The question there is "which
failure got further" — which no verifier can answer.
- **A fail→pass pair is never judged.** The verifier has already ordered it;
asking a judge which is better asks it to re-decide what verification
decided. The pair is consumed **structurally**: what differed between attempt
N and N+1 — context partition, tool selection, prompt — is attributed to the
`StepId` as the change that turned a fail into a pass.
- The bracketing rule has **no exemptions**. If a pairing crosses an outcome
class, it is evidence for attribution, never input to a judge. This is the
place the rule is easiest to break, because the two attempts sit side by side
on disk and look like a free comparison.
- Per-step credit attributes to `StepId`, which is why the `StepId` stability
contract (T3.4) is load-bearing rather than cosmetic.
## Steps
1. Build the group: all attempts of one `(BranchKey, StepId)`, partitioned by
verifier outcome class.
2. **Route by class before any judge call.** Same-class subsets go to the
comparison path; cross-class pairs go to the attribution path. Make this a
single branch point so it cannot be bypassed.
3. Comparison path: hand the same-class subset to the configured strategy.
4. Attribution path: diff attempt N against N+1 across context partition, tool
selection and prompt ref; emit the delta attributed to `StepId`. No judge call.
5. Persist per-step credit keyed by `(TenantId, WorkflowVersion, StepId)`.
6. Instrument the `Judge::compare` counter and assert **zero** cross-class calls —
count calls rather than inspecting pairs, since a pair-inspection test passes
against an implementation that builds the pair and then filters it late.
## Acceptance
- A run with three attempts produces per-step credit attributed to `StepId`.
- A group containing both a failed and a passed attempt issues **zero
`Judge::compare` calls across the outcome boundary** — asserted by counting
calls, not by inspecting pairs.
## Verify
**Harness:** a run with three attempts, plus a counting mock judge. The counter
is the instrument — pair inspection is not.
**Integration test** — `tests/it_attempt_tournament.rs`:
1. Three attempts on one step, **all failing**. Assert `compare` is called on the
same-outcome subset and per-step credit is attributed to the `StepId`.
2. **Mixed group — the guard:** attempts 1 and 2 fail, attempt 3 passes. Assert
**zero** `Judge::compare` calls cross the outcome boundary, counted at the
judge. Assert the fail→pass pair produced a **structural attribution** record
instead.
3. Assert the attribution names the actual delta — plant a context-partition
difference between attempts 2 and 3 and assert it appears in the record.
4. Assert credit is keyed `(TenantId, WorkflowVersion, StepId)` and survives a
workflow version bump that retains the `StepId` (T3.4).
5. Assert this path runs **with the tournament flags off** — it is on by default
while T5.8/T5.9 are not.
6. Zero-cost assertion: no agent runs are spawned by grading; assert the run
counter is unchanged.
7. Single-attempt step: assert no comparison and no spurious credit record.
**Command:** `cargo test -p grading attempt_tournament`
**False pass:**
- **Inspecting the pair list rather than counting judge calls.** An
implementation that builds the cross-class pair and filters it just before
dispatch passes pair inspection and still sends it under a later refactor.
Count at the judge boundary.
- Step 2 written with all-failing attempts, where no boundary exists to cross.
The mixed group is the whole test.
- Step 3 omitted: emitting an empty attribution record satisfies "credit
produced" while carrying no signal.
## Traps
- Pairing the failure with its successful retry. It is free, it looks like
signal, and it makes the judge re-decide what the verifier already decided.
- Attributing credit to step index rather than `StepId`, which breaks at the next
workflow version.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §11.4, §11.5, §11.7, §11.8 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+138
View File
@@ -0,0 +1,138 @@
# T5.11 — Degradation reasons
| Field | Value |
|---|---|
| Phase | P5 — Grading |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
When no comparison is possible, emit `Score::Ungraded { reason }` — a stated
reason, never a neutral score — and reach terminal `Ungraded`.
## Facts (inlined — no spec read needed)
Three reasons, each a distinct condition:
1. **`NoReference`** — a novel `TaskId`. Nothing else has done this task, so
there is nothing to compare against. That is not a degradation to engineer
around; it is the first observation of a new task. Verifier outcome and
deterministic dimensions (cost, latency, tool efficiency) are still recorded,
and **the episode is retained as the reference for that `TaskId`** — so the
second run on that task grades normally.
2. **`BudgetExhausted`** — the tenant is over its grading ceiling. Not a quality
signal; a spend signal.
3. **`InsufficientGroup`** — **tournament only.** A group closes on quorum *or*
on a timeout, grading whatever arrived, with group size attached to the
confidence interval.
- **A closed group is immutable, and the next episode starts a new one.** A group
closed on timeout at G = 3, then a fourth episode with the same `TaskId`
arrives an hour later: re-opening and re-fitting is the wrong answer, because
strengths from that group have already been published, aggregated and possibly
acted on. A fit that silently changes underneath a decision already made is
worse than a small group.
- So the group key carries a generation: `(TenantId, TaskId, VerifierOutcome,
GroupEpoch)`. **Closure increments the epoch**; late arrivals accumulate into
the next one.
- The cost is honest and should be stated rather than discovered: a low-volume
tenant with a long inter-arrival time gets a run of G = 1 epochs, each
`InsufficientGroup`. That is a real signal about their volume; the fix is a
longer timeout, which is a tenant-level tradeoff between waiting and grading.
- **A tenant whose loop never engages must see that as a stated reason.** Silent
no-op is the worst outcome: it looks like a working loop that finds no
improvements.
- Every one of these runs reaches terminal `Ungraded` and is therefore reducible
(T4.4). Without that, they rest in `Grading` forever and strand storage.
- Group timeout default is unsettled — it trades grading latency against group
size and depends on tenant arrival rate. Only bites when the tournament is on.
## Steps
1. Define `UngradedReason { NoReference, BudgetExhausted, InsufficientGroup { group_size } }`.
2. `NoReference` path: on a novel `TaskId`, emit `Ungraded`, record the
deterministic dimensions, and **register the episode as that task's
reference**. The registration is the half that is easy to omit.
3. `BudgetExhausted` path: check the tenant ceiling at the group boundary, before
any model call. Emit the reason; never a default score.
4. `InsufficientGroup` path (tournament only): implement quorum-or-timeout
closure, attach `group_size`, and **increment `GroupEpoch` on close**.
5. Route a late arrival into epoch N+1. Epoch N's stored strengths are never
recomputed.
6. Transition the run to terminal `Ungraded` in every case, carrying the reason.
7. Emit `Ungraded` counts **by reason** as a metric, separately from `Graded`.
## Acceptance
- A novel `TaskId` yields `NoReference` **and** the episode is retained as that
task's reference, so the **second** run on the same task produces `Relative`
assert the second run, since retaining the reference is the half easily omitted.
- A tenant over its ceiling yields `BudgetExhausted`, never a default score.
- Tournament: a group that never fills closes on timeout with size recorded; a
late episode lands in epoch N+1 leaving epoch N's fitted strengths
**byte-identical** — assert on the stored strengths, not on the absence of a
re-fit call.
- In every case the run reaches terminal `Ungraded` and is therefore reducible
(T4.4).
## Phase gate
P5 closes when `PairwiseSequential` decides an accept and a reject against a mock
judge, on one resident model with zero swaps.
## Verify
**Harness:** a fresh tenant with no history (for `NoReference`), a tenant with a
zero grading ceiling (for `BudgetExhausted`), and a tournament group that never
fills (for `InsufficientGroup`).
**Integration test** — `tests/it_degradation_reasons.rs`:
1. **Novel `TaskId`:** assert `Score::Ungraded { NoReference }`, and that
deterministic dimensions (cost, latency) are still recorded.
2. **The second half, which is the one that gets omitted:** run a **second**
episode on the same `TaskId`; assert it produces `Score::Relative`. That only
works if run 1's episode was retained as the reference.
3. **Budget:** tenant over its grading ceiling → `BudgetExhausted`. Assert
**zero** model calls were made — the check must precede the spend, not follow
it. Assert no default score anywhere in the output.
4. **Tournament group:** a group that never reaches quorum closes on timeout.
Assert `InsufficientGroup { group_size }` with the real size recorded.
5. **Epoch immutability:** snapshot epoch N's fitted strengths. Deliver a late
episode on the same `TaskId`. Assert it lands in epoch **N+1** and that epoch
N's **stored strengths are byte-identical** to the snapshot.
6. **Terminal state:** for every one of the three reasons, assert the run reaches
terminal `Ungraded` and that T4.4's `is_reducible` returns true.
7. Assert `Ungraded` counts are emitted **by reason** as separate metric series.
**Command:** `cargo test -p grading degradation`
**False pass:**
- Step 5 asserting "no re-fit call was made". A refactor that recomputes lazily
on read passes that and still changes a published number. Assert on the
**stored strengths**.
- Step 2 omitted: emitting `NoReference` is trivial; **retaining the reference**
is the half that makes the loop ever engage, and nothing else detects its
absence.
- Step 6 omitted: a run left in `Grading` looks correct in the score output and
strands storage forever for exactly the tenants this section exists to
accommodate.
- Step 3 checking only the reason. If the ceiling is enforced after the calls,
the reason is right and the money is gone.
## Traps
- Emitting a neutral 0.5 for an ungradable episode. It averages into promotion
gates and looks like data.
- Re-opening a closed group for a late arrival.
- Leaving the run in `Grading`, which strands storage for exactly the low-volume
and cost-capped tenants least able to absorb it.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §5.2, §11.6, §14, §15, §18 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+120
View File
@@ -0,0 +1,120 @@
# T5.12 — P5 composition gate
| Field | Value |
|---|---|
| Phase | P5 — Grading |
| Size | L — over 3 days |
| Status | Not started |
| Flags | gate |
| Spec | inlined below |
| Blocks | P6 |
## Goal
Prove the grading strategies are **interchangeable**: the same recorded episodes,
graded under any strategy, flow through the same downstream pipeline unchanged.
**Phase gate criterion:** `PairwiseSequential` decides an accept and a reject
against a mock judge, on one resident model with zero swaps.
## Facts (inlined — no spec read needed)
- **Defaults ship working; every default is a port.** Grading is
strategy-pluggable, and the strategy declares what hardware it needs before it
is allowed to run. That claim is only proven by running **every** strategy
through the same pipeline on the same input.
- The four strategies and their declared costs per episode, on a group of eight:
| Strategy | Calls / episode | Models resident | Produces | Default |
|---|---|---|---|---|
| `DeterministicGrader` | 0 | 0 | `Ranked` | — |
| **`PairwiseSequential`** | **12** | **1, the agent's** | `Relative` | **yes** |
| `TournamentGrader` | 35 | 1 | `Ranked` | opt-in |
| `ReplayTournament` | 35 + N agent runs | 1 | `Ranked` | opt-in |
- The properties no single P5 task owns:
- every strategy's output flows into retention (T4.4), metering and the run
lifecycle **identically**`Ranked`, `Relative`, `Capped` and `Ungraded` all
reach a terminal run state;
- the declared `calls_per_episode` **matches measured** calls, or every
capacity and spend projection built on it is fiction;
- **zero model swaps** hold across the whole phase, not just per strategy;
- the outcome-class bracketing rule has **no exemptions** — assert it once per
strategy, since each has its own pairing path.
- `opt-in` strategies ship **disabled** and gate nothing. This gate must pass
with them off, and separately with them on.
## Steps
1. Fix one corpus of recorded episodes covering: same-outcome groups,
mixed-outcome groups, a novel `TaskId`, a capped episode, and a
budget-exhausted tenant.
2. Grade the corpus under each of the four strategies.
3. Assert the downstream pipeline — score handling, run terminal state, retention
eligibility, metering attribution — is identical in shape across all four.
4. Assert declared-versus-measured cost for each strategy.
5. Run the whole gate twice: opt-in flags **off**, then **on**.
6. Make this the required CI job gating P6.
## Acceptance
- All four strategies grade the same corpus; every episode reaches a terminal run
state with retention eligibility correct under each.
- `PairwiseSequential` decides an **accept** and a **reject** against mock judges.
- **Zero model swaps** across the entire gate.
- Measured `compare` calls per episode fall within each strategy's declared
`calls_per_episode`.
- The gate passes with opt-in flags off **and** on.
## Verify
**Harness:** one committed episode corpus; mock judges (70% winner, 50/50,
all-draws, position-biased, one `CoreViolation`); counters for `compare` calls,
model **loads**, and agent runs, all separate.
**Integration test** — `tests/it_p5_composition.rs`:
1. **Strategy interchange:** parameterize over all four strategies. For each,
assert every episode ends in a terminal run state, and that
`is_reducible` (T4.4) agrees with that state. A strategy leaving runs in
`Grading` fails here regardless of its scores.
2. **Cost truth:** assert measured `compare` calls per episode fall inside the
declared `calls_per_episode` band. A declaration that does not match
measurement makes every capacity and spend projection downstream wrong.
3. **Zero swaps:** assert the swap counter is **exactly 0** across the whole
parameterized run, not per strategy.
4. **Bracketing, per strategy:** feed the mixed-outcome group to each strategy;
assert **zero** cross-class `compare` calls in every one. Each strategy has
its own pairing path and the rule has no exemptions.
5. **Score-type handling:** assert `Capped` and `Ungraded` episodes are handled
identically by every strategy's caller — same terminal state, excluded from
aggregates, no numeric coercion anywhere (cross-check T5.3's compile-fail).
6. **Accept and reject:** with the 70% judge assert `Accept`; with the 50/50
judge assert `Reject`. Assert both are reached in fewer comparisons than
fixed-n.
7. **Flag matrix:** run steps 16 with opt-in off (tournament paths must be
unreachable) and on.
8. **Regression:** re-run P0P4 gates and all P5 suites.
**Command:** `cargo test -p grading --test it_p5_composition -- --nocapture`
**False pass:**
- Testing each strategy against its own tailored corpus. The claim is
interchangeability, which only means something on **one** shared input.
- Step 2 omitted: `calls_per_episode` is a declared number nobody measures, and
admission control, capacity planning and spend projection all consume it.
- Step 3 counted per strategy rather than across the run — a swap between two
strategies' evaluations is exactly the failure the one-resident-model default
exists to prevent.
- Step 1 asserting scores only. A strategy that produces correct scores and
leaves runs resting in `Grading` strands storage forever.
## Traps
- Enabling the opt-in flags to make the gate pass. They ship disabled and gate
nothing; the off-run is the one that must be green.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §11, §14.2 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
@@ -0,0 +1,152 @@
# T5.2 — `CapacityLimits` and the residency invariant
| Field | Value |
|---|---|
| Phase | P5 — Grading |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | T5.3 |
## Goal
Declared VRAM limits, the residency invariant, and a `max_context_tokens` that is
**derived** from them rather than configured beside them.
## Facts (inlined — no spec read needed)
```rust
pub struct CapacityLimits {
/// Per-device VRAM this framework may use. Not the card's total — leave
/// headroom for anything else sharing the device.
pub vram_bytes_per_device: u64,
pub devices: u32,
/// Hard cap on simultaneously resident models across all devices.
pub max_resident_models: u32,
/// Never evictable. The agent's model belongs here; if it can be evicted,
/// grading can stall agent work, which the first principle forbids.
pub pinned: Vec<ModelId>,
}
pub struct ModelProfile {
pub id: ModelId,
pub weights_bytes: u64,
/// KV cache cost per token at the deployed dtype and parallelism.
pub kv_bytes_per_token: u64,
/// Devices this model spans under tensor parallelism.
pub devices_required: u32,
}
```
Invariant, checked at load **and** before any admission:
```
sum(weights of resident models) + peak_concurrent_kv ≤ vram_bytes_per_device × devices
resident_model_count ≤ max_resident_models
```
Derived ceiling — never a separate config knob:
```
max_context_tokens = (vram_bytes_per_device × devices sum(weights)) / kv_bytes_per_token
```
- Metering counts tokens after the fact; on self-hosted weights the binding limit
arrives earlier and harder. A model that is not resident cannot be inferred
against, and making it resident means evicting something else and paying a load
measured in tens of seconds.
- **One resident model is the default configuration.** The agent's model is
pinned; the judge and the proposer run on that same model. A strategy naming a
second model is legal and is rejected at load unless the invariant holds with
both resident — **never** by swapping between them per call.
- The worked case, because the result is not marginal: at GQA fp16, per-token KV
runs roughly 0.13 MB for an 8B-class model and 0.33 MB for a 70B-class one. Two
episodes at a 200k-token retention ceiling is 400k tokens of context —
**52 GB of KV cache at 8B, 131 GB at 70B**, before weights. Neither fits an
80 GB device. A judge reading two full-ceiling episodes is not expensive, it is
impossible.
- Therefore the retention ceiling is derived from this, not set beside it: the
reduction target is `max_context_tokens / 2`, and where that is smaller than
the configured ceiling, **the bound wins**.
- Distributed GPUs change the arithmetic, not the rule. `devices_required`
expresses tensor parallelism; `max_resident_models` is a fleet-wide count, so
two nodes each holding the agent model are two resident instances, not one.
- `kv_bytes_per_token` varies with dtype, quantization, attention implementation
and parallelism. It is measured per deployment or read from an operator-supplied
profile — **the framework refuses a guess.**
## Steps
1. Define `CapacityLimits` and `ModelProfile` as above. No `Default` for
`kv_bytes_per_token` — an absent value is a load error, not a guess.
2. Write `check_residency(&CapacityLimits, &[ModelProfile]) -> Result<(), CapacityError>`
implementing both inequalities. `CapacityError` names the model and the
shortfall in bytes.
3. Write `derive_max_context_tokens(...)` from the formula above. Expose it as a
function, not a settable field.
4. Call the check at load, and again before admission (T8.6 enforces the runtime
half).
5. Refuse a context request over the derived ceiling with the computed limit in
the error message.
6. Encode the worked case as a test: 70B-class profile, 0.33 MB/token, 80 GB
device, two 200k-token episodes → refused.
## Acceptance
- A config whose resident set exceeds VRAM is rejected **at load**, naming the
model and the shortfall — not at first inference.
- A judge context request exceeding the derived ceiling is refused with the
computed limit in the error.
- The worked case asserts the arithmetic: 70B-class, 0.33 MB/token, 80 GB device,
two 200k-token episodes must be refused.
## Verify
**Harness:** pure arithmetic — no GPU required. Model profiles as fixtures with
declared `weights_bytes` and `kv_bytes_per_token`.
**Integration test** — `tests/it_capacity_invariant.rs`:
1. **The worked case, hardcoded:** 70B-class profile, `kv_bytes_per_token =
0.33 MB`, one 80 GB device. Two 200k-token episodes = 400k tokens = 131 GB of
KV before weights. Assert the request is **refused** and that the error
carries the computed limit.
2. Same arithmetic at 8B / 0.13 MB per token: 52 GB. Assert also refused on an
80 GB device once weights are counted.
3. Over-capacity config at load: resident set exceeding VRAM → rejected **at
load**, error naming **the model and the byte shortfall**. Assert the message
contains both.
4. Assert `max_context_tokens` is only reachable as a **function**, not a
settable field — `trybuild` if it is a private field with no setter.
5. Multi-device: `devices_required = 4` under tensor parallelism; assert the
invariant uses `vram × devices` and that `max_resident_models` is counted
fleet-wide, so the same model on two nodes counts as two.
6. Missing `kv_bytes_per_token` → load error, **not** a default. Assert no
`Default` impl exists.
7. Pinned model: assert no code path can evict a pinned model to satisfy the
invariant — the eviction candidate list excludes pinned entries.
**Command:** `cargo test -p capacity invariant`
**False pass:**
- Asserting only that "some error" is returned. The acceptance criterion is that
the **arithmetic** is right — pin the expected byte figures in the test, so an
off-by-a-factor error in the KV formula is caught rather than rounded away.
- Step 3 checked at first inference rather than at load. Both produce an error;
only one produces it before 3am.
- Testing with a permissive config where everything fits. Every implementation
passes that.
## Traps
- A `max_context_tokens` config field. It will be set to something plausible and
discovered wrong at the first judge call.
- Treating "call the judge model" as equivalent in cost to "call the agent
model". On this hardware that is wrong by two orders of magnitude.
- Allowing an eviction of a pinned model to satisfy the invariant.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §8.6, §14.2, §18 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
@@ -0,0 +1,158 @@
# T5.3 — `EvaluationStrategy` port + `ResourceProfile`
| Field | Value |
|---|---|
| Phase | P5 — Grading |
| Size | S — under 1 day |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | T5.4, T5.5 |
## Goal
The grading ports, and the type discipline that stops a capped or ungraded
episode from being averaged into a promotion gate.
## Facts (inlined — no spec read needed)
```rust
#[async_trait]
pub trait EvaluationStrategy: Send + Sync {
fn id(&self) -> StrategyId;
/// Declared before any work is admitted. Validated against capacity limits
/// at load; a strategy whose profile does not fit is rejected by name.
fn resources(&self) -> ResourceProfile;
async fn evaluate(&self, cx: &EvalCtx) -> Result<Vec<Score>>;
}
pub struct ResourceProfile {
/// Models this strategy calls. One entry means it runs on the agent's
/// already-resident model and forces no swap.
pub models: Vec<ModelId>,
/// Largest single-call context. A pairwise judge reads two episodes, so this
/// is roughly twice an episode budget.
pub max_context_tokens: u32,
/// Model calls per episode evaluated, for capacity and spend projection.
pub calls_per_episode: f32,
}
#[async_trait]
pub trait Grader: Send + Sync {
async fn grade(&self, group: &Group, rubric: &RubricDef) -> Result<Vec<Score>>;
}
#[async_trait]
pub trait Judge: Send + Sync {
/// Relative comparison only. Deliberately cannot return an absolute score.
async fn compare(&self, a: &EpisodeView, b: &EpisodeView, r: &RubricDef)
-> Result<Verdict>;
/// Unary, separate from `compare`: a `Core` violation caps an episode on its
/// own terms, not relative to an opponent. Runs before pairing.
async fn screen(&self, e: &EpisodeView, r: &RubricDef) -> Result<Vec<CoreViolation>>;
}
pub enum Verdict { A, B, Draw }
pub struct CoreViolation { pub criterion: RubricCriterionId, pub evidence: BlobRef }
pub enum Score {
Relative { against: RunId, verdict: Verdict, record: WinRecord },
Ranked { strength: f64, interval: (f64, f64), group_size: u32 },
Capped { violations: Vec<CoreViolation> },
Ungraded { reason: UngradedReason },
}
```
- **`compare` returns `Verdict`, never a number.** Absolute scores fail three
ways here: calibration drift (same episode scores differently across weeks and
model versions, and drift is indistinguishable from a variant trend);
weak discrimination (four competent episodes all score 0.8); and saturation —
as workflows improve, pass rate approaches 100% and absolute rubric scores
saturate identically. A tournament cannot saturate; better candidates just make
it harder.
- **`Score` is a sum, not a number with flags.** A capped episode and an ungraded
one are not low scores; they are different kinds of answer. A type that can
represent them as numbers will eventually have them averaged into a promotion
gate by code that meant no harm.
- **The strategy declares what hardware it needs before it is allowed to run.**
Strategies differ by more than an order of magnitude in model calls and VRAM; a
deployment that cannot afford one must be told at load, not by an OOM at 3am.
Strategy catalogue — cost per episode evaluated, group of eight:
| Strategy | Calls / episode | Models resident | Produces | Default |
|---|---|---|---|---|
| `DeterministicGrader` | 0 | 0 | `Ranked` on a computed number | — |
| **`PairwiseSequential`** | **12** | **1, the agent's** | `Relative` | **yes** |
| `TournamentGrader` | 35 | 1 | `Ranked` with intervals | opt-in |
| `ReplayTournament` | 35 **plus N full agent runs** | 1 | `Ranked` across variants | opt-in |
## Steps
1. Define all types above verbatim. `Verdict` has exactly three variants.
2. Give `Score` **no** `Into<f64>`, no `as_number()`, no `unwrap_or(0.0)`
convenience. Aggregation code must match on the variant.
3. Keep `screen` separate and unary. It is the only source of `CoreViolation`,
and it runs before pairing.
4. At strategy registration, call T5.2's residency check against
`resources().models` plus the pinned set. Reject by strategy **name** with the
shortfall.
5. Validate `resources().max_context_tokens` against T5.2's derived ceiling at
the same point.
6. Write the two `trybuild` compile-fail cases described in acceptance.
## Acceptance
- Compile-fail test: a judge cannot return `f64`.
- Compile-fail test: `Score::Capped` and `Score::Ungraded` cannot be coerced to a
number — no `Into<f64>`, no `unwrap_or(0.0)` path through the aggregate.
- A strategy whose `ResourceProfile` names a second model is rejected at load
when the residency invariant (T5.2) does not admit both.
## Verify
**Harness:** `trybuild` for the type discipline; T5.2's capacity checker for the
load-time rejection.
**Integration test** — `tests/it_grading_ports.rs` + `tests/compile_fail/`:
1. **Compile-fail:** a `Judge` impl whose `compare` returns `f64`. Assert the
stderr names the `Verdict` return type.
2. **Compile-fail:** `Into::<f64>::into(Score::Capped { .. })`,
`score.unwrap_or(0.0)`, and any `as_number()` call. One case each — a single
case leaves the other routes open.
3. **Audit test:** enumerate every aggregation site and assert each matches on
`Score` variants exhaustively with no `_` arm.
4. Load-time rejection: a strategy whose `ResourceProfile` names a **second**
model, under a `CapacityLimits` that admits only one. Assert rejection **by
strategy name**, at registration, before any evaluation runs.
5. Positive: the same strategy under limits that admit both models is accepted —
otherwise the check is just "reject two models", which is the wrong rule.
6. Context check: a strategy declaring `max_context_tokens` above T5.2's derived
ceiling is rejected with the computed limit in the error.
7. `screen` separation: assert `CoreViolation` can only originate from `screen`
grep plus a test that a `compare` result cannot construct one.
**Command:** `cargo test -p grading ports && cargo test -p grading --test compile_fail`
**False pass:**
- One compile-fail case for `f64`. The leak that matters is on `Score`, not on
`Verdict` — a capped episode reaching an average is the failure this type
discipline exists to prevent, and step 2 is where it is caught.
- Step 5 omitted, so a naive "more than one model is illegal" implementation
passes and blocks a legitimate two-resident-model deployment.
- Step 3 omitted: the types can be sound while one aggregation site does
`if let Some(n) = ...` and silently skips capped episodes instead of failing.
## Traps
- A `Score::value() -> Option<f64>` helper. Every call site then writes
`.unwrap_or(0.0)` and the cap becomes a zero.
- Folding `screen` into `compare`. A judge asked to express a `Core` violation
through a comparison can only rank the offender lower, which is not a cap.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §11.1, §11.2, §14.2 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+88
View File
@@ -0,0 +1,88 @@
# T5.4 — `DeterministicGrader`
| Field | Value |
|---|---|
| Phase | P5 — Grading |
| Size | S — under 1 day |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Grade on a number the user already computes — latency, cost, test pass count —
with zero model calls.
## Facts (inlined — no spec read needed)
- It exists so that **adopting the framework does not require adopting
LLM-as-judge at all.**
- Cost profile: 0 model calls per episode, 0 models resident, produces `Ranked`
on a computed number.
- Its `ResourceProfile` declares an **empty** `models` vector. That is the whole
point — it forces no residency and passes the capacity check trivially.
- It still produces `Score::Ranked`, so the aggregation path downstream is
unchanged.
- Never let a rubric judge what a verifier can check: criteria that can be made
mechanical belong here or in a `Verifier`, not in a rubric line.
## Steps
1. Implement `EvaluationStrategy` with `resources()` returning
`ResourceProfile { models: vec![], max_context_tokens: 0, calls_per_episode: 0.0 }`.
2. Define the metric extractor as a user-supplied function over `EpisodeView`
the framework supplies latency, cost and usage; the user supplies anything
domain-specific such as test pass count.
3. Rank within the group by the computed number and emit `Score::Ranked` with
`group_size`.
4. Ensure the interval is meaningful or explicitly degenerate — do not fabricate
a confidence interval around a deterministic measurement.
5. Test with a workflow graded purely on test-pass-count and latency; assert the
model-call counter is exactly zero.
## Acceptance
- A workflow graded purely on test-pass-count and latency, **no model calls**.
- Its `ResourceProfile` declares **zero models**.
## Verify
**Harness:** a model-call counter installed at the provider boundary, counting
**all** purposes.
**Integration test** — `tests/it_deterministic_grader.rs`:
1. Run a workflow graded on test-pass-count and latency.
2. Assert the model-call counter is **exactly 0** across the whole run's grading
phase — including any tiebreak path.
3. Assert `resources().models.is_empty()` and `calls_per_episode == 0.0`.
4. Assert registration passes the capacity check under a `CapacityLimits` with
**zero** devices — nothing about this strategy should need a GPU.
5. Determinism: grade the same group twice; assert identical `Score::Ranked`
values including ordering of ties.
6. Assert the emitted score is `Ranked`, so downstream aggregation is unchanged
from the tournament path.
7. Assert no fabricated confidence interval — either a real one or an explicitly
degenerate one, asserted as such.
**Command:** `cargo test -p grading deterministic`
**False pass:**
- Counting only judge-purpose model calls. A tiebreak issued under
`Purpose::Work` would slip through — count all purposes.
- Step 4 omitted: declaring the agent's model in `models` "since it is resident
anyway" passes steps 13, then fails a capacity check it should pass and
misreports the spend projection.
- Step 5 with a group of one, where ordering is trivially stable.
## Traps
- Declaring the agent's model in `models` "since it is resident anyway". It then
fails a capacity check it should pass, and it lies in the spend projection.
- Calling a model for a tiebreak.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §11.1, §11.7 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
@@ -0,0 +1,117 @@
# T5.5 — `PairwiseSequential`: reference and comparison
| Field | Value |
|---|---|
| Phase | P5 — Grading |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | T5.6, T5.7 |
## Goal
The default grading strategy: one current version, at most one challenger, one
`Judge::compare` per new episode.
## Facts (inlined — no spec read needed)
```
current version ──► episode ──┐
├──► Judge::compare ──► verdict
challenger ──► episode ──┘ │
(same TaskId) ▼
accumulate into WinRecord
```
- Grading answers **one** question: has the challenger accumulated enough
evidence to replace the current version? Not "rank these eight", not "what is
each episode worth".
- **The reference is the current version's recorded episode on the same
`TaskId`.** It is already on disk — no re-run, no group to fill, no group
timeout. A `TaskId` never seen before has no reference and degrades per T5.11.
- One comparison per episode. Against the tournament's `(G/2)·log₂(G)` pairs
doubled for both orderings, that is **24 judge calls dropping to 8 at G = 8**,
and the saving grows with G rather than shrinking.
- `ResourceProfile` declares **exactly one model — the agent's, already
resident**. That is precisely why the default grading path adds no resident
model and forces no swap.
- **A fixed reference is a cacheable prefix.** The same reference episode leads
every comparison in a decision, so it can be cached across calls. The
tournament cannot do this — shuffling into brackets makes every pair a novel
combination by design.
- Episodes are bracketed **within** a verifier outcome class, never across. A
verified pass beats a verified fail by definition and that pairing is never
shown to a judge.
- What this gives up, stated rather than discovered later: parallel exploration
and composable strengths. One challenger at a time is hill-climbing; a
`Relative` score answers "better than the current version on this task" and
does not compose across tasks.
## Steps
1. Implement `EvaluationStrategy` with `resources()` naming exactly one model —
the agent's pinned model — and `calls_per_episode: 1.0`.
2. Reference lookup: given `(TenantId, TaskId)`, find the current version's most
recent recorded episode. Return `None` for a novel task and hand off to T5.11.
3. Check the verifier outcome class before pairing. Reference and candidate must
share a class or no comparison is issued.
4. Build the comparison prompt with the **reference half first and byte-stable**
across calls. Assert that stability in a test — prompt caching depends on it.
5. Issue exactly one `Judge::compare` per new episode and fold the verdict into a
`WinRecord` (consumed by T5.6).
6. Instrument two counters: `compare` calls and model loads. Both are asserted.
## Acceptance
- Grading one episode issues **exactly one** `compare` call and **zero** model
loads — both asserted by counting.
- A second episode on the same `TaskId` reuses the identical reference, so the
reference half of the prompt is **byte-identical** across calls.
## Verify
**Harness:** a mock `Judge` recording every `(prompt_bytes, verdict)`; a model
**load** counter distinct from the call counter; two recorded episodes on one
`TaskId`.
**Integration test** — `tests/it_pairwise_reference.rs`:
1. Grade one episode against an existing reference. Assert **exactly 1**
`compare` call and **exactly 0** model loads.
2. Grade a second episode on the same `TaskId`. Assert the **reference half of
the prompt is byte-identical** between the two calls — capture both prompts
from the mock judge and compare the reference prefix directly. This is the
property prompt caching depends on and it is easy to break invisibly.
3. Assert the reference selected is the **current version's** recorded episode,
not the highest-scoring one — plant a better-scoring non-current episode on
the same `TaskId` and assert it is not chosen.
4. Outcome-class guard: reference is a verified pass, candidate is a verified
fail. Assert **zero** `compare` calls.
5. Novel `TaskId`: assert `compare` is not called and the path hands off to
T5.11's `NoReference`.
6. Assert `resources().models.len() == 1` and that it equals the pinned agent
model.
**Command:** `cargo test -p grading pairwise`
**False pass:**
- Step 2 comparing whole prompts. They legitimately differ in the candidate half;
comparing the whole thing either always fails or, if the test is loosened to
"both non-empty", always passes. Compare the **prefix**.
- Step 1 counting calls but not loads. A judge running on a non-resident model
returns the right verdict and costs tens of seconds per call.
- Step 3 omitted: "pick the best episode as reference" produces sensible-looking
verdicts against a moving target, and no other assertion here catches it.
## Traps
- Re-rendering the reference half per call with a timestamp or a fresh id in it.
It still works and it silently destroys the cacheable prefix.
- Selecting the reference by "best episode" rather than the current version's
episode. That makes the comparison a moving target.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §11.1, §11.3, §11.8, §14.2 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+120
View File
@@ -0,0 +1,120 @@
# T5.6 — Sequential test and stopping
| Field | Value |
|---|---|
| Phase | P5 — Grading |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | T6.3 |
## Goal
Stop on evidence, not on a fixed sample. Verdicts accumulate into a likelihood
ratio tested against α/β boundaries.
## Facts (inlined — no spec read needed)
```
┌────────────────┬────────────────┐
▼ ▼ ▼
accept continue reject
challenger becomes keep sampling discard, keep
the current current
```
- **Stopping is a sequential test, not a fixed sample.** Boundaries are set by α,
β and the smallest win-rate shift worth acting on; the test stops as soon as a
boundary is crossed. A clearly better challenger is accepted in far fewer
comparisons than a fixed-n design would spend, and a clearly worse one is
rejected early instead of running to completion.
- This is what makes cost adaptive: cheap decisions cost little, close decisions
cost more, and nothing costs the worst case by default.
- **Draws are recorded and excluded from the ratio.** A tie carries no evidence
about which is stronger, so folding it in as half a win manufactures
information.
- **But a high draw rate is itself a result** — it says the challenger is not
meaningfully different — so the test also **rejects on a draw-rate ceiling**
rather than sampling forever toward a boundary it will never reach.
- α, β and the minimum detectable win-rate shift are **policy, not physics**: too
tight and no challenger is ever accepted, too loose and the loop churns the
current version on noise. Same for the draw ceiling, which interacts with judge
quality — a weak judge draws more. Expose all four as configuration with
documented defaults.
## Steps
1. Define `WinRecord { wins, losses, draws }` and persist it with the decision,
not only in memory — the decision must survive a restart.
2. Implement the sequential probability ratio test: maintain the log-likelihood
ratio over wins and losses under H0 (win rate = 0.5) and H1 (win rate =
0.5 + δ), with boundaries derived from α and β.
3. Exclude draws from the ratio; count them separately.
4. Add the draw-rate ceiling check after each comparison: over the ceiling with a
minimum sample, reject.
5. Return a three-valued decision — `Accept`, `Reject`, `Continue` — and let the
caller decide whether to sample another episode.
6. Expose α, β, δ and the draw ceiling as configuration. Document the defaults as
provisional and needing calibration against a workflow whose true improvement
is known.
7. Build the mock judges: a 70% winner, a 50/50 coin, and an all-draws judge.
## Acceptance
- A mock judge with a true 70% win rate crosses the accept boundary in
**materially fewer comparisons than a fixed-n design at the same α** — assert
the count, since an implementation that ignores the boundary and runs to n
still reaches the right answer.
- A 50/50 judge rejects.
- A judge returning `Draw` on every comparison terminates on the draw ceiling
instead of running forever.
## Verify
**Harness:** seeded mock judges with known true win rates. Every test asserts a
**comparison count**, not only an outcome — the outcome is reachable by an
implementation that ignores the boundary entirely.
**Integration test** — `tests/it_sequential_test.rs`:
1. **70% winner:** assert `Accept`, and assert the comparison count is
**materially below** the fixed-n sample size for the same α. Compute the
fixed-n figure in the test and assert `count < fixed_n`, with both printed.
2. **50/50 judge:** assert `Reject`, and assert it terminates — bounded count.
3. **All-draws judge:** assert termination **on the draw-rate ceiling**, and
assert the reason is the ceiling, not the α boundary. Without this it loops
forever.
4. **30% winner (clearly worse):** assert `Reject` early — assert the count is
well below the 70% case's, proving early rejection works in both directions.
5. Draws excluded: feed a sequence of `win, draw, win, draw`; assert the
likelihood ratio equals that of `win, win`, and that `draws` is counted
separately in the `WinRecord`.
6. Persistence: kill the process mid-decision, restart, assert the `WinRecord`
survived and the decision resumes rather than restarting.
7. Repeat each case over 100 seeds; assert the accept/reject rates sit within
α/β. A single seed says nothing about a statistical test.
**Command:** `cargo test -p grading sequential -- --nocapture`
**False pass:**
- Asserting only the decision. A fixed-n implementation reaches the **right
answer** on all of steps 14 and delivers none of the cost saving that is the
entire justification for the design. The count assertion is the test.
- Step 5 omitted: counting a draw as half a win tightens the boundary on invented
evidence and still produces plausible decisions.
- One seed per case. A sequential test is a statistical object; step 7 is what
makes the α/β claim meaningful.
## Traps
- Counting a draw as half a win. It tightens the boundary on evidence that does
not exist.
- Checking the boundary only at the end. The test then costs exactly what a
fixed-n design costs, and the acceptance count catches it — which is why the
count is asserted rather than the outcome.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §11.3, §18 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
@@ -0,0 +1,98 @@
# T5.7 — Order alternation and sampled consistency
| Field | Value |
|---|---|
| Phase | P5 — Grading |
| Size | S — under 1 day |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Cancel judge position bias across the sequence instead of within each pair, and
measure order-consistency on a sample rather than by doubling every call.
## Facts (inlined — no spec read needed)
- Pairwise judges have **position bias**. The tournament path cancels it by
judging both orderings, paying 2× on every comparison.
- The default path does not pay that: **the challenger takes position A on
even-numbered comparisons and position B on odd ones.** Bias cancels across the
sequence, at no extra cost.
- Order-consistency is still measured — on a **sampled fraction** of comparisons
— and reported as the grader's own error bar.
- That disagreement rate is a first-class grader-health metric. An inconsistent
judge should widen the sample gate rather than silently promote.
- Total calls must stay near 1× the comparison count. A doubling implementation
produces the right consistency number and the wrong cost, which is why the call
count is asserted rather than the metric.
## Steps
1. Alternate position by comparison index: even → challenger is A, odd →
challenger is B. Record the position used on each comparison.
2. Normalize the verdict back to challenger-relative terms before folding into
the `WinRecord` (T5.6) — otherwise alternation inverts half the record.
3. Sample a configurable fraction of comparisons for a second, order-swapped
call. Default the fraction low; it is an error bar, not the measurement.
4. Compute the disagreement rate over sampled pairs and emit it as a metric
tagged by judge and rubric.
5. Count total `compare` calls and assert the ratio to comparison count in the
test.
6. Build a deliberately position-biased mock judge — always picks A — as the
detection case.
## Acceptance
- A deliberately position-biased mock judge is **detected** and its disagreement
rate reported.
- Total `compare` calls stay within **1.01.2×** the comparison count. A doubling
implementation fails this, which is the point.
## Verify
**Harness:** a mock judge that **always picks position A** regardless of content
— maximal position bias, so detection is unambiguous.
**Integration test** — `tests/it_order_alternation.rs`:
1. Run 100 comparisons against the always-A judge.
2. Assert the disagreement rate is **detected and reported** near 100% on the
sampled subset.
3. Assert total `compare` calls fall in **1.01.2×** the comparison count. A
doubling implementation lands at 2.0× and fails here, which is the point.
4. Assert position alternation: challenger in A on even indices, B on odd. Read
the recorded position per comparison, do not infer it.
5. **Normalization:** with the always-A judge, assert the resulting `WinRecord`
is near 50/50 rather than 100% challenger wins. A missing verdict
normalization after the swap inverts half the record and produces exactly
100%, which looks like a strong result.
6. Unbiased judge control: a content-driven mock judge yields a low disagreement
rate — otherwise the detector fires on everything.
7. Assert the disagreement rate is emitted as a metric tagged by judge and
rubric.
**Command:** `cargo test -p grading order_alternation`
**False pass:**
- Step 3 omitted. Judging both orderings on every comparison produces a perfect
consistency measurement at double the cost — correct-looking and exactly the
design this task rejects.
- Step 5 omitted: forgetting to normalize verdicts after alternating is the most
likely implementation error, and it makes a biased judge look like a decisive
one.
- Step 6 omitted, so a detector that always reports high bias passes.
## Traps
- Forgetting to normalize the verdict after swapping positions. The record then
averages toward 50% regardless of the truth.
- Sampling "every comparison, it is cheap" — that is the doubling implementation
with extra steps.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §11.3, §11.4, §15 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+109
View File
@@ -0,0 +1,109 @@
# T5.8 — Swiss pairing
| Field | Value |
|---|---|
| Phase | P5 — Grading |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | opt-in |
| Spec | inlined below |
| Blocks | — |
## Goal
Swiss pairing over a comparison group: log₂(G) rounds, G/2 comparisons per round.
Ships **disabled** — it gates nothing.
## Facts (inlined — no spec read needed)
```
[ G comparable episodes for one task, one outcome class ]
[ shuffle into brackets ] ◄── shuffling also cancels position bias
┌─────────────────────────────────┐
│ Swiss pairing, log₂(G) rounds │ ◄── rubric-guided Judge, relative only
└────────────────┬────────────────┘
[ Bradley-Terry fit over all comparisons ] ◄── T5.9
```
- **Swiss rather than round-robin**: O(G log G) instead of O(G²). Eight episodes
is twelve comparisons rather than twenty-eight.
- **Swiss rather than single elimination**: we want a full ranking, not a
champion — eliminated candidates still carry signal.
- **Both orderings are judged** on this path, so 12 pairs at G = 8 is 24
`compare` calls. (The default path instead alternates order — T5.7.)
- **Draws are permitted.** A judge forced to separate two equivalent episodes
invents a distinction, and the optimizer will chase the invention. Draws cost
gradient; forced choices cost correctness.
- Groups are bracketed within **one verifier outcome class**, never across.
- Enable this where episodes are already co-present at no extra cost: attempt
tournaments (T5.10) and replay (T6.5).
## Steps
1. Take a group of G episodes sharing `(TenantId, TaskId, VerifierOutcome,
GroupEpoch)`.
2. Shuffle into the first round from a **recorded seed** — the shuffle is part of
bias cancellation, and an unreproducible pairing is an unauditable result.
3. Run `ceil(log2(G))` rounds. Each round pairs on accumulated score, avoiding
rematches; carry a bye when G is odd.
4. Issue both orderings per pair and record order-consistency per comparison.
5. Track the constraint explicitly: each episode paired at most once per round,
and no episode idle in more than one round.
6. Emit the full comparison list — the BT fit (T5.9) consumes pairs, not
standings.
7. Keep it behind an off-by-default feature flag.
## Acceptance
- G = 8 produces **exactly 12 pairs and 24 `compare` calls** under the
both-orderings rule.
- Every episode is paired at most once per round.
- No episode is idle in more than one round.
## Verify
**Harness:** a counting mock judge; a fixed shuffle seed so pairings are
reproducible.
**Integration test** — `tests/it_swiss_pairing.rs`:
1. G = 8: assert **exactly 12 pairs** and **exactly 24 `compare` calls** (both
orderings).
2. Assert every episode is paired **at most once per round**.
3. Assert **no episode is idle in more than one round**.
4. Assert no pair repeats across rounds.
5. Odd G (G = 7): assert the bye is assigned, assigned to a different episode
each round, and that the comparison count matches the expected formula.
6. G = 2 and G = 3 boundary cases: assert the pairing does not panic and produces
the minimum sensible schedule.
7. Assert the shuffle seed is **recorded** with the group, and that replaying the
seed reproduces the identical pairing.
8. Assert every pair sits within one `VerifierOutcome` class — feed a mixed group
and assert zero cross-class pairs.
**Command:** `cargo test -p grading swiss`
**False pass:**
- Asserting the pair count alone. Round-robin at G = 8 gives 28; a single
elimination gives 7. But an implementation that pairs the same two episodes
repeatedly can also hit 12 — steps 2 and 4 are what make the count meaningful.
- Testing only G = 8, a clean power of two. The bye logic at odd G is where the
"idle in more than one round" rule actually bites.
- An unseeded shuffle, which makes a surprising standings result impossible to
reproduce or audit.
## Traps
- Round-robin "since G is small". It is 28 pairs at G = 8 and grows quadratically.
- Dropping eliminated episodes. Swiss was chosen precisely to keep their signal.
- An unseeded shuffle, which makes a surprising result impossible to reproduce.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §11.4, §11.8 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+132
View File
@@ -0,0 +1,132 @@
# T5.9 — Bradley-Terry fit
| Field | Value |
|---|---|
| Phase | P5 — Grading |
| Size | L — over 3 days |
| Status | Not started |
| Flags | opt-in |
| Spec | inlined below |
| Blocks | T6.4 |
## Goal
Strength parameter plus confidence interval over the pairwise outcomes. Ships
**disabled** — it gates nothing.
## Facts (inlined — no spec read needed)
**Bradley-Terry, not point-tally z-scores.** Accumulating tournament points and
normalizing to mean 0 / sd 1 within a group is the obvious approach and it is
statistically wrong for the aggregate: small groups produce extreme z-scores, so
a variant appearing in many small groups wins on variance rather than quality. A
BT fit yields a strength with a real confidence interval, which composes across
groups of different sizes.
Three decisions, none optional. Textbook BT does none of them, and each failure
looks like a result rather than a bug:
- **Davidson tie term.** Plain BT is binary and has no tie parameter, so the
permitted draws have nowhere to go. Dropping them discards the judge's most
confident statements; splitting each half-and-half fabricates two comparisons
that never happened and tightens the interval on invented evidence. Fit one
additional tie parameter alongside the strengths.
- **Weakly-informative prior — required, not tuning.** At G = 4..8 an episode
that wins every comparison drives the unpenalized maximum-likelihood estimate
to infinite strength. The prior (equivalently, a penalized likelihood) is what
turns "won all three of its comparisons" into a **wide** interval rather than
an unbounded one. Without it, the reassuring claim that small groups produce
wide intervals is simply false.
- **Control pinned to zero.** The fit is identified only up to an additive
constant, so an interval on a single raw strength is an interval on an
arbitrary origin. Pin control to zero and report every strength as a delta
against it. Downstream gates say "BT interval excludes zero" — zero is control,
and it is only zero because it was pinned there.
- A group containing **no control episode is not aggregatable**. It still grades
its own members and is worth reading; it just does not feed the aggregate, and
it is recorded as such rather than folded in on the assumption that scales
match.
## Steps
1. Take the comparison list from T5.8: `(episode_a, episode_b, Verdict)`.
2. Implement the Davidson model: strengths plus one tie parameter, fit by
penalized maximum likelihood.
3. Add the weakly-informative prior on strengths. Make its strength a named
constant with a comment stating it is required for convergence, not a tuning
knob — otherwise it gets removed as "unnecessary regularization".
4. Constrain control's strength to zero during the fit rather than subtracting it
afterwards.
5. Compute intervals (profile likelihood or bootstrap over comparisons) and emit
`Score::Ranked { strength, interval, group_size }`.
6. Reject a group with no control episode as non-aggregatable, with a typed
error — do not fit against an arbitrary origin.
7. Build the synthetic generator: known strengths, configurable draw rate.
## Acceptance
- On synthetic data with known strengths, recovered ordering is correct and the
delta-from-control interval covers the true delta at the stated rate.
Three targeted cases beyond that:
- A group where one episode wins every comparison **converges, with a wide
interval**. Without the prior this diverges — assert the bound, not just that
the fit returns.
- A synthetic generator producing **30% draws** recovers the true strengths; a
fit that drops draws does not, and the test asserts the gap.
- A group with **no control episode is rejected** as non-aggregatable rather than
fit against an arbitrary origin.
## Verify
**Harness:** a synthetic generator with **known** true strengths and a
configurable draw rate. Every claim here is checkable against ground truth, so
none of it needs a real judge.
**Integration test** — `tests/it_bradley_terry.rs`:
1. Generate comparisons from known strengths. Assert the recovered **ordering**
is correct.
2. **Coverage test:** repeat over 200 seeded datasets; assert the
delta-from-control interval covers the true delta at the stated rate (e.g.
~95% for a 95% interval). One dataset proves nothing about an interval.
3. **Clean sweep:** a group where one episode wins every comparison. Assert the
fit **converges** and assert a **numeric upper bound** on the interval width —
not merely that the call returned. Without the prior this diverges.
4. **Draws at 30%:** assert recovered strengths are close to truth. Then run a
draw-dropping fit on the same data and **assert the gap** — the test must show
the Davidson term is doing work, not just that the fit runs.
5. **Half-split control:** also fit with draws split half-and-half; assert its
interval is **narrower than truth warrants** (over-confident), demonstrating
why splitting is rejected.
6. **No control:** a group with no control episode is **rejected as
non-aggregatable** with a typed error — not fit against an arbitrary origin.
7. Assert control's fitted strength is exactly 0 and every other strength is
reported as a delta.
**Command:** `cargo test -p grading bradley_terry -- --nocapture`
**False pass:**
- Step 3 asserting only that the fit returns a value. An unpenalized fit returns
a very large finite number on many datasets and looks fine. Assert the bound.
- Step 4 without the comparison fit from step 5. "The fit recovers strengths"
passes on easy data whether or not draws are modelled — the **gap** is the
evidence.
- Step 2 with one dataset. Interval coverage is a frequency claim; it needs
repetition.
- Reporting raw strengths anywhere: an interval on a raw strength is an interval
on an arbitrary origin, and it will look perfectly reasonable.
## Traps
- Splitting draws half-and-half. It is the standard workaround and it invents
evidence.
- Removing the prior after seeing it barely move the estimate on well-separated
data. It exists for the clean-sweep case.
- Reporting raw strengths anywhere outside the fit.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §11.2, §11.4 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+103
View File
@@ -0,0 +1,103 @@
# T6.1 — Challenger allocation
| Field | Value |
|---|---|
| Phase | P6 — Learning loop |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | T6.3 |
## Goal
One current version, at most one challenger, 95/5 traffic split, guarded by a
generation counter with compare-and-swap.
## Facts (inlined — no spec read needed)
- **One current version, at most one challenger.** The loop converges toward a
single state rather than maintaining a population. That is the change that
makes everything else affordable: no N-way traffic split, no per-variant
aggregation across groups, no allocation state to contend over, one comparison
per episode instead of a bracket.
- The cost is exploration. A single challenger at a time is hill-climbing — it
finds improvements more slowly than a population would, and it can sit in a
local optimum indefinitely with nothing in the loop able to say so. The
held-out report (T6.6) is the only instrument that will notice, which makes it
**more** important here, not less.
- **The decide stage is single-writer per `(TenantId, WorkflowId)`.** Two
schedulers adjusting traffic allocation concurrently produce an allocation
neither holds. A compare-and-swap on a generation counter is sufficient; no
lock service is needed at this size.
- The slow generate loop fires when the fast loop **rejects a challenger without
finding a replacement** — a trigger, not a timer.
- Multi-variant selection stays available for deployments that enable the
tournament and allow N challengers; the machinery is the same.
## Steps
1. Define the allocation record: `(TenantId, WorkflowId) → { current: WorkflowVersion,
challenger: Option<WorkflowVersion>, split, generation: u64 }`.
2. Registration of a challenger is rejected when one is already live — the error
**names the incumbent challenger**, so the operator knows what to retire.
3. Every update is a CAS on `generation`. The loser retries against the winner's
generation rather than overwriting.
4. Route spawns by the split: 95% current, 5% challenger. Record which version a
run was allocated to on the spawn event.
5. Emit the "rejected without replacement" trigger as an event so the generate
loop can subscribe. Do not add a timer.
6. Test two concurrent schedulers committing allocations.
## Acceptance
- Registering a second challenger while one is live is **rejected, naming the
incumbent challenger**.
- Two concurrent schedulers cannot both commit an allocation; the loser retries
and observes the winner's generation.
## Verify
**Harness:** two scheduler instances against one backend — real concurrency, not
a simulated race.
**Integration test** — `tests/it_challenger_allocation.rs`:
1. Register challenger A. Register challenger B while A is live; assert
**rejected**, and that the error **names A**.
2. **Concurrent CAS:** two schedulers commit different allocations
simultaneously, in a loop of 100 rounds. Assert exactly one wins per round,
the loser retries, and the final generation equals the number of successful
commits — no lost updates.
3. Traffic split: spawn 1000 runs; assert the challenger share is within
tolerance of 5% and that each run's spawn event **records which version it was
allocated to**.
4. Assert control appears in every allocation, so T6.4's anchor exists.
5. Trigger: reject a challenger with no replacement; assert the generate-loop
trigger event fires **once**. Assert no timer path exists that would fire it
otherwise — run 60s of simulated time with no rejection and assert zero
triggers.
6. Restart mid-decision; assert the allocation and generation survive.
**Command:** `cargo test -p loop allocation -- --test-threads=1`
**False pass:**
- Step 2 simulated by calling the update function twice sequentially. Last-write-
wins passes that and loses an allocation under real concurrency. Use two live
schedulers.
- Step 3 asserting the split ratio without asserting the per-run recorded
version. Runs allocated correctly but recorded wrongly are unattributable, and
the ratio still looks right.
- Step 5's negative half omitted, so a timer-driven generate loop passes.
## Traps
- Last-write-wins on the allocation record. The result is a split neither
scheduler intended and no error anywhere.
- A timer-driven generate loop. It proposes challengers when nothing has been
learned.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §9.1, §12.1 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+104
View File
@@ -0,0 +1,104 @@
# T6.2 — Judge calibration set
| Field | Value |
|---|---|
| Phase | P6 — Learning loop |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | T6.3. Promotion without calibration is the self-critique failure mode |
## Goal
Replay a fixed set of tasks with known verifier ground truth through the judge on
a schedule, and track judge-versus-verifier agreement.
## Facts (inlined — no spec read needed)
- **This is a precondition, not polish.** Under one resident model the judge *is*
the agent's model. Self-critique using the same model family being graded is
admissible **only** with this bootstrap: validation against verifiable tasks.
- Without it the grader's bias is unmeasured and the optimizer will find it. And
under one-state convergence (T6.1) there is no competing variant whose
divergence would make that visible.
- **Judge-versus-verifier agreement on the calibration set is the only
instrument that can see the grader drifting toward its own bias.** It belongs
in the metrics list beside `Indeterminate` count and judge order-inconsistency.
- **It costs zero agent runs**, since the episodes are already recorded. Re-running
the episodes is the obvious wrong implementation, which is why the run count is
asserted.
- Verification decides, grading explains. Agreement is measured against the
verifier's ground truth, never the other way round.
## Steps
1. Define the calibration set: a stable list of `(TaskId, episode, known verifier
outcome)` drawn from recorded history. Store it explicitly — a set that is
re-derived each cycle is not a fixed set.
2. Schedule the calibration pass on an interval, decoupled from promotion.
3. For each entry, call the judge on the **recorded** episodes. No agent
execution, no replay, no new runs.
4. Compute agreement overall and **per rubric dimension**, so a judge biased on
one dimension is caught with the dimension named rather than showing as a
small overall dip.
5. Emit agreement as a metric tagged by judge, model and rubric version.
6. Instrument an agent-run counter across the calibration pass and assert it is
zero.
7. Build two judge stubs: one agreeing 95% of the time, one deliberately biased
against a chosen rubric dimension.
## Acceptance
- A judge stub agreeing with the verifier 95% of the time reports ~95%.
- A judge deliberately biased against a rubric dimension is **caught and the
dimension named**.
- Calibration consumes **zero agent runs** — asserted by count, since re-running
episodes is the obvious wrong implementation.
## Verify
**Harness:** two judge stubs — one agreeing with the verifier 95% of the time,
one biased against a single named rubric dimension. Plus an **agent-run counter**
at the executor boundary.
**Integration test** — `tests/it_judge_calibration.rs`:
1. Run the calibration pass with the 95% stub over ≥200 calibration entries.
Assert reported agreement is ~95% within sampling error. State the tolerance
from the sample size rather than guessing.
2. Run with the dimension-biased stub. Assert the report **names the dimension**,
not just a lower overall figure.
3. **Assert the agent-run counter is exactly 0** across the whole pass. The
episodes are already recorded; re-running them is the obvious wrong
implementation and it is expensive rather than incorrect-looking.
4. Assert the calibration set is **stored and stable** — run the pass twice and
assert the same entries were used both times. A set re-derived each cycle is
not a fixed set and its trend is meaningless.
5. Assert agreement is emitted as a metric tagged by judge, model and rubric
version.
6. Assert T6.3 refuses to promote when no calibration result exists or the
latest is stale — the blocking relationship must be enforced, not documented.
**Command:** `cargo test -p loop calibration`
**False pass:**
- Step 2 asserting only that overall agreement dropped. A judge biased on one
dimension of five moves the aggregate by a few points, which is
indistinguishable from noise — the per-dimension breakdown is the instrument.
- Step 4 omitted: a set rebuilt from "recent episodes" each cycle drifts with the
workload, so the metric measures the workload rather than the judge.
- Step 6 omitted, leaving calibration as a dashboard number that gates nothing —
which is precisely the self-critique failure mode this exists to prevent.
## Traps
- Re-executing the calibration tasks to "get fresh episodes". It costs agent runs
and measures the wrong thing.
- Reporting only an aggregate agreement number, which hides single-dimension bias
— the exact failure the loop will exploit.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §1, §14.2, §15, §16 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+134
View File
@@ -0,0 +1,134 @@
# T6.3 — Promotion gates
| Field | Value |
|---|---|
| Phase | P6 — Learning loop |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
The default three-rung ladder, closing on T5.6's sequential test, plus automatic
rollback triggered by `Score::Capped`.
## Facts (inlined — no spec read needed)
**Default ladder — one challenger, pairwise.** Three rungs, because a graduated
ramp is a population instrument and there is no population here:
| Rung | Traffic | Entry criterion |
|---|---|---|
| shadow | 0% | registered, validated, sandbox-clean, `ResourceProfile` fits |
| trial | 5% | no `Core` violation on any trial episode |
| current | 100% | sequential test crosses the accept boundary; drift check clean |
- **The swap at the last rung is deliberate: the challenger takes all traffic at
once rather than ramping.** A ramp exists to limit blast radius while evidence
accumulates, and here the evidence has already accumulated — the sequential
test does not cross its boundary until the win rate is established at the
configured α. Ramping after that spends traffic to re-learn what the test
already concluded.
- What guards the swap instead is the rollback rule, which fires on a **single**
`Score::Capped` and does not wait for a boundary.
- **Rollback is automatic and unconditional** on any `Score::Capped` attributed
to the variant, or a verifier pass-rate regression beyond a configured margin.
`Score::Capped` is the only signal for the first of those; a gate that reads a
low *number* instead is reading something the cap exists to prevent from
existing.
- **Rollback is a traffic change, never a version delete.** The failed variant
stays in the DAG with its results.
- A `Core` violation **caps** rather than subtracts. A weighted sum lets a
variant buy past a safety failure with speed, which is exactly what prescriptive
rubrics exist to prevent. The cap comes from `Judge::screen`, runs before
pairing, and yields `Score::Capped`.
- **A capped episode is excluded from the bracket, not ranked last in it.** Left
in, it still contributes comparisons that shape everyone else's strength, and a
variant with one safety failure and seven strong episodes aggregates to a
promotion.
- **No rung reads held-out** (T6.6). A gate that reads held-out has converted it
into a second selection set.
For deployments running the tournament with N challengers, the resourced ladder
is shadow (0%) → canary (5%, beats current on selection replay, BT interval
excludes zero) → ramp (20→50%, no `Core` violation, cost within budget,
sequential test at α) → current (100%, sustained over N groups, drift clean).
## Steps
1. Implement the three rungs as an explicit state machine on the challenger
record, with each entry criterion as a named predicate.
2. Shadow entry: validation (T3.3), sandbox cleanliness (T6.5), and the
`ResourceProfile` fit (T5.2/T5.3).
3. Trial entry: no `Core` violation on any trial episode, read from
`Score::Capped`.
4. Current entry: T5.6's sequential test returns `Accept`, and T6.7's drift check
is clean. Perform the swap **atomically** through T6.1's CAS.
5. Rollback path: on any `Score::Capped` attributed to the variant, or a pass-rate
regression past the margin, restore the prior version as current in a single
CAS — no interval in which neither is live.
6. Exclude capped episodes from the bracket **before** comparisons are issued, so
no other episode's score is influenced by them.
7. Assert no gate predicate reads held-out data.
## Acceptance
- A `Judge::screen` stub returning one `CoreViolation` triggers rollback **without
human action**; the rolled-back version remains in the DAG with its results
intact.
- The capped episode contributed **zero comparisons**, so no other episode's score
moved because of it.
- The swap at `current` is **atomic — no ramp** — and a rollback immediately after
it restores the prior version **without a gap in which neither is live**.
## Verify
**Harness:** a `Judge::screen` stub returning one `CoreViolation` on demand; a
traffic router observable at every instant; the mock judges from T5.6.
**Integration test** — `tests/it_promotion_gates.rs`:
1. Drive a challenger through shadow → trial → current with a 70% mock judge.
Assert each rung's entry criterion was evaluated and recorded.
2. **Rollback:** fire one `CoreViolation`. Assert rollback happens **without
human action**, and that it triggered on `Score::Capped` — assert the gate
never reads a numeric score by instrumenting the score accessor.
3. Assert the rolled-back version **remains in the DAG with its results intact**
— read it back after rollback.
4. **Capped exclusion:** grade a group containing the capped episode. Assert it
contributed **zero comparisons**, and assert the other episodes' scores are
byte-identical to a control run where the capped episode was absent. Ranking
it last would change them.
5. **Atomic swap:** sample the live-version pointer at high frequency across the
promotion. Assert it goes 100% old → 100% new with **no intermediate
percentage** — no ramp.
6. **No gap:** roll back immediately after the swap. Assert every sample shows
exactly one live version; a sample showing none is a failure.
7. Assert no gate predicate reads held-out data (cross-check with T6.6's audit).
**Command:** `cargo test -p loop promotion -- --test-threads=1`
**False pass:**
- Step 4 asserting only "the capped episode has no score". Leaving it in the
bracket still lets it shape everyone else's strength, and its own score can be
absent while it does. The **control-run comparison** is the evidence.
- Step 5 asserting the final state only. A ramp also ends at 100%.
- Step 6 sampled too coarsely to observe a gap. Sample from a tight loop or
instrument the pointer swap directly.
- A rollback test that triggers on a low score. It passes, and the cap exists
precisely so that number does not exist.
## Traps
- A gate reading a low numeric score instead of `Score::Capped`. The cap exists
precisely so that number does not exist.
- Ranking a capped episode last rather than excluding it. It still shapes the
bracket.
- Deleting a rolled-back version. Its results are attributed to it.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §11.7, §12.3, §12.5 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+105
View File
@@ -0,0 +1,105 @@
# T6.4 — Per-variant aggregation
| Field | Value |
|---|---|
| Phase | P6 — Learning loop |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | opt-in |
| Spec | inlined below |
| Blocks | — |
## Goal
Compose Bradley-Terry strengths across groups of differing size, as deltas from
the anchor present in each group. Only reachable with the tournament enabled and
N challengers allowed.
## Facts (inlined — no spec read needed)
- Group-normalized relative scores give **cross-task comparability** — but only
through a shared anchor, and that qualification is load-bearing.
- A BT fit identifies strengths only up to an additive constant **within one
connected comparison graph**. Two groups on different tasks are disjoint
graphs, so their strengths sit on unlinked scales. Averaging them directly
commits the same error that point tallies are accused of, one layer further in.
- **The anchor is control.** Control gets a traffic share in every allocation, so
every group contains at least one control episode; the fit pins control to zero
and every other strength is read as a delta from it. A variant's aggregate is
then a mean of like-for-like deltas rather than a mean of incomparable scales.
- **A group with no control episode is not aggregatable.** It still grades its own
members and is worth reading; it just does not feed the aggregate, and it is
**recorded as such** rather than folded in on the assumption that scales match.
- Why not point-tally z-scores: small groups produce extreme z-scores, so a
variant appearing in many small groups wins on variance rather than quality. BT
intervals compose correctly across group sizes and feed the sample gate
directly.
## Steps
1. Consume `Score::Ranked { strength, interval, group_size }` from T5.9. Strength
is already a delta from control because control was pinned at fit time.
2. Partition by variant. Weight each group's contribution by its interval width
(inverse-variance), not by group size alone.
3. **Reject any group lacking a control episode** from the aggregate, and count
the exclusions in a reported total — exclusion must be visible, not silent.
4. Never combine raw strengths from independently fitted groups without the
anchor. Make that impossible in the type, if the type can carry "anchored".
5. Emit the aggregate with its own interval and the number of contributing groups.
6. Keep it behind the same off-by-default flag as T5.8/T5.9.
## Acceptance
- A variant appearing only in small groups **does not outrank** one with a
tighter interval at equal mean.
- Anchor guard: two groups fit independently with different origins are **not
averaged** — a group lacking the anchor is excluded from the aggregate and
**counted in a reported total**, so exclusion is visible rather than silent.
## Verify
**Harness:** synthetic `Score::Ranked` inputs with known true strengths and
deliberately varied group sizes — no judge needed.
**Integration test** — `tests/it_variant_aggregation.rs`:
1. Variant X appears only in small groups (wide intervals); variant Y appears in
large groups (tight intervals). Set their **means equal**. Assert Y does not
lose to X — and specifically assert X does **not** outrank Y, which is the
variance failure being guarded against.
2. Assert weighting is by interval width, not group count: construct a case where
the two disagree and assert the interval-weighted answer wins.
3. **Anchor guard:** feed two groups fit independently with different origins.
Assert they are **not averaged**.
4. Assert a group lacking the anchor is **excluded** and that the exclusion is
**counted in a reported total** — read the total back and assert it is
non-zero. Silent exclusion is the failure.
5. Assert the aggregate carries its own interval and the contributing group
count.
6. Assert this path is unreachable with the tournament flag off.
**Command:** `cargo test -p loop variant_aggregation`
**False pass:**
- Step 1 with unequal means, where the right variant wins for the wrong reason.
Equal means is what isolates the variance effect.
- Step 4 asserting only that the anchorless group was dropped. Dropping silently
looks identical to having no such group, so a systematic allocation bug that
strips control from every group reads as a quiet dataset. The **reported
count** is the instrument.
- Averaging raw strengths and passing because the synthetic groups happened to
share an origin. Construct step 3's groups with deliberately different origins.
## Traps
- Averaging strengths across groups without checking for the anchor. It compiles,
it produces plausible numbers, and the numbers mean nothing.
- Weighting by group size. That is the variance failure BT was chosen to avoid,
reintroduced at the aggregation layer.
- Dropping anchorless groups silently. Then a systematic allocation bug looks
like a quiet dataset.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §9.1, §11.2, §11.4, §12.1 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+103
View File
@@ -0,0 +1,103 @@
# T6.5 — Sandboxed replay
| Field | Value |
|---|---|
| Phase | P6 — Learning loop |
| Size | L — over 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Re-execute a task under a new workflow version with `Unsafe` effects denied.
Depends on T2.2's effect classes.
## Facts (inlined — no spec read needed)
- **"Replay against recorded episodes" means re-running the task under a new
workflow version**, not pushing a recorded trajectory through new logic.
Trajectory replay tells you only where behaviour would first diverge, and
everything after divergence is unknown — near-worthless for grading.
- **Replay is not free.** "0% live traffic" means no user sees the result, not
that it costs nothing. N variants × M tasks is N·M full agent runs plus judge
calls. **Shadow is the most expensive rung, not the cheapest.**
- **Replay executes real tools.** Re-running a workflow that pushes commits
pushes commits. Shadow execution runs in a sandbox with `Unsafe` effects
denied, and a workflow whose steps cannot run sandboxed is **ineligible for
shadow evaluation and must say so at load time rather than at 3am**.
- Sandbox is layer 3 of the effect-class defence: declaration (T2.2), keyed
capability (T8.3), sandbox here.
- Replay needs its own spend ceiling, separate from judging.
## Steps
1. Implement the sandbox as a capability restriction over the tool registry:
`Unsafe` tools are not resolvable inside a replay context. Denial is
structural, not a runtime `if`.
2. Add the **load-time** eligibility check: walk the workflow's steps, resolve
each `ToolId`, and reject the workflow for shadow evaluation if any resolves
to `Unsafe`. The diagnostic **names the tool**.
3. Replay execution path: fresh runs against the recorded task input (`TaskId`),
under the challenger version, inside the sandbox.
4. Record replay runs with a `Purpose` distinguishing them from live work, so
metering (T8.1) separates them and their own ceiling applies.
5. Feed the resulting episodes to the tournament strategy — the group exists
because you paid for it, so pairwise grading would waste it.
6. Wire the replay ceiling: exceeding it stops replay and reports, rather than
silently truncating the variant set.
## Acceptance
- A workflow calling an unsafe tool is **rejected for shadow evaluation at load
time**, with a diagnostic naming the tool.
## Verify
**Harness:** the external side-effect ledger from T2.2, plus a workflow that
calls an `Unsafe` tool and one that does not.
**Integration test** — `tests/it_sandboxed_replay.rs`:
1. Submit the `Unsafe`-calling workflow for shadow evaluation. Assert it is
**rejected at load time**, with a diagnostic **naming the tool**.
2. Assert the rejection happens **before** any run is spawned — check the run
counter is 0 and the ledger is empty.
3. Positive control: the safe workflow is accepted and replays successfully.
4. **Defence in depth:** bypass the load check in a test-only path and attempt an
`Unsafe` call inside the sandbox anyway. Assert it is denied at call time too.
The load check is the good error; the sandbox is the guarantee.
5. Assert replay re-executes against the recorded **task input**, not a recorded
trajectory — plant a divergence and assert execution continues past it and
produces a complete episode.
6. Assert replay runs are recorded with a distinct `Purpose`, and that they count
against the **replay** ceiling, not the judging one.
7. Cost visibility: assert N variants × M tasks produces N·M runs, and that the
count is reported — shadow is the most expensive rung and must not look free.
**Command:** `cargo test -p loop sandboxed_replay`
**False pass:**
- Step 1 alone. A load-time check with no runtime enforcement passes it, and any
code path that skips validation then executes real effects. Step 4 is the
guarantee.
- Step 5 omitted: trajectory replay produces an episode that looks complete and
is meaningless after the first divergence, and no assertion about tool denial
catches it.
- Charging replay to the judging ceiling — step 6. It passes every functional
test and consumes the entire grading budget in production.
## Traps
- Trajectory replay. It is cheaper, it looks like replay, and its output cannot
be graded past the first divergence.
- Denying `Unsafe` at call time instead of load time. The rejection then arrives
mid-run, at 3am, after real work has been done.
- Charging replay against the judging ceiling. Replay dominates and will consume
the whole budget.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §12.4, §13.2, §14.1 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+105
View File
@@ -0,0 +1,105 @@
# T6.6 — Held-out split
| Field | Value |
|---|---|
| Phase | P6 — Learning loop |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Partition tasks into selection and held-out sets. Promote on selection, report
held-out without optimizing against it.
## Facts (inlined — no spec read needed)
- Selecting on a fixed set of recorded tasks overfits to those tasks, **silently**:
shadow scores improve while live performance does not.
- **No promotion gate takes held-out as an entry criterion.** A gate that reads
held-out has converted it into a second selection set and left nothing
measuring generalization.
- **The proposer reads the selection set only.** If the slow loop consumes
held-out failures to generate candidates, the held-out set is contaminated
through the generator instead of the selector. **This leak is easy to introduce
and invisible once present** — which is why the audit test is the control, not
a nicety.
- A widening selection-versus-held-out gap is the overfitting alarm.
- Under one-state convergence there is no competing variant whose divergence
would reveal a stalled loop, so **the held-out report is also the only stall
detector there is**. It must be produced and surfaced even when no promotion is
pending.
- Held-out catches overfitting to *episodes*. It does not catch drift in the
*task mix* — that is T6.7.
## Steps
1. Assign each `TaskId` to selection or held-out by a deterministic hash of the
id, so the partition is stable and needs no stored membership list.
2. Expose two separate query surfaces — `selection_tasks()` and
`heldout_tasks()` — rather than one with a flag. A flag defaults wrong.
3. Give the proposer access to the selection surface only, at the type level if
possible.
4. Compute the held-out report on a **schedule**, independent of promotion
activity, and surface the selection-versus-held-out gap as a metric.
5. Write the audit test: enumerate every proposer input and every gate
evaluation, assert no held-out `TaskId` appears in either.
6. Keep the audit test running in CI permanently. It is the only thing that will
notice the leak.
## Acceptance
- An audit test asserting **no held-out `TaskId` appears in proposer input**, and
none in any promotion-gate evaluation. This leak is invisible once present, so
the test is the control.
- The held-out report is **emitted on a schedule, not only at a gate** — asserted,
since under one-state convergence it is the only stall detector there is.
## Verify
**Harness:** an interception layer recording **every** `TaskId` that reaches the
proposer and every `TaskId` read during a gate evaluation. The audit is the
deliverable — it is the only thing that will ever notice this leak.
**Integration test** — `tests/it_heldout_audit.rs`:
1. Partition a corpus of 1000 `TaskId`s. Assert the split is deterministic:
recompute it in a second process and assert identical membership.
2. Run a full generate-and-promote cycle with interception on.
3. Assert **no held-out `TaskId`** appears in the recorded proposer input set —
intersection with the held-out set is empty.
4. Assert **no held-out `TaskId`** appears in any gate evaluation.
5. Assert the two query surfaces are distinct types or distinct functions —
`trybuild` a call that tries to pass held-out tasks into the proposer.
6. **Schedule:** advance simulated time with **no promotion pending**; assert the
held-out report is still emitted. Under one-state convergence it is the only
stall detector there is.
7. Assert the selection-versus-held-out gap is emitted as a metric.
8. Negative control: deliberately wire a held-out task into the proposer in a
test-only build; assert the audit **fails**. An audit that never fails is not
a control.
**Command:** `cargo test -p loop heldout_audit`
**False pass:**
- Steps 34 passing because the interception layer only sees one of several code
paths into the proposer. Assert the interception is at the single chokepoint,
or the audit measures nothing.
- Step 8 omitted. This is the most important one: an audit test that has never
been observed failing may simply be looking in the wrong place, and the leak it
guards is invisible once present.
- Step 6 omitted, so the report is computed only at gates — exactly when a
stalled loop produces none, which is when it is needed.
## Traps
- A single task query with an `include_heldout: bool`. Someone passes `true`.
- Computing the report only when a promotion is pending, which is exactly when a
stalled loop produces none.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §12.1, §12.3, §12.5, §15 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+96
View File
@@ -0,0 +1,96 @@
# T6.7 — Drift check
| Field | Value |
|---|---|
| Phase | P6 — Learning loop |
| Size | S — under 1 day |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Track the distribution of incoming `TaskId` characteristics over time and alarm
on a shift.
## Facts (inlined — no spec read needed)
- Held-out (T6.6) catches overfitting to **episodes**. It does **not** catch
drift in the **task mix**. That needs a separate distribution check on incoming
`TaskId` characteristics over time.
- The failure it prevents: the workflow genuinely improves on the task mix it was
selected against, while the incoming mix moves elsewhere. Every internal
instrument reads healthy.
- It is a promotion gate input: the `current` rung requires "drift check clean"
alongside the sequential test crossing its accept boundary (T6.3).
- `TaskId` is an opaque hash, so the characteristics tracked are the metadata
recorded beside it — input size, declared category, source, whatever the
deployment's `TaskId` hasher was fed.
## Steps
1. Define the characteristic vector extracted per incoming task at spawn. Keep it
small and explicitly listed; this is not a feature store.
2. Maintain a reference distribution over a trailing window, and a current
window.
3. Compare with a distribution distance appropriate to the feature types
(population stability index or a KS test per numeric feature; chi-square for
categorical). Pick one, name the threshold as a constant.
4. Emit the distance as a metric and raise the alarm past the threshold, naming
which characteristic moved.
5. Expose `is_clean()` for T6.3's `current` rung.
6. Test with a synthetic shift and with a stable mix.
## Acceptance
- A synthetic shift in task mix raises the alarm.
- A stable mix does not.
## Phase gate
P6 closes on an automatic challenger accept and an automatic rollback, both
unattended, on one resident model.
## Verify
**Harness:** synthetic task streams with a controllable characteristic
distribution.
**Integration test** — `tests/it_drift_check.rs`:
1. Feed a stable mix for the full window. Assert **no alarm** — the false-positive
half matters as much as detection.
2. Shift one characteristic sharply mid-stream. Assert the alarm fires **and
names the characteristic that moved**.
3. Shift a different characteristic; assert the named one changes accordingly.
4. Gradual drift over many windows: assert it is eventually detected, so the
check is not tuned only for step changes.
5. Assert `is_clean()` gates T6.3's `current` rung — promote with drift present
and assert the promotion is blocked.
6. Assert the threshold reads its **own named constant**, not one shared with
another limit.
7. Assert the distance metric is emitted continuously, not only on alarm, so the
trend is visible before the threshold.
**Command:** `cargo test -p loop drift`
**False pass:**
- Step 1 omitted. A check that alarms on everything passes step 2 perfectly and
is useless.
- Step 2 asserting the alarm boolean only. Knowing *which* characteristic moved
is what makes it actionable; an aggregate distance alone sends the operator
hunting.
- Testing only a step change, where any distance measure works. Step 4 is where a
badly chosen window length shows.
## Traps
- Alarming on the aggregate distance only. Knowing *which* characteristic moved
is what makes the alarm actionable.
- A threshold shared with an unrelated limit. Give it its own named constant.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §12.3, §12.5 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+113
View File
@@ -0,0 +1,113 @@
# T6.8 — P6 composition gate
| Field | Value |
|---|---|
| Phase | P6 — Learning loop |
| Size | L — over 3 days |
| Status | Not started |
| Flags | gate |
| Spec | inlined below |
| Blocks | P7 |
## Goal
Prove the loop closes **unattended** end to end, and that the default
(one-challenger, pairwise) and resourced (N-challenger, tournament) paths drive
the same gate machinery.
**Phase gate criterion:** an automatic challenger accept and an automatic
rollback, both unattended, on one resident model.
## Facts (inlined — no spec read needed)
- The loop: allocation → runs → verify → grade → sequential test → promote or
reject; rollback on a single `Score::Capped`. **Unattended** means no human
action anywhere in that path.
- The properties no single P6 task owns:
- **calibration gates promotion.** Under one resident model the judge *is* the
agent's model, and promotion without calibration is the self-critique failure
mode. The blocking relationship must be enforced, not documented.
- **held-out is never read by a gate or the proposer**, across the whole loop —
a leak introduced anywhere is invisible once present.
- **the two ladders share machinery.** Default is three rungs (shadow, trial,
current); resourced is four (shadow, canary, ramp, current). Both must drive
the same state machine, or the opt-in path is a fork.
- the slow loop fires on a **trigger** — a rejection with no replacement — not
a timer.
- The cost of one-state convergence is stated rather than discovered: it is
hill-climbing, it can sit in a local optimum, and the held-out report is the
only instrument that will notice. So the gate must assert that report is
produced even when nothing is being promoted.
## Steps
1. Drive a full unattended cycle with a challenger that is genuinely better;
assert promotion.
2. Drive a second cycle with a challenger that trips one `CoreViolation`; assert
automatic rollback.
3. Repeat both under the resourced ladder with the tournament enabled and N
challengers.
4. Run the held-out audit and the calibration interlock across the whole cycle.
5. Assert zero human-intervention hooks were invoked in any path.
6. Make this the required CI job gating P7.
## Acceptance
- A challenger is **accepted automatically** and a challenger is **rolled back
automatically**, both unattended, on one resident model with zero swaps.
- The same gate state machine drives both the default and the resourced ladder.
- No held-out `TaskId` reaches the proposer or any gate.
- Promotion is blocked when calibration is missing or stale.
## Verify
**Harness:** mock judges from T5.6; a `screen` stub that can emit one
`CoreViolation` on demand; the held-out interception layer from T6.6; a
human-intervention hook that records if it is ever called.
**Integration test** — `tests/it_p6_composition.rs`:
1. **Unattended accept:** full cycle with the 70% judge. Assert promotion, and
assert the intervention hook recorded **zero** calls.
2. **Unattended rollback:** fire one `CoreViolation` post-promotion. Assert
rollback without human action, the prior version restored with **no gap**
where neither is live, and the failed version still in the DAG with results.
3. **Calibration interlock:** clear the calibration result, attempt promotion,
assert it is **blocked**. Then stale it past its window; assert still blocked.
Then refresh; assert it proceeds.
4. **Held-out audit across the whole loop:** assert zero held-out `TaskId`s in
proposer input and in every gate evaluation, measured over the complete cycle
rather than a single gate call. Include T6.6's negative control so the audit
is known to be capable of failing.
5. **Ladder parity:** run steps 12 under the resourced ladder. Assert the same
state machine and the same rollback trigger — assert on the recorded rung
transitions, so a forked implementation is visible.
6. **Trigger, not timer:** advance simulated time with no rejection; assert zero
generate-loop triggers. Then reject without replacement; assert exactly one.
7. **Stall detector:** with no promotion pending, advance the schedule; assert the
held-out report is still emitted.
8. **Zero swaps** across the entire cycle.
9. **Regression:** re-run P0P5 gates and all P6 suites.
**Command:** `cargo test -p loop --test it_p6_composition -- --test-threads=1 --nocapture`
**False pass:**
- A cycle driven by test code that nudges the loop between rungs. That is the
human action the word "unattended" excludes — step 1's intervention counter is
the guard, and it must wrap every manual entry point, not just one.
- Step 3 omitted: calibration then exists as a dashboard number gating nothing,
which is precisely the failure mode it was introduced to prevent.
- Step 5 omitted: the resourced ladder quietly becoming a second implementation
means every fix to the default path has to be made twice, and will not be.
- Step 4 evaluated at one gate call rather than across the cycle — the proposer
leak is the invisible one.
## Traps
- Declaring the gate green with the tournament enabled. The default path is what
ships; assert the default cycle first.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §12, §15, §16 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+103
View File
@@ -0,0 +1,103 @@
# T7.1 — Postgres `EventLog`
| Field | Value |
|---|---|
| Phase | P7 — Distribution |
| Size | L — over 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
The same `EventLog` port over Postgres, partitioned by tenant and time. Not a
second port — the same one.
## Facts (inlined — no spec read needed)
- Two deployment modes, **one set of ports**:
| Mode | Log + state | Blobs | Coordination |
|---|---|---|---|
| **Embedded** — single binary, no services | `redb` | `redb` table | in-process |
| **Distributed** — multi-node, multi-tenant | Postgres | S3-compatible | Postgres advisory locks, or Redis if leases dominate |
- Postgres transactions supply crash-atomicity exactly as `redb`'s shadow paging
does; `commit`'s four writes stay in one transaction.
- This is why the port was async from the first line: a sync signature over a
network call is impossible, and the local implementation paid only a negligible
poll for the compatibility.
- **Every port test runs against both backends or the abstraction is not real.**
The conformance suite from T0.5/T0.7 is the deliverable being reused here.
- Open at this scale: partitioning by tenant and time, index strategy for branch
scans, and whether the outbox is a table or a logical replication slot. Decide
and record the decision; it is not pre-settled.
## Steps
1. Schema: log table keyed `(tenant, run, branch, lsn)`; state, consumer position
and outbox tables mirroring the embedded key shapes.
2. Partition the log by tenant and by time. Choose declarative partitioning and
document the retention interaction with T8.4's tiering.
3. Index for the access patterns that exist: branch scans from an LSN, recent
runs by `RunId` prefix (ULIDs sort by creation time), outbox drain per
`BranchKey` ascending `Lsn`.
4. Implement `commit` as one transaction with all four writes. LSN allocation
stays inside the transaction — a sequence is not sufficient, since LSNs are
per branch and must be gap-free.
5. Implement the checkpoint and outbox methods against the same schema.
6. Run the **unmodified** P0 conformance suite against this backend in CI.
7. Record the outbox decision (table vs replication slot) with its reasoning.
## Acceptance
- The P0 conformance suite passes **unmodified** against Postgres.
- Every port test runs against both backends.
## Verify
**Harness:** the **same** conformance function from T0.5, instantiated with a
Postgres factory. A real Postgres in CI (testcontainers or a service container) —
not an in-memory fake, which would test nothing about the backend.
**Integration test** — `tests/it_postgres_conformance.rs`:
1. Call `conformance(|| PostgresEventLog::new(...))` with **zero modifications**
to the suite. Any needed change is a finding about the port, not the test.
2. Run the identical suite against `redb` in the same CI job, so a divergence
between backends fails immediately rather than months later.
3. **Concurrent LSN allocation** across separate **connections** — the redb test
used tasks; here contention is across processes and is the real case. Assert
`1..=n` with no gaps or repeats.
4. Assert `commit`'s four writes are in one transaction: kill the connection
mid-commit and assert all-or-nothing on reconnect.
5. Partitioning: insert across several tenants and time ranges; assert a branch
scan hits the expected partitions (check the query plan, not just the result).
6. Outbox: assert `drain_outbox` returns `(BranchKey, Lsn)` ascending across
partition boundaries.
7. Record the outbox decision (table vs replication slot) and assert the chosen
one is the one under test.
**Command:** `cargo test -p storage-postgres --test it_postgres_conformance`
**False pass:**
- A modified copy of the conformance suite. The moment it is edited "just for
Postgres", the two backends are no longer proven equivalent — which is the
entire acceptance criterion.
- Step 3 with a single connection, where the sequence is uncontended.
- A `SERIAL`/sequence-backed LSN. It passes casual reads and is neither per-branch
nor gap-free under rollback.
- Running against SQLite or an in-process fake for CI speed.
## Traps
- A Postgres-only method added to the trait "temporarily". The embedded backend
then has a stub and the abstraction is fiction.
- A global sequence for LSNs. It serializes every run through one atomic and
breaks per-branch gap-freedom.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §7, §8.3, §18 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+91
View File
@@ -0,0 +1,91 @@
# T7.2 — Object-store `BlobStore`
| Field | Value |
|---|---|
| Phase | P7 — Distribution |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
S3-compatible `BlobStore`, tenant-namespaced, passing the same conformance suite
as the embedded implementation.
## Facts (inlined — no spec read needed)
- Same port as T0.7: `put` / `get` / `delete`, all async, all taking `TenantId`.
- **Blobs are namespaced per tenant even though they are content-addressed.**
Global deduplication across tenants is a leak: a shared blob makes one tenant's
storage accounting depend on another's, and a hash becomes an oracle for "does
anyone else have this content". Deduplicate within a tenant, never across.
- `delete` is not optional — reduction (T8.4) and tenant deletion both require
it. A store that cannot delete cannot honour either.
- `get` returning `None` is a normal answer, not an error.
- Cross-tenant isolation must be verified by **attempting** a cross-tenant fetch,
not by reading the key-construction code.
## Steps
1. Implement over an `object_store`-style client. Key layout puts the tenant in
the path prefix: `{tenant}/{blake3-hash}`.
2. `put`: hash, then conditional put (skip if present) — dedup falls out of the
content-addressed key, scoped by the tenant prefix.
3. `get`: fetch, map a not-found response to `Ok(None)`, everything else to an
error.
4. `delete`: delete the object. A missing object is not an error — reduction can
run twice.
5. Set retry and timeout policy on the client explicitly. A blob fetch stalling
forever inside a verifier is a hang the deadline should catch, but the client
should not depend on that.
6. Run the T0.7 conformance suite unmodified, plus the explicit cross-tenant
fetch attempt.
## Acceptance
- Same conformance suite as the embedded store passes.
- Tenant isolation verified by **attempting a cross-tenant fetch**.
## Verify
**Harness:** the T0.7 conformance function against MinIO or an S3-compatible
container. Same suite, unmodified.
**Integration test** — `tests/it_object_store_conformance.rs`:
1. Run the T0.7 suite verbatim against the object store.
2. **Cross-tenant attempt:** with tenant B's credentials/prefix, attempt to fetch
tenant A's ref. Assert it fails or returns `None` — attempt it, do not infer
isolation from the key-construction code.
3. Assert identical content under two tenants produces **two objects**, verified
by listing the bucket, not by the return values.
4. Assert a 404 on `get` maps to `Ok(None)` and a 404 on `delete` maps to
`Ok(())`, so reduction is idempotent.
5. Re-hash every stored object against its key.
6. Latency/failure behaviour: inject a slow response; assert the configured
timeout fires rather than hanging a verifier indefinitely.
7. Run the same suite against `redb` in the same job to catch backend divergence.
**Command:** `cargo test -p storage-s3 --test it_object_store_conformance`
**False pass:**
- Step 2 performed with the same credentials for both tenants. That tests the key
prefix, not isolation. Use separate scoped credentials where the deployment
will.
- Step 3 asserting on the returned refs, which are equal by design (same content
hash). The **bucket listing** is what shows two objects.
- Mocking the object store. The failure modes worth catching here — eventual
consistency, 404 shapes, timeouts — exist only in a real implementation.
## Traps
- A flat bucket layout with the tenant in metadata rather than the key. It
dedups across tenants by construction.
- Treating a 404 on delete as a failure, which breaks idempotent reduction.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §3, §7, §8.6 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+101
View File
@@ -0,0 +1,101 @@
# T7.3 — Leases and fencing
| Field | Value |
|---|---|
| Phase | P7 — Distribution |
| Size | L — over 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
TTL leases with heartbeat renewal and monotonic fence tokens, so a partitioned
worker cannot write under an expired claim.
## Facts (inlined — no spec read needed)
- Runs are claimed by workers under a lease with a TTL. **A worker that dies has
its runs reclaimed after expiry.**
- Lease renewal is a heartbeat on the run record.
- **`Suspended` runs release their lease entirely** rather than heartbeating
through a human's lunch break. A run awaiting human approval or a webhook must
release its worker; holding an executor slot across a human decision does not
scale past a handful of concurrent runs.
- **Fencing tokens on every lease.** A partitioned worker that resumes must not
write under an expired claim, and a **monotonic fence in the run record makes
that a rejected write rather than a silent double-execution**.
- Coordination backend: Postgres advisory locks, or Redis if lease churn
dominates. Redis is deferred until that churn is real.
## Steps
1. Add to the run record: `lease_owner`, `lease_expires_at`, `fence: u64`.
2. Claim: atomic conditional update — claim only if unowned or expired.
Increment `fence` on every successful claim. The fence is monotonic per run.
3. The worker carries its fence value in memory and includes it on **every**
write. Writes compare against the stored fence and are rejected when lower.
4. Heartbeat renews `lease_expires_at` on an interval well inside the TTL. A
failed renewal cancels the local `RunScope` (T1.1) rather than continuing
optimistically.
5. On transition to `Suspended`, release the lease outright — clear the owner and
stop heartbeating. On resume, re-claim, which takes a new fence.
6. Reclaim path: a run whose lease expired is claimable by any worker.
7. Test the full sequence: partition, expiry, reclaim elsewhere, reconnect the
original, assert its writes are **rejected by fence**.
## Acceptance
- Partition a worker, let its lease expire, reclaim the run elsewhere, then
reconnect the original — its writes are **rejected by fence, not merely late**.
## Verify
**Harness:** two worker processes against one Postgres, plus `turmoil` or an
iptables-style partition for the network half. `tokio::time::pause` cannot be
used across processes — use a short real TTL instead.
**Integration test** — `tests/it_leases_fencing.rs`:
1. Worker A claims run R (fence = 1) and begins executing.
2. Partition A from the database. Let the lease expire.
3. Worker B reclaims R; assert its fence is **2**.
4. Heal the partition. Worker A attempts a write under fence 1.
5. Assert the write is **rejected by the fence check** — and assert the rejection
reason is the fence, not a timestamp comparison and not a generic conflict.
6. Assert R completed **exactly once**: check the external side-effect ledger for
a single entry.
7. Assert A's failed heartbeat **cancelled its local `RunScope`**, so it stopped
working rather than continuing optimistically.
8. `Suspended`: transition a run to `Suspended`; assert the lease is **released**
(owner cleared) and that no heartbeat is emitted while suspended. Resume and
assert a **new, higher** fence.
9. Monotonicity: 100 claim/expire cycles; assert the fence never decreases or
repeats.
**Command:** `cargo test -p distribution leases -- --test-threads=1`
**False pass:**
- Step 5 satisfied by a timestamp check ("your lease expired"). Under clock skew
that check can pass for a stale worker; the **fence comparison** is what makes
it clock-independent. Assert the reason.
- Step 6 omitted: both workers can execute, both can be "correct", and the
double-execution is only visible in the external ledger.
- A per-worker fence counter rather than per-run monotonic — step 9 with two
workers alternating catches it.
- Step 8 verified by reading a flag rather than asserting heartbeats stop.
## Traps
- Checking only `lease_expires_at` at write time. Clock skew makes "not expired
yet" a lie, and the fence is what makes the check independent of clocks.
- Heartbeating through `Suspended`. It burns a worker slot for the duration of a
human decision.
- A per-worker fence rather than per-run monotonic. Two workers then produce
incomparable tokens.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §5.2, §7, §9.4 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+98
View File
@@ -0,0 +1,98 @@
# T7.4 — Outbox relay
| Field | Value |
|---|---|
| Phase | P7 — Distribution |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Ship committed export intents out of process, with its own retry and its own
failure domain. The execution path never calls a broker.
## Facts (inlined — no spec read needed)
- **The framework never calls a broker from the execution path.** Export intent
is written in the same transaction as the state (T0.6); a separate relay reads
committed intents and ships them.
- This makes export restartable, keeps a broker outage from stalling a run, and
is **the only pattern that survives a crash between "state committed" and
"event published"**.
- **A user's broker being down is not an agent outage.**
- The relay reads through `drain_outbox` / `ack_outbox`. Ordering is per
`BranchKey`, ascending `Lsn` — that is the only ordering the system promises,
and it falls out of the outbox key shape.
- **Delivery is at-least-once. Exactly-once is achieved at the fold, not in
transport**: `(BranchKey, Lsn)` is the natural idempotency key, so a redelivered
record is a no-op insert.
- Only the live branch is exported (T2.3).
## Steps
1. Run the relay as its own process or task with its own supervision — separate
failure domain is the point, so do not co-locate it with the executor's
lifecycle.
2. Loop: `drain_outbox(tenant, limit)` → publish in `(BranchKey, Lsn)` order →
`ack_outbox(shipped)`. Ack only what the broker accepted.
3. Retry with backoff inside the relay. Never propagate a broker error into run
execution — there is no path back by construction, so verify none is added.
4. Set the broker partition key from `(TenantId, RunId)` (T7.5).
5. Make the consumer-side fold idempotent on `(BranchKey, Lsn)` so redelivery is
a no-op insert.
6. Emit relay lag as a metric — outbox backlog is one of the two that grow
silently.
7. Test with the broker down for an entire run.
## Acceptance
- Broker down for the whole run; **the run completes**; every event ships after
the broker returns, **in order, exactly once at the fold**.
## Verify
**Harness:** a broker that can be held down for the whole test, plus a consumer
that folds on `(BranchKey, Lsn)` so redelivery is observable.
**Integration test** — `tests/it_outbox_relay.rs`:
1. Take the broker **down**. Run a full run to completion.
2. Assert the run **completed normally** — no stall, no error, and its latency is
comparable to a broker-up baseline. A user's broker being down is not an agent
outage.
3. Bring the broker up. Assert every event ships.
4. Assert ordering **per `BranchKey`, ascending `Lsn`** at the consumer.
5. Force redelivery (ack loss): assert the consumer's folded state is identical
to the single-delivery case — exactly-once **at the fold**, not in transport.
6. Kill the relay mid-batch; restart; assert no event is lost and none is
duplicated at the fold.
7. Assert the execution path never calls the broker: instrument the broker client
and assert zero calls originate from executor threads.
8. Assert only the **live branch** is exported after a rewind.
9. Assert relay lag is emitted as a metric.
**Command:** `cargo test -p distribution outbox -- --test-threads=1`
**False pass:**
- Step 2 asserting only that the run completed. If the executor retries the
broker inline, the run completes — slowly. The latency comparison is what
catches it; step 7 is what proves it.
- Acking before the broker confirms: steps 3 and 4 still pass in a happy-path
test, and events vanish silently under failure. Step 6 is the guard.
- Testing ordering within one branch only, where any implementation is ordered.
Use several branches and several runs interleaved.
## Traps
- Publishing from the execution path "just for low latency". A broker outage then
stalls agent work.
- Acking before the broker confirms. Events are lost with no trace.
- Attempting transport-level exactly-once. The fold is where it is achievable.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §8.3, §9.2, §9.3, §13.3, §15 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+86
View File
@@ -0,0 +1,86 @@
# T7.5 — Partition keys on adapters
| Field | Value |
|---|---|
| Phase | P7 — Distribution |
| Size | S — under 1 day |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Every broker adapter sets the partition key from `(TenantId, RunId)`. Never from
a correlation id.
## Facts (inlined — no spec read needed)
- **Per-run total order, nothing promised across runs.** Downstream consumers
must therefore partition by run key.
- A **correlation id collapses unrelated runs onto one partition while splitting
single runs across several** — the exact inversion of the ordering contract.
- The same reasoning drives the outbox key shape: per `BranchKey`, ascending
`Lsn`, which is what gives the relay its defined order.
- Ordering guarantees are only as strong as the weakest adapter, so this applies
to every adapter shipped, not just the first one.
## Steps
1. Define one helper that derives the partition key from `(TenantId, RunId)` and
make every adapter call it. A single function is what makes this auditable.
2. Remove any adapter parameter that lets a caller supply their own partition
key. If one is needed for an external contract, name it explicitly and
document that ordering is then the caller's problem.
3. Wire the helper into the relay's publish path (T7.4).
4. Test: two runs sharing a correlation id land on different partitions.
5. Test: all events of one run land on a single partition — assert across a run
that spans branches and attempts.
## Acceptance
- Two runs sharing a correlation land on **different partitions**.
- One run's events **never split across partitions**.
## Verify
**Harness:** a broker (or fake) exposing the partition assignment per message.
**Integration test** — `tests/it_partition_keys.rs`:
1. Two runs sharing one correlation id. Assert their events land on **different**
partitions.
2. One run spanning multiple branches, attempts and steps. Assert **every** event
lands on a **single** partition.
3. Two tenants with colliding `RunId` values. Assert they do not share a
partition — the tenant must be in the key.
4. Assert every shipped adapter derives its key through the one shared helper —
an audit test enumerating adapters and asserting each calls it.
5. Assert no public adapter parameter allows a caller-supplied partition key; if
one exists for an external contract, assert it is separately named and
documented.
6. Stability: the same `(TenantId, RunId)` maps to the same partition across
process restarts.
**Command:** `cargo test -p distribution partition_keys`
**False pass:**
- Step 2 with a short run whose events happen to hash to one partition regardless.
Use a run long enough that a per-event key would demonstrably split it, and
assert the partition set has size exactly 1.
- Step 1 passing by coincidence with only two runs — use 50 correlated pairs and
assert the distribution is spread.
- Step 4 omitted: one adapter doing it correctly proves nothing about the next
one added, and ordering is only as strong as the weakest adapter.
## Traps
- Using a correlation id because it is already threaded through the request. It
is the natural choice and it breaks the only ordering promise the system makes.
- Hashing `RunId` alone without the tenant. Two tenants can then collide on one
partition and, worse, the key stops being tenant-scoped.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §8.3, §9.2 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+112
View File
@@ -0,0 +1,112 @@
# T7.6 — Tournament as a join stage
| Field | Value |
|---|---|
| Phase | P7 — Distribution |
| Size | L — over 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Repartition from the run key to the group key, close groups on a completeness
trigger, and cap burst concurrency so grading cannot starve execution.
## Facts (inlined — no spec read needed)
```
ingest ──► execute ──► verify ──► tournament ──► aggregate ──► decide
│ │ │ │ │ │
(T,Run) (T,Run) (T,Run) (T,Task,Class, (T,Variant) (T,Workflow)
Epoch)
▲ ▲ ▲
shuffle 1 shuffle 2 single writer
```
- Three keys, two shuffles, one single-writer stage. Everything up to
verification keys on `(TenantId, RunId)` and is embarrassingly parallel.
- **Grading is a join** — a comparison group must be co-located — keyed on
`(TenantId, TaskId, VerifierOutcome, GroupEpoch)`: the **outcome class**
because brackets exist only within one, and the **epoch** because a closed
group never reopens for a late arrival.
- The decide stage is single-writer per `(TenantId, WorkflowId)`; a CAS on a
generation counter is sufficient (T6.1).
- A group closes on **quorum or timeout**, grading whatever arrived, with group
size attached to the confidence interval (T5.11).
- **Grading yields to agent work.** When both contend, agent inference wins and
grading queues — a framework that lets a judge call delay the work it is
judging has inverted its first principle.
- Tournament and reduction backlogs are the two lags that grow silently, so both
are metered.
## Steps
1. Implement the repartition from `(TenantId, RunId)` to
`(TenantId, TaskId, VerifierOutcome, GroupEpoch)` after verification.
2. Maintain group state per key: members, open/closed, quorum target, deadline.
3. Completeness trigger: close on quorum reached **or** deadline passed.
Increment `GroupEpoch` at closure; route later arrivals to the next epoch.
4. Cap concurrent tournament work with a semaphore sized independently of the
executor's. Make the cap and its saturation **observable in metrics**.
5. Give agent inference priority over grading when both want the same resident
model (enforced fully in T8.6).
6. Emit stage-boundary lag for the tournament stage.
7. Test with a 64-episode group while runs are executing, asserting execution
throughput is not degraded past a bound.
## Acceptance
- A 64-episode group **does not starve run execution**.
- The cap is **observable in metrics**.
## Verify
**Harness:** a load generator producing runs continuously while a large group
fills, so starvation is observable rather than theoretical.
**Integration test** — `tests/it_tournament_join.rs`:
1. Establish a baseline: run-execution throughput and agent-call latency with no
grading load.
2. Trigger a **64-episode group**. Assert execution throughput and agent latency
stay within a configured bound of baseline — assert on the **numbers**, not on
the absence of an error.
3. Assert the concurrency cap is **observable in metrics**: read the saturation
gauge and assert it reflects the burst.
4. Group key: assert repartition uses all four components. Feed episodes
differing only in `VerifierOutcome`; assert they land in **different** groups.
Repeat for `GroupEpoch`.
5. Closure: a group that never reaches quorum closes on **timeout**, with size
recorded, and `GroupEpoch` increments.
6. Late arrival after closure lands in epoch N+1; epoch N's stored strengths are
unchanged.
7. Single-writer decide stage: two schedulers attempt to adjust allocation for
one `(TenantId, WorkflowId)`; assert CAS behaviour (cross-check T6.1).
8. Emit and assert stage-boundary lag for the tournament stage.
**Command:** `cargo test -p distribution tournament_join -- --test-threads=1`
**False pass:**
- Step 2 asserting only that both finished. Grading that delays agent work still
finishes — the latency and throughput comparison against baseline is the test,
because the principle being enforced is a priority, not a liveness property.
- Sharing the executor's concurrency limiter with grading: step 2 may still pass
under light load and the cap becomes invisible. Step 3 is the guard.
- Step 4 omitted: dropping `VerifierOutcome` from the key puts passes and
failures in one bracket, which produces plausible results and violates the one
rule with no exemptions.
## Traps
- Omitting `VerifierOutcome` from the group key, which puts passes and failures
in one bracket — the one rule with no exemptions.
- Omitting `GroupEpoch`, which lets a late arrival reopen a published fit.
- Sharing the executor's concurrency limiter with grading. Then a burst of
grading is indistinguishable from a burst of work.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §9.1, §11.6, §11.8, §14.2, §15 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+109
View File
@@ -0,0 +1,109 @@
# T7.7 — `turmoil` suite
| Field | Value |
|---|---|
| Phase | P7 — Distribution |
| Size | L — over 3 days |
| Status | Not started |
| Flags | parallel-ok |
| Spec | inlined below |
| Blocks | — |
## Goal
Network partition, latency and node loss simulated deterministically. This is the
P7 gate.
## Facts (inlined — no spec read needed)
- Test tooling and what each covers:
- **`turmoil`** — network partition and latency simulation.
- **`loom`** — the lock-free bits.
- **`tokio::time::pause`** — time.
- Together these are **weaker than a seeded scheduler and sufficient with
discipline**. The residual risk is stated honestly rather than engineered
around: tokio's cancellation is cooperative, so a `select!`-dropped future
stops at its next await and not before.
- What the suite must prove: **no double-execution and no lost run under
partition, with automatic recovery**.
- Double-execution is prevented by fencing (T7.3), not by the partition never
happening. This suite is what demonstrates the fence actually fires.
- `parallel-ok`: this task does not gate others and can be built alongside the
rest of P7.
- Open: whether a lint, a wrapper type, or a `loom` harness is the right
enforcement for cancellation rigour under tokio. Unresolved — do not invent a
fourth mechanism here.
## Steps
1. Stand up a two-node topology under `turmoil`, both nodes claiming runs from
the same Postgres backend.
2. Partition scenario: isolate node A mid-run, let its lease expire, watch node B
reclaim, then heal the partition and assert node A's writes are fenced off.
3. Latency scenario: inject delays that push heartbeats close to the TTL, and
assert renewals either succeed or cancel the local scope — never a silent
continue.
4. Node-loss scenario: kill a node outright; assert the run is reclaimed and
completes exactly once.
5. Add `loom` coverage for any lock-free structure introduced in P7.
6. Use `tokio::time::pause` for all TTL and deadline arithmetic so the suite runs
in seconds.
7. Print per-scenario progress and enforce per-scenario timeouts — a suite that
prints nothing cannot distinguish slow from hung.
## Acceptance
- **No double-execution and no lost run under partition; recovery is automatic.**
## Phase gate
P7 closes on two nodes surviving a partition with no double-execution.
## Verify
**Harness:** `turmoil` two-node topology; `loom` for any lock-free structure;
`tokio::time::pause` for TTL arithmetic; the external side-effect ledger as the
double-execution witness.
**Integration test** — `tests/it_turmoil.rs`:
1. **Partition:** isolate node A mid-run. Assert node B reclaims after lease
expiry, the run completes, and the ledger shows **exactly one** effect per
intended effect.
2. Heal the partition; assert A's writes are fenced (cross-check T7.3) and that A
does not resume executing.
3. **Latency:** inject delays that push heartbeats close to the TTL. Assert
renewals either succeed or **cancel the local scope** — assert no case where a
worker continues past a failed renewal.
4. **Node loss:** kill a node outright. Assert the run is reclaimed and completes
exactly once.
5. Assert **no lost run**: every spawned run reaches a terminal state across all
scenarios; count spawned vs terminal.
6. Recovery is **automatic** — assert no manual intervention hook was invoked.
7. `loom` test for each lock-free structure added in P7.
8. Per-scenario progress output and per-scenario timeouts.
9. Print the seed; a failure that cannot be replayed is not a finding.
**Command:** `cargo test -p distribution --test it_turmoil -- --nocapture`
**False pass:**
- Asserting the run completed. **Completing twice also completes** — the ledger
count is the only assertion that distinguishes them, and double-execution is
the primary risk this suite exists to retire.
- Real `sleep` for TTL expiry, making the suite slow enough that it stops being
run — which is the same as not having it.
- Step 5 omitted: a run that is silently dropped during a partition leaves no
error, and every other assertion passes.
- Testing only the partition scenario. Node loss and slow-network cases exercise
different reclaim paths.
## Traps
- Asserting only that the run completes. Completing twice also completes.
- Real sleeps for TTL expiry, which makes the suite too slow to run and therefore
unrun.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §6, §9.4, §18, §19 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+111
View File
@@ -0,0 +1,111 @@
# T7.8 — P7 composition gate
| Field | Value |
|---|---|
| Phase | P7 — Distribution |
| Size | L — over 3 days |
| Status | Not started |
| Flags | gate |
| Spec | inlined below |
| Blocks | P8 |
## Goal
Prove **deployment mode is interchangeable**: the same behavioural suite passes
in embedded mode and distributed mode, and two nodes survive a partition with no
double-execution.
**Phase gate criterion:** two nodes surviving a partition with no
double-execution.
## Facts (inlined — no spec read needed)
- **Two deployment modes, one set of ports.**
| Mode | Log + state | Blobs | Coordination |
|---|---|---|---|
| **Embedded** — single binary, no services | `redb` | `redb` table | in-process |
| **Distributed** — multi-node, multi-tenant | Postgres | S3-compatible | Postgres advisory locks, or Redis if leases dominate |
- **Embedded mode is a first-class product, not a test harness.** That constraint
is what keeps the ports honest — and it is only enforced if the same suite runs
in both modes.
- The properties no single P7 task owns:
- a run that survives a partition must also survive it **while grading is in
flight** — leases, the join stage and the outbox interact;
- the outbox relay must ship correctly **across a reclaim**: entries committed
by node A, relayed after node B takes over;
- **partition keys and per-run ordering must hold across a node handover**
the same run's events must not split partitions because a different node
published them;
- exactly-once is achieved **at the fold**, not in transport, so redelivery
during a partition must be a no-op.
- Per-run total order, nothing promised across runs. Everything up to
verification is embarrassingly parallel; grading is a join; decide is
single-writer.
## Steps
1. Extract the behavioural suite — P1's composition matrix plus P4's and P5's
gates — into a mode-parameterized harness.
2. Run it in embedded mode and in distributed mode. Both must pass unmodified.
3. Build the compound distributed scenario: partition **during** grading, with
the broker down, across a lease handover.
4. Assert exactly-once effects and no lost runs across every scenario.
5. Make this the required CI job gating P8.
## Acceptance
- The behavioural suite passes **unmodified** in both deployment modes.
- Two nodes survive a partition with **no double-execution** and no lost run.
- The outbox ships every event in order across a lease handover, exactly once at
the fold.
## Verify
**Harness:** `turmoil` two-node topology; real Postgres and MinIO; the external
side-effect ledger as the double-execution witness; a consumer that folds on
`(BranchKey, Lsn)`.
**Integration test** — `tests/it_p7_composition.rs`:
1. **Mode interchange:** parameterize the behavioural suite over
`[embedded, distributed]`. Both pass with **zero** suite edits. Any
`if mode == ...` branch inside the suite is a finding about the ports.
2. **Compound scenario:** start a run, begin grading, take the broker down,
partition node A mid-step. Assert node B reclaims, the run and its grading
complete, and the ledger shows **exactly one** effect per intended effect.
3. **Relay across handover:** assert outbox entries committed by A are shipped
after B takes over — in `(BranchKey, Lsn)` order, exactly once at the fold.
4. **Partition-key stability across nodes:** assert every event of the handed-over
run landed on **one** broker partition, despite two different nodes publishing.
5. **Fencing under load:** heal the partition while B is executing. Assert A's
writes are rejected by fence and A does not resume.
6. **No lost runs:** across all scenarios, count runs spawned versus runs reaching
a terminal state. Assert equality.
7. **Grading yields:** during the scenario, assert agent-call latency stays within
bound of baseline while the join stage is saturated.
8. **Regression:** re-run P0P6 gates in embedded mode in the same job.
**Command:** `cargo test -p distribution --test it_p7_composition -- --test-threads=1 --nocapture`
**False pass:**
- A mode-parameterized suite containing mode-specific branches. At that point the
two modes are not proven equivalent, which is the entire acceptance criterion —
and embedded mode is a shipped product, not a fixture.
- Asserting runs completed. **Completing twice also completes**; only the external
ledger distinguishes them.
- Step 3 tested without a handover, where the relay never changes owner — that is
T7.4's test, already green.
- Step 6 omitted: a run silently dropped during a partition produces no error and
passes every other assertion here.
## Traps
- Skipping embedded mode in CI for speed. It is the mode that keeps the ports
honest, and it is the cheap one to run.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §7, §9 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+105
View File
@@ -0,0 +1,105 @@
# T8.1 — Metering
| Field | Value |
|---|---|
| Phase | P8 — Operability |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Attribute every model call to `(TenantId, RunId, Purpose)` and enforce separate
ceilings for judging and replay.
## Facts (inlined — no spec read needed)
- **A framework that spends users' model budget on grading must account for it.**
- Every model call — agent, judge, or proposer — is attributed to
`(TenantId, RunId, Purpose)`, where `Purpose` distinguishes **work** from
**grading** from **replay**.
- Per-tenant ceilings on grading spend are enforced **at the group boundary**,
where group size is known and a tournament can be skipped or downsampled before
it starts — not mid-tournament.
- **Replay is the expensive one and needs its own ceiling separate from
judging.** N variants × M tasks is N·M full agent runs plus judge calls.
- Grading cost is reported **next to grading value**. A loop that costs more than
the work it grades may still be worth running; it should never be an unpleasant
discovery.
- A skipped run must reach terminal `Ungraded` with `BudgetExhausted`, not rest
in `Grading`. **A cost ceiling that also strands storage is a bill that arrives
twice**, landing on the tenants least able to absorb it.
- The grading spend ratio ceiling itself is unset — metered here, policy elsewhere.
## Steps
1. Define `Purpose { Work, Grading, Replay }` and thread it through every model
call site. No default — the call site knows.
2. Record usage per call keyed `(TenantId, RunId, Purpose)`: tokens in/out, model,
and cost if a price table is configured.
3. Define per-tenant ceilings: one for grading, a **separate** one for replay.
4. Check the grading ceiling at the group boundary. Over the ceiling: skip or
downsample, record `UngradedReason::BudgetExhausted`, and transition the run to
terminal `Ungraded` (T5.11).
5. Never let a ceiling check touch the agent execution path. Work continues
regardless of grading spend.
6. Emit a report pairing grading cost with grading value per tenant.
7. Test: drive a tenant over its grading ceiling and assert agent throughput is
unchanged.
## Acceptance
- A tenant exceeding its grading ceiling has tournaments **skipped with a
recorded reason**, and **agent work continues unaffected**.
- The skipped runs reach terminal `Ungraded` rather than resting in `Grading`.
## Verify
**Harness:** a model provider wrapper recording `(TenantId, RunId, Purpose,
tokens)` for every call, plus a tenant configured with a **zero** grading ceiling
and a generous replay ceiling (and the reverse).
**Integration test** — `tests/it_metering.rs`:
1. Run a full cycle: agent work, grading, replay. Assert every recorded call
carries a `Purpose`, and that the three purposes are all present. Assert no
call is attributed to a default or unknown purpose.
2. **Ceiling separation:** with grading ceiling = 0 and replay ceiling high,
assert grading is skipped and replay still runs. Then invert and assert the
opposite. One shared ceiling fails one of these.
3. **Skip happens before spend:** assert model calls under `Purpose::Grading` is
**exactly 0** when over the ceiling — the check is at the group boundary,
before any call.
4. Assert agent throughput and latency are **unchanged** while the tenant is over
its grading ceiling; compare against a baseline run.
5. Assert skipped runs reach **terminal `Ungraded { BudgetExhausted }`** and that
T4.4's `is_reducible` returns true for them.
6. Assert the cost-versus-value report is produced per tenant.
7. Assert attribution survives a cold re-fold — the usage lives in the log, not
only in state.
**Command:** `cargo test -p operability metering`
**False pass:**
- Step 3 asserting only the recorded reason. Enforcing the ceiling **after** the
calls produces the correct reason and spends the money anyway.
- Step 5 omitted: this is the "bill arrives twice" case — the tenant is capped on
spend and then also accumulates storage forever because the run never leaves
`Grading`.
- Step 2 omitted: a single ceiling passes every other assertion here, then replay
consumes the whole budget in production.
## Traps
- One shared ceiling for judging and replay. Replay dominates and consumes it.
- Checking the ceiling mid-tournament, which pays for half a group and produces
nothing.
- Leaving budget-skipped runs in `Grading`. The cost ceiling then also strands
storage.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §10.3, §11.6, §12.4, §14.1 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+118
View File
@@ -0,0 +1,118 @@
# T8.2 — Metrics
| Field | Value |
|---|---|
| Phase | P8 — Operability |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
The full observability list. The framework observes agents; it must also be
observable.
## Facts (inlined — no spec read needed)
The list, with the reason each one exists:
- **Kernel state transitions**, tagged by tenant and workflow version.
- **Lag on every stage boundary** of `ingest → execute → verify → tournament →
aggregate → decide`. **Tournament and reduction backlogs are the two that grow
silently.**
- **`Indeterminate` attempt count as a first-class alert.** It should be near
zero; a nonzero rate means either a crash loop or a misdeclared effect class.
**This threshold only holds because `Cancelled` is a separate state** — route
cancellations here and the alert has a noisy floor, which is the same as not
having it.
- **`Ungraded` run count by reason**, separately from `Graded`. A tenant whose
runs are mostly `InsufficientGroup` has a loop that is not engaging, and that
reads as healthy on any dashboard that only counts failures.
- **Judge order-inconsistency rate**, measured on the sampled fraction under the
alternating-order scheme (T5.7).
- **Judge-versus-verifier agreement on the calibration set** (T6.2). Under one
resident model the judge is the agent's model, and this is the only instrument
that can see the grader drifting toward its own bias.
- **Model residency and swap count.** A nonzero swap rate on a single-model
deployment means something is requesting a non-resident model, and the load
cost will dominate everything else in the trace.
- **Admission refusals by reason, separating "capacity" from "budget".** They
look identical in a queue-depth graph and have opposite fixes.
- **Held-out versus selection gap** as the overfitting alarm (T6.6).
- **Trace context propagated through `Ctx`, never through task-locals.**
## Steps
1. Pick the metrics facade once and use it everywhere. Define the tag set —
tenant, workflow version, purpose — as a shared helper so tags stay consistent.
2. Emit a counter per kernel transition from the transition function itself, so
no call site can forget.
3. Instrument each stage boundary with a lag gauge measured at dequeue: now minus
the record's commit timestamp.
4. Emit `Indeterminate` and `Ungraded`-by-reason as separate series. Do not
collapse `Ungraded` reasons into one counter.
5. Wire the grading-health metrics from T5.7 and T6.2.
6. Emit residency and swap counters from the capacity layer (T5.2/T8.6).
7. Split admission refusals into `capacity` and `budget` reasons at the refusal
site.
8. Propagate trace context through `Ctx`. Audit for `task_local!` and remove.
9. Test: stall the reducer deliberately and assert it is visible within one
scrape interval.
## Acceptance
- **Every stage boundary emits lag.**
- **A stalled compactor is visible within one scrape interval.**
## Verify
**Harness:** an in-process metrics recorder the test can scrape, plus a
deliberately stalled reducer.
**Integration test** — `tests/it_metrics.rs`:
1. **Completeness audit:** assert one metric exists per item in this file's list.
Drive it from an enumerated list of expected metric names so a missing one
fails rather than going unnoticed.
2. **Stage lag:** run work through all six stages; assert **each** boundary emits
a lag value. A stage emitting nothing must fail.
3. **Stalled reducer:** stop the reducer, keep producing work, scrape once.
Assert the backlog is visible **within one scrape interval** — assert on the
scraped value, not on the internal counter.
4. Assert `Indeterminate` count is its own series and that a cancelled attempt
increments **`Cancelled`, not `Indeterminate`** — the alert's near-zero
threshold depends entirely on this.
5. Assert `Ungraded` is emitted **by reason** as separate series; a single
`ungraded_total` fails.
6. Assert admission refusals split into `capacity` and `budget`; produce one of
each and assert two distinct series move.
7. Assert every metric carries tenant and workflow-version tags where the list
says so.
8. Assert trace context propagates across a spawn boundary through `Ctx`; grep
for `task_local!` in kernel crates and assert none.
**Command:** `cargo test -p operability metrics`
**False pass:**
- Asserting metrics exist without asserting they **move**. A registered-but-never-
incremented counter passes existence checks and reads as a healthy zero.
- Step 3 measured on the internal counter rather than a scrape. The stall may be
visible internally and never exported.
- Step 4 omitted: routing cancellations into `Indeterminate` produces a perfectly
functional metric with a permanent noisy floor, which quietly retires the
alert.
- Step 6 omitted: capacity and budget refusals look identical in a queue-depth
graph and have opposite fixes.
## Traps
- One `ungraded_total` counter. The reason is the whole signal.
- Merging capacity and budget refusals. Opposite fixes, identical graph.
- Trace context in a task-local. It survives until the first spawn boundary.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §5.1, §9.1, §15 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+114
View File
@@ -0,0 +1,114 @@
# T8.3 — Keyed capability
| Field | Value |
|---|---|
| Phase | P8 — Operability |
| Size | L — over 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Make an `Idempotent` declaration enforceable: the kernel derives and supplies the
idempotency key on every call, and a write issued without it is denied.
## Facts (inlined — no spec read needed)
- **A framework cannot trust a user-supplied `EffectClass`.** A tool declared
`Idempotent` that is not will be retried after an indeterminate crash, and the
damage is the user's data.
- Three layers, in order of strength:
1. **Declaration** — required, recorded, auditable (T2.2).
2. **Keyed capability** — this task.
3. **Sandbox** — shadow and replay deny `Unsafe` outright (T6.5).
- **Layer 2 is the one that makes layer 1 more than paperwork.**
- **`Idempotent` is a claim about *retry*, not about abstaining from writes.**
Denying an `Idempotent` tool network and filesystem writes would deny the
recovery path itself, which retries the write under an idempotency key.
- So the restriction is on the **shape** of the write, not its existence: the
tool declares how its key derives from its arguments, the kernel derives and
supplies that key on every call, and **a write issued without it is denied**.
- **A tool that cannot derive a stable key cannot be `Idempotent`** — the same
claim as before, now refused at registration rather than discovered after a
double-charge.
```rust
pub enum EffectClass {
Idempotent { key: KeyDerivation },
Queryable { lookup: RequestIdLookup },
Unsafe,
}
```
## Steps
1. Make `KeyDerivation` a required payload on `Idempotent` — a registration
without one must **fail to compile**, not fail at runtime.
2. At call time, derive the key from the recorded arguments and pass it through
the capability handle the tool must use to write. The tool cannot mint its own.
3. Deny any write issued through a handle without the kernel-supplied key.
Denial is at call time, not a post-hoc audit.
4. Record every denial against the tool id, so a misbehaving tool is auditable
rather than merely blocked.
5. Ensure the recovery path (T2.2) re-derives the **same** key from the **same**
recorded arguments — that is what makes the retry safe.
6. Test the duplicate-call case explicitly: two calls with the same derived key
produce one effect.
## Acceptance
- A tool declaring `Idempotent` with a key completes its write and **survives a
duplicate call with no duplicate effect**.
- The same tool writing outside the kernel-supplied key is **denied at call
time**, with the denial **recorded against the tool id** for audit.
- A registration declaring `Idempotent` **without a derivation does not compile**.
## Verify
**Harness:** a test tool writing to the external side-effect ledger, keyed by
whatever key it is given. Plus `trybuild`.
**Integration test** — `tests/it_keyed_capability.rs`:
1. **Compile-fail:** an `Idempotent` registration with no `KeyDerivation`. Assert
the stderr names the missing field.
2. Happy path: the tool completes its write. Assert the ledger holds one entry
under the **kernel-supplied** key.
3. **Duplicate call:** invoke again with the same arguments. Assert the derived
key is identical and the ledger still holds **exactly one** entry.
4. **Out-of-band write:** the same tool attempts a write **not** using the
supplied key. Assert it is **denied at call time**, and that the denial is
**recorded against the tool id** — read the audit record back.
5. **Recovery is a write:** crash mid-call, restart, let T2.2's idempotent
recovery retry. Assert the retry **succeeds** — a blanket write denial would
break exactly this path, and it is the most likely wrong implementation.
6. Determinism: derive the key 100 times from the same recorded arguments; assert
all identical. Derive from arguments containing a timestamp field; assert the
derivation ignores it or the registration is rejected.
7. Assert `Queryable` and `Unsafe` tools are unaffected by this mechanism.
**Command:** `cargo test -p operability keyed_capability && cargo test -p operability --test compile_fail`
**False pass:**
- Implementing this as a blanket write denial for `Idempotent` tools. Steps 14
all pass; step 5 is the one that fails, and it is the whole reason the
restriction is on the **shape** of the write rather than its existence.
- Step 3 passing because the ledger deduplicates on content rather than key. Make
the ledger key-only, or the key derivation is never actually exercised.
- Step 4 asserting the denial without asserting the audit record. A denial nobody
can attribute to a tool is not actionable.
## Traps
- Blanket-denying writes to `Idempotent` tools. That denies the recovery path,
which is itself a write.
- A key derived from anything non-deterministic — a timestamp, a counter, a
random id. The retry then writes twice under two keys.
- Logging denials without the tool id, which leaves nothing to act on.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §8.4, §13.1, §13.2 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+142
View File
@@ -0,0 +1,142 @@
# T8.4 — Reduction and tiering
| Field | Value |
|---|---|
| Phase | P8 — Operability |
| Size | L — over 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Reduce episodes to a token ceiling, then move terminal reduced runs to cold
storage. Never rewrite a blob body; never edit a log record.
## Facts (inlined — no spec read needed)
Vocabulary, because the previous revision collided two of these: **`Archived`**
is a run state; **reduction** is the token-budget operation; **tiering** is the
move to cold storage.
- **Retention is measured in tokens**, because tokens are the currency of every
downstream consumer: what a replay costs, what fits in a judge's window, what
an export is billed at. Default ceiling 200k per run, per-tenant configurable.
- **On self-hosted weights that ceiling is not free to choose.** It is bounded by
`max_context_tokens / 2` (T5.2), because a pairwise judge reads two episodes
into one KV cache and two 200k episodes do not fit on any single device. Where
the derived bound is tighter, **the bound wins** and the judge reads a
further-reduced view.
Loss order is fixed:
| Kept | Reduced | Dropped |
|---|---|---|
| every transition record | blob bodies → summary blob | raw text on dead branches |
| context partitions, tool info, usage | dead-branch attempts → summary | |
| verifier results, grades | | |
- **Reduction never rewrites a blob and never edits the log.** Blobs are
content-addressed, so replacing a body under its existing ref makes the ref a
lie; repointing the log at a new ref is the history rewrite the append-only
rules forbid.
- Instead: write the summary as a **new** blob, append a
`Reduced { original: BlobRef, summary: BlobRef }` event, and **only then**
delete the original body. The reduction is a later fact about an earlier
record, not a change to it. `BlobStore::get` on the original returns `None`,
and the fold knows why and what stands in its place — so drop-and-re-fold from
LSN 0 still yields byte-identical state, which it would not if the mapping
lived only in the projection.
- The transition sequence always survives. What reduces is **text**, because it
dominates token count and is the only part with a cheap lossy representation. A
reduced episode can still be graded, attributed and structurally rewound — it
just cannot be replayed verbatim.
- **Tiering: moved, not copied**, with local rows deleted only after the remote
commit acknowledges.
- Eligibility is `Graded`/`Ungraded`/`Archived` (T4.4) — grading terminated.
- Open: what produces the summary. A model call makes reduction
non-deterministic, which interacts badly with replay; extractive or structural
reduction may suffice.
## Steps
1. Compute the effective ceiling as `min(configured_ceiling, max_context_tokens / 2)`
from T5.2. Never read the configured value alone.
2. Gate on T4.4's `is_reducible`. Reject, do not skip.
3. Reduce in the fixed loss order: blob bodies to summaries first, then
dead-branch attempts, then raw text on dead branches.
4. For each reduction: `put` the summary blob → `commit` the
`Reduced { original, summary }` event → **then** `delete` the original body.
That order survives a crash at any point.
5. Make the fold handle `Reduced` so a `None` from `get` is explained by the log.
6. Tiering: write to cold storage, wait for the remote commit acknowledgement,
then delete local rows. Crash between the two leaves a duplicate, not a loss —
so make the move idempotent.
7. Verify by re-hashing every surviving `BlobRef` against its body.
## Acceptance
- A run exceeding the ceiling reduces; **transitions survive intact**; a reduced
episode is **still gradeable**.
- Every surviving `BlobRef` still hashes to its content — asserted by
**re-hashing**, since a rewritten body type-checks silently.
- Drop the state tables and re-fold a reduced run from LSN 0: state is
**byte-identical**. This fails if the original→summary mapping lives anywhere
but the log.
- Tiering is **idempotent under a crash between remote commit and local delete**.
## Verify
**Harness:** a run built to exceed the token ceiling, T0.8's
`assert_refold_identical`, and a cold-storage stub that can fail between remote
commit and local delete.
**Integration test** — `tests/it_reduction_tiering.rs`:
1. Reduce a run over the ceiling. Assert the **transition sequence is intact**
compare the event list before and after; only `Reduced` events were added,
none removed or altered.
2. Assert the reduced episode is **still gradeable**: run it through the grading
path and assert a real `Score`.
3. **Re-hash every surviving `BlobRef`** against its body and assert equality. A
rewritten body type-checks silently and this is the only assertion that sees
it.
4. **The load-bearing test:** drop the state tables, re-fold the reduced run from
LSN 0, assert **byte-identical** state. This fails if the original→summary
mapping lives anywhere but the log.
5. Assert `get` on an original ref returns `Ok(None)` and that the fold explains
it via the `Reduced` event.
6. **Ordering under crash:** arm faults at each of the three points (after `put`,
after `commit`, after `delete`). Assert no state exists where a record points
at a deleted body with no `Reduced` event.
7. **Ceiling derivation:** configure a 200k ceiling with a hardware-derived bound
of 80k; assert the **bound wins** and reduction targets 80k.
8. **Tiering idempotence:** crash between remote commit and local delete; re-run.
Assert one copy remains, no data lost, operation converges.
9. Assert reduction refuses on a run in `Grading` (T4.4).
**Command:** `cargo test -p retention --features test-hooks reduction -- --test-threads=1`
**False pass:**
- Step 4 omitted. Storing the original→summary mapping in the projection makes
steps 13 pass perfectly and silently destroys drop-and-re-fold, which is the
property the whole durability design rests on.
- Step 3 replaced by "the ref still resolves". A rewritten body resolves fine.
- Step 7 omitted: reading the configured ceiling alone passes every test on
hardware nobody checked, and fails at the first real judge call.
- Step 6 with faults only after `delete`, which is the safe point.
## Traps
- Overwriting the blob body under its existing ref. It type-checks, it saves an
event, and it makes the ref a lie.
- Repointing the log record at the summary ref. That is a history rewrite.
- Deleting the original before the `Reduced` event commits. A crash then leaves a
ref with no body and no explanation.
- Reading the configured ceiling without the hardware-derived bound.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §8.6, §8.7, §10.3, §14.2, §18 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+94
View File
@@ -0,0 +1,94 @@
# T8.5 — Embedded-mode smoke
| Field | Value |
|---|---|
| Phase | P8 — Operability |
| Size | S — under 1 day |
| Status | Not started |
| Flags | parallel-ok |
| Spec | inlined below |
| Blocks | — |
## Goal
A fresh crate does `cargo add`, runs one workflow, and gets durability and
grading with zero infrastructure.
## Facts (inlined — no spec read needed)
- **Embedded mode is a first-class product, not a test harness.** A user must be
able to `cargo add` this, run an agent, and get durability and grading with
zero infrastructure.
- **That constraint is what keeps the ports honest.** If this breaks, the ports
have leaked — something in the default path now requires Postgres, an object
store, or a broker.
- Embedded mode is `redb` for log and state, a `redb` table for blobs, and
in-process coordination.
- Defaults ship working: a user who wants the built-in behaviour writes no code.
The smoke test is the proof of that claim.
- `parallel-ok`: this gates nothing and can be built alongside the rest of P8.
## Steps
1. Create the smoke test as a **separate crate outside the workspace**, depending
on the published crate surface only. Inside the workspace it can reach
internals and prove nothing.
2. Depend on the default feature set. No `features = ["postgres", ...]`.
3. Define one small workflow in YAML, register a stub model provider and one
verifier.
4. Run it: spawn with a `TaskId`, execute, verify, grade with the default
`PairwiseSequential` (first run yields `Ungraded { NoReference }`; a second run
on the same `TaskId` grades).
5. Assert no network listener is opened and no external service is contacted.
6. Run it in CI on every change to the default path.
## Acceptance
- A fresh crate with **no infrastructure** records, verifies and grades a run.
- If this breaks, the ports have leaked.
## Verify
**Harness:** a crate **outside the workspace**, depending on the published crate
by version or path with `default-features = true` and nothing added. That
location is the test — inside the workspace it can reach internals and proves
nothing.
**Integration test** — `smoke-crate/tests/it_embedded_smoke.rs`:
1. `cargo add` the framework (or a path dependency with default features only).
2. Define one small YAML workflow, register a stub model and one verifier.
3. Spawn with a `TaskId`, execute, verify, grade. Assert the run reaches a
terminal state and produces a `Score` — the first run legitimately yields
`Ungraded { NoReference }`.
4. Second run on the same `TaskId`: assert `Score::Relative`, proving the whole
default grading path works with no infrastructure.
5. Assert **no network listener is opened** and no external host is contacted —
run under a network-denied sandbox if the CI supports it, otherwise assert on
an instrumented socket layer.
6. Assert no Postgres, object store or broker dependency is pulled in: check
`cargo tree` for the distributed-only crates and assert absent.
7. Assert the process exits cleanly and the `redb` file is readable afterwards.
8. Run this in CI on **every change to the default path**, not nightly.
**Command:** `cargo test --manifest-path smoke-crate/Cargo.toml`
**False pass:**
- The smoke crate living inside the workspace. It compiles against internals and
stays green while the published API is unusable — which is the exact leak this
test exists to detect.
- Enabling a non-default feature to make it build. That is the leak, papered
over; the failure is the finding.
- Step 4 omitted: a first run that returns `Ungraded` is trivially reachable
without a working grading path.
## Traps
- Writing the smoke test inside the workspace with access to `pub(crate)` items.
It then passes while the public API is unusable.
- Enabling a non-default feature to make it pass. That is the leak, papered over.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §1, §7 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+113
View File
@@ -0,0 +1,113 @@
# T8.6 — Capacity admission control
| Field | Value |
|---|---|
| Phase | P8 — Operability |
| Size | M — 1 to 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | — |
## Goal
Enforce the residency invariant at runtime, not only at load. Grading yields to
agent inference.
## Facts (inlined — no spec read needed)
- **Admission control, not backpressure.** Work whose `ResourceProfile` does not
fit the current residency is **refused at admission with the limit named**.
Queuing it would stall behind an eviction that the first principle does not
permit.
- **Grading yields to agent work.** When both contend for the same resident
model, agent inference wins and grading queues. **A framework that lets a judge
call delay the work it is judging has inverted its own first principle.**
- **One resident model is the default configuration.** The agent's model is
pinned and never evictable; the judge and the proposer run on that same model.
- A second model is legal only if the invariant holds with **both resident**
never by swapping between them per call, which is the failure mode this exists
to prevent.
- **A swap is a test failure, not a slow path.** A nonzero swap rate on a
single-model deployment means something is requesting a non-resident model, and
the load cost — tens of seconds — will dominate everything else in the trace.
- Admission refusals are metered by reason, separating **capacity** from
**budget**: they look identical in a queue-depth graph and have opposite fixes.
- Residency is per device. A model resident on node A does not make node B's runs
admissible.
## Steps
1. Track current residency as live state: which models are resident on which
devices, and current concurrent KV usage.
2. At admission, evaluate T5.2's invariant against the requesting work's
`ResourceProfile` plus current residency.
3. Refuse when it does not fit, with the **named limit** in the error — not a
generic capacity error, and not a queue.
4. Implement priority: agent inference preempts queued grading for the same
resident model. Grading waits; agent work does not.
5. Assert on **agent-call latency under grading load**, not on queue ordering —
a scheduler that merely queues correctly still fails the principle if grading
wins the race.
6. Count model swaps. Emit as a metric and assert zero in the test.
7. Tag admission refusals `capacity` or `budget` at the refusal site (T8.1 owns
the budget half).
## Acceptance
- With one resident model, a burst of grading work **never delays agent inference
past a configured bound** — asserted on agent-call latency under grading load.
- **Zero model swaps** occur across the whole run; a swap is a test failure, not
a slow path.
## Verify
**Harness:** a model runtime stub that reports residency and **counts swaps**,
plus a latency histogram on agent calls. No GPU needed — the stub enforces the
declared limits.
**Integration test** — `tests/it_admission_control.rs`:
1. Baseline: measure agent-call latency (p50 and p99) with **no** grading load.
2. Burst 100 grading tasks against one resident model while agent work continues.
3. **Assert agent-call p99 stays within the configured bound of baseline.** This
is the acceptance criterion and it is a latency assertion, not an ordering
one — a scheduler that merely queues correctly still fails the principle if
grading wins the race.
4. **Assert zero model swaps** across the whole run. A swap is a test failure,
not a slow path — assert `swap_count == 0`, not a threshold.
5. Submit work whose `ResourceProfile` does not fit current residency. Assert it
is **refused at admission**, not queued — check the queue depth stayed at
zero — and that the error **names the limit**.
6. Assert the refusal is tagged `capacity`, distinct from T8.1's `budget`
refusals, and that both series exist.
7. Multi-node: assert a model resident on node A does not make node B's work
admissible.
8. Assert a pinned model is never selected for eviction, even when the invariant
would otherwise be satisfiable by evicting it.
**Command:** `cargo test -p operability admission -- --test-threads=1 --nocapture`
**False pass:**
- Step 3 asserting that grading was queued behind agent work. Correct ordering
with a shared lock still adds latency to agent calls; the histogram is what
detects it.
- Step 5 asserting an eventual error after queuing. Queuing over-capacity work
stalls it behind an eviction that must not happen — assert the queue never grew.
- Step 4 as `swap_count < 5`. Any nonzero swap rate means something is requesting
a non-resident model, and the tens-of-seconds load cost will dominate the
trace.
- Measuring only mean latency, where a p99 stall from one swap disappears.
## Traps
- Queuing over-capacity work instead of refusing it. It stalls behind an eviction
that must not happen.
- A fair scheduler between grading and agent work. Fair is the wrong policy here;
agent work wins.
- Measuring only queue depth. Both refusal reasons look identical there.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §1, §14.2, §15 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+116
View File
@@ -0,0 +1,116 @@
# T8.7 — P8 composition gate
| Field | Value |
|---|---|
| Phase | P8 — Operability |
| Size | L — over 3 days |
| Status | Not started |
| Flags | gate |
| Spec | inlined below |
| Blocks | — release gate |
## Goal
Prove the operability layer composes with everything before it — and that turning
all of P8 on does not break the zero-infrastructure embedded product.
**Phase gate criterion:** metering, metrics, sandbox enforcement and capacity
admission all active, with the embedded smoke crate still green.
## Facts (inlined — no spec read needed)
- P8's features all sit **across** the rest of the system rather than beside it,
so each one's own test is necessarily narrow:
- metering attributes calls the executor and grader make;
- metrics observe every stage;
- keyed capability constrains every tool call;
- reduction rewrites what grading and verification later read;
- admission control gates work the scheduler wants to run.
- The properties no single P8 task owns:
- **a reduced episode is still gradeable** — reduction and grading are built by
different tasks and read the same blobs;
- **budget and capacity refusals are distinguishable** end to end, since they
look identical in a queue-depth graph and have opposite fixes;
- **metering totals reconcile** with the model provider's own call count —
attribution that misses a call path understates spend silently;
- **the embedded smoke crate still passes with every P8 feature enabled.** If
it does not, an operability feature has leaked a service dependency into the
default path.
- The retention ceiling is derived from hardware, not chosen: where
`max_context_tokens / 2` is tighter than the configured ceiling, the bound
wins. That interaction spans T5.2 and T8.4 and belongs to neither.
## Steps
1. Enable every P8 feature and run the full behavioural suite from T7.8.
2. Run the reduce-then-grade path: reduce a run, then grade the reduced episode.
3. Reconcile metering totals against the provider's own counter.
4. Run the embedded smoke crate with all features on.
5. Drive both refusal reasons and assert they are separable everywhere.
6. Make this the release gate.
## Acceptance
- The full behavioural suite passes with **all P8 features enabled**, in both
deployment modes.
- A **reduced** episode is still gradeable and still produces a valid `Score`.
- Metering totals reconcile exactly with the provider's call count.
- The embedded smoke crate passes with every feature on.
- Capacity and budget refusals are distinguishable end to end.
## Verify
**Harness:** T7.8's mode-parameterized suite; the provider call counter; the
external side-effect ledger; the out-of-workspace smoke crate; a scrapeable
metrics recorder.
**Integration test** — `tests/it_p8_composition.rs`:
1. **Everything on:** run the T7.8 behavioural suite with metering, metrics,
keyed capability, reduction and admission control all enabled, in both modes.
2. **Reduce then grade:** reduce a run past the ceiling, then grade it. Assert a
valid `Score`, and assert the grading path handled `get``None` on reduced
originals by following the `Reduced` event. Then drop state, re-fold, and
assert byte-identical.
3. **Derived ceiling interaction:** configure a 200k retention ceiling with a
hardware-derived bound of 80k. Assert reduction targets **80k** and that the
judge's context request is admitted.
4. **Metering reconciliation:** assert `sum(metered calls)` equals the provider's
own counter, and that every call carries a non-default `Purpose`. A mismatch
means a call path is unattributed.
5. **Refusal separability:** drive one capacity refusal and one budget refusal.
Assert two distinct metric series moved, that each error names its limit, and
that the budget-refused run reaches terminal `Ungraded` while the
capacity-refused work was never queued.
6. **Keyed capability under the real interpreter:** assert every tool call in the
suite carried a kernel-supplied key or was denied and audited — count against
the intent records, not against the tool's own reporting.
7. **Zero swaps** across the whole gate.
8. **Smoke crate:** run it, outside the workspace, default features only, with
every P8 feature active. Assert no network listener and no distributed-only
crate in `cargo tree`.
9. **Regression:** re-run P0P7 gates.
**Command:** `cargo test -p operability --test it_p8_composition && cargo test --manifest-path smoke-crate/Cargo.toml`
**False pass:**
- Running each P8 feature's suite with the others off. Every one is green that
way; the interactions — reduction versus grading, metering versus purpose
attribution, admission versus the join stage — are the reason this gate exists.
- Step 2 asserting the reduced run produced *a* score. Assert the re-fold too:
a mapping stored outside the log passes the grading half and destroys
drop-and-re-fold.
- Step 4 comparing metered totals to themselves. The **provider's** counter is
the independent witness.
- Step 8 run with a non-default feature enabled to make it build. That is the
leak, papered over.
## Traps
- Treating this as a formality once P0P7 are green. Every property here spans
tasks that were each verified in isolation, and P8's features touch all of them.
---
Background (not required to do this task):
[rust-agentic-sys.md](../../../rust-agentic-sys.md) §8.6, §13, §14, §15 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)
+218
View File
@@ -0,0 +1,218 @@
You are an Elite Rust Software Architect and Agentic Systems Engineer specializing in high-performance, memory-safe, and zero-overhead distributed systems. Your objective is to write production-grade, idiomatic Rust code by analyzing constraints step-by-step and enforcing rigorous type-driven compile-time guarantees.
### 🤖 Agentic Behavior & Code Reasoning Practice
Before emitting any code blocks, you must perform a silent internal thought process following these agentic rules:
1. **State & Boundary Analysis:** Reason explicitly about the data's lifecycle. Who owns this data? Can it be represented as a borrow (`&T` or `&[T]`) instead of moving or cloning?
2. **Defensive Non-Invasive State:** Never assume incoming data or strings are well-formed. Enforce structural integrity at creation boundaries using parsing methods (`parse()`, `try_from()`) rather than loose validation later.
3. **Self-Correction Check:** Audit your own generated code loops for hidden heap allocations (such as premature `.collect()`, `.to_owned()`, or `.clone()`). If found, refactor them into lazy iterator chains immediately.
### 🏗️ Engineering Architecture Directives
#### 1. Type-Driven Design (Anti-Primitive Obsession)
- **Bad Practice:** Relying on magic strings or loose primitives for structural attributes (e.g., using `String` for categories/roles, `u64` for un-typed currency values, or raw strings for emails).
- **Good Practice:** Enforce structural correctness at compile time using strict `enum` types, specialized "Newtype" wrappers (e.g., `struct UsdCents(u64)`), and dedicated parsing validation structs. Implement the standard `Default` trait for default state fallbacks.
#### 2. Zero-Copy Memory Management & Iterators
- **Bad Practice:** Taking full ownership of collections via `Vec<T>`, indexing loops manually (triggering runtime bounds-checking penalties), or using heavy `.clone()` operations inside loops.
- **Good Practice:** Accept reference slices (`&[T]`) instead of full collection vectors. Use functional iterator pipelines (`.iter().filter().map().collect()`) to let the compiler safely optimize and vectorize the underlying operations without manual memory allocations.
#### 3. Panic-Free Control Flow & Error Isolation
- **Bad Practice:** Abusing runtime panicking structures (`unwrap()`, `expect()`, `panic!()`) or adding massive deep code indentation branches with nested `if/else` statements.
- **Good Practice:** Maintain flat code architecture using Guard Clauses and early-return mechanics. Model all predictable application faults as domain-specific data wrapped inside strict `Result<T, E>` enums, and propagate them cleanly using the `?` operator.
#### 4. Non-Blocking Async Execution
- **Bad Practice:** Executing long-lasting synchronous blocking I/O functions or CPU-bound threads (e.g., `std::thread::sleep`) inside an asynchronous async/await environment, which freezes the executor runtime threads.
- **Good Practice:** Utilize native async non-blocking alternatives (e.g., `tokio::time::sleep`). For unoptimized third-party legacy blocking drivers or heavy compute loads, explicitly isolate and dispatch the task via dedicated background threadpools like `tokio::task::spawn_blocking`.
### 🎯 Expected Output Format
Deliver fully-formed, clean, production-ready Rust code without introductory preamble or conversational filler. Ensure that all data models, error structures, zero-copy pointer traits, and asynchronous wrappers are encapsulated into a single unified implementation file.
---
### 📚 Reference Style Guide (Few-Shot Examples)
Use the following architectural code layout pattern as your quality baseline reference:
```rust
// Cargo.toml dependencies required for the async examples:
// [dependencies]
// tokio = { version = "1.0", features = ["full"] }
use std::time::Duration;
// =========================================================================
// 1. Type-Driven Design & Domain Modeling
// =========================================================================
// ❌ BAD: Relying on raw primitives. Prone to typos and lacks validation.
pub struct BadUser {
pub id: u64,
pub name: String,
pub role: String,
pub email: String,
pub balance_cents: i64,
}
// GOOD: Leverage the type system to enforce correctness at compile time.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
Admin,
Member,
Guest,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct UsdCents(pub u64);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Email(String);
impl Email {
pub fn parse(email: String) -> Result<Self, &'static str> {
if email.contains('@') {
Ok(Self(email))
} else {
Err("Invalid email format")
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
pub struct GoodUser {
pub id: u64,
pub name: String,
pub role: Role,
pub email: Email,
pub balance: UsdCents,
}
impl Default for GoodUser {
fn default() -> Self {
Self {
id: 0,
name: String::from("Anonymous"),
role: Role::Guest,
email: Email(String::from("noreply@domain.com")),
balance: UsdCents(0),
}
}
}
// =========================================================================
// 2. Error Handling & Control Flow
// =========================================================================
// ❌ BAD: Crashing threads with panic, causing heavy nesting.
pub fn bad_process_user(user: &BadUser) -> String {
if user.role == "Admin" {
if user.name.is_empty() {
panic!("Critical error: User name cannot be empty!");
} else {
return format!("Admin: {}", user.name);
}
} else {
return String::from("Regular User");
}
}
// GOOD: Domain-specific error enums, guard clauses, and early returns.
#[derive(Debug)]
pub enum UserError {
EmptyName,
InsufficientFunds,
}
pub fn good_process_user(user: &GoodUser) -> Result<&str, UserError> {
if user.name.is_empty() {
return Err(UserError::EmptyName);
}
match user.role {
Role::Admin => Ok(&user.name),
_ => Ok("Regular User"),
}
}
// =========================================================================
// 3. Memory Allocation & Iterators
// =========================================================================
// ❌ BAD: Forcing heavy vector ownership and creating redundant heap allocations.
pub fn bad_filter_admins(users: Vec<BadUser>) -> Vec<String> {
let mut admins = Vec::new();
for i in 0..users.len() {
let user = users[i].clone();
if user.role == "Admin" {
admins.push(user.name);
}
}
admins
}
// GOOD: Accepting reference slices, zero-copy outputs, and lazy iterators.
pub fn good_filter_admins(users: &[GoodUser]) -> Vec<&str> {
users
.iter()
.filter(|u| u.role == Role::Admin)
.map(|u| u.name.as_str())
.collect()
}
// =========================================================================
// 4. Async Execution & I/O Blockages
// =========================================================================
// ❌ BAD: Executing synchronous blocking operations inside an async task.
pub async fn bad_fetch_data() -> String {
std::thread::sleep(Duration::from_millis(100));
String::from("data")
}
// GOOD: Non-blocking timers or spawning background threads for sync workloads.
pub async fn good_fetch_data() -> String {
tokio::time::sleep(Duration::from_millis(100)).await;
String::from("data")
}
pub async fn good_handle_heavy_cpu() -> Vec<u8> {
tokio::task::spawn_blocking(|| {
let mut data = vec![0u8; 10000];
data.sort();
data
})
.await
.unwrap_or_default()
}
// =========================================================================
// 5. Verification Entry Point (Executable Main)
// =========================================================================
#[tokio::main]
async fn main() {
let database_users = vec![
GoodUser {
id: 1,
name: String::from("Alice"),
role: Role::Admin,
email: Email::parse(String::from("alice@test.com")).unwrap(),
balance: UsdCents(5000),
},
GoodUser::default(),
];
let admin_names = good_filter_admins(&database_users);
println!("Idiomatic Admins: {:?}", admin_names);
// Fixed index mapping to avoid slicing/type compilation errors
match good_process_user(&database_users[0]) {
Ok(name) => println!("Processed user safely: {}", name),
Err(e) => Box::leak(Box::new(eprintln!("Error encountered: {:?}", e))),
}
let async_data = good_fetch_data().await;
println!("Fetched async data safely: {}", async_data);
}
```