Skip to content

merge(schemas): close the v1 review's wire breach and evidence gaps - #8

Merged
LittleCoinCoin merged 16 commits into
mainfrom
fix/v1-review-conformance
Nov 9, 2026
Merged

LittleCoinCoin merged 16 commits into
mainfrom
fix/v1-review-conformance

Conversation

@LittleCoinCoin

Copy link
Copy Markdown
Member

Closes items W1, E1, E2 and E3 of table C of the conformance review (__reports__/candler-v1-review/00-findings_v0.md):

  • W1: the one wire breach.
  • E1, E2, E3: three places where an M2b gate rested on weaker evidence than planned.

Two read-only reviews shaped the plan before any code was written. The first checked alignment with the goal of a domain-agnostic session runner, and the second hardened the plan. Six leaves then ran in parallel, each on its own task branch, and each lands here as a --no-ff merge. Every leaf that touches a persisted contract, the production recovery, a real daemon or real signals had an adversarial verifier (W1, E2, E3, E3b).

Out of scope, and untouched: E4, the planner axis, stable.in, ENTRY_PARAMS, the I1 guard, provenance names (all in M2c's amendment A2), and docs drift (the sibling session).

What lands

Item Change Guard Release
W1 CatalogServer reads through object_only!. The snapshot's wire struct reads through MapOnly. A struct written as a list is refused at every level. unit test at each level, plus a fixture-wide guard in guard_wire_shapes.rs fix(schemas): schemas patch
E1 none (test only) B39: environments and read-only input mounts never enter the run tree, and a marker at the workspace root empties it none
E2 recover_job_dir / recover_job_dir_with in jobs::runtime::session replace the byte-identical ps::recover_job and session::recover_one. ps, kill, watch and reap_lost_jobs call it. B36 recovers through the shared composition, with only the destroy half behind the test's allowlist. B42 pins reap_lost_jobs (spares its own job, recovers a lost sibling, leaves an ended one, stops on cancel). refactor(jobs): core patch
E3 (lost half of gate 4) none (test only) B40: the real candler kill --all -y against real containers, through a fail-closed docker shim none
E3 (running half of gate 4) none (test only) B41: the real candler kill --all -y against two live supervise_entry supervisors, which end their own jobs as killed none
— build(cli): candler takes candler-core/test-support on its dev-dependency edge only. Cargo.lock does not move. — none

The schema files are byte-identical. DRIFT is green after a bless, and git diff origin/main -- crates/candler-schemas/schema is empty.

Bite evidence

Every bite was restored with git checkout -- <file>, then git diff --quiet.

W1

  • Remove remote = "Self" and object_only!(CatalogServer). Both tests go red:
    • guard_wire_shapes: bio_opentrons.json: the object at '/servers/0' written as a list was read
    • the unit test: a server as a list
  • Read CatalogSnapshotWire without MapOnly. Both go red:
    • the guard: the object at '' written as a list was read
    • the unit test: a snapshot as a list
  • The review's probe ["tool-catalog-snapshot/1","2026-01-01T00:00:00Z","p@1",{},{},[]] returned Ok at the base. It is now refused through from_value, from_str, from_slice and from_reader. The verifier diffed every object-form behaviour (error text and order, null optionals, unknown keys) against the base and found none changed.

E1 (B39)

  • A marker that matches no output name:

    left:  ["inputs/corpusx/b.txt", "out/a.txt", "tools/env/bin/python", "tools/env/pyvenv.cfg", "tools/run.sh", "venv/lib/python3.12/site-packages/x.py", "venv/pyvenv.cfg", "venvish/pyvenv.cfg.bak"]
    right: ["inputs/corpusx/b.txt", "out/a.txt", "tools/run.sh", "venvish/pyvenv.cfg.bak"]
    

    The root case also goes red: left: ["lib/x.py", "out/a.txt", "pyvenv.cfg"], right: [].

  • Input mount points dropped from the filter:

    left:  ["inputs/corpus/a.txt", "inputs/corpus/deep/b.txt", "inputs/corpusx/b.txt", "out/a.txt", "tools/run.sh", "venvish/pyvenv.cfg.bak"]
    right: ["inputs/corpusx/b.txt", "out/a.txt", "tools/run.sh", "venvish/pyvenv.cfg.bak"]
    

E2 (B36 through the shared path)

  • The lost-job path skips the container sweep (jobs/lost.rs). B36 goes red at supervision_gates_e2e.rs:385:

    containers of the job remain: [<agent>, <upstream>, <gateway>]
    

    The implementer and the verifier each reproduced it once. The test's cleanup removed the bitten run's containers through the guard.

E2 (B42)

  • Remove the own filter in reap_lost_jobs:

    left:  [report …22 Recovered, report …23 Recovered]
    right: [report …23 Recovered]
    
  • Remove the cancel check:

    cancelled-reports 1, cancelled-sibling Ended(Failed)
    

E3 (B40)

  • kill stops only the first target:

    no single '<second lost id>: recovered (1 containers destroyed)' line in …
    

    docker ps -aq and docker network ls -q were identical before and after the bitten run.

  • Shim made permissive (listed "$1" || true). Both shim tests go red:

    `container stop -t 1 OTHER` was not refused
    `rm ID` was not refused
    

E3 (B41)

  • kill's Running branch skips sup.reap. B41 goes red:

    <id>: not stopped: its supervisor still holds the job
    

    The exit code was 2 instead of 0. No supervisor child survived; the verifier checked by the pids it sampled.

Safety on a shared machine

B40 and B41 follow the meta manager's binding conditions on top of the M2b safety rules.

Store scope

  • Every binary call passes --store <tempdir>, with HOME, the XDG config, data, state and cache directories all inside the tempdir, and CANDLER_STORE unset.
  • Before any -y, a kill --all dry run with stdin closed must be refused (exit 2). Its first line must equal exactly would stop: <the test's own ids, sorted>, or the test aborts. This pre-check passed in every run (implementers and verifiers).

B40's shim

  • The binary reaches docker only through a fail-closed shim first on PATH.
  • The shim bakes in the real docker's absolute path, refuses to run if that path is itself, and refuses:
    • every destroy verb on an id outside the test's own (full ids only);
    • every option before the verb;
    • any -H, --host or --context anywhere;
    • unknown verbs.
  • Refusal matrix: 56 cases plus about 90 verifier cases, all against a fake docker. They cover update, exec, run, create, start, restart, pause, cp, compose, buildx, system, context use, network create/connect/disconnect, volume rm, mixed id lists, smuggled whitespace and newlines, prefixes and an empty allowlist. Every case is refused, with positive controls.
  • Cleanup removes only the exact ids captured at creation, through ContainerGuard over AllowlistDriver.

B41's shim and signals

  • B41's shim forwards nothing. With an empty allowlist, a fall-through to recovery cannot reach the daemon. Its matrix (destroy verbs, -H, --host and --context in every form, read controls) runs in the default suite.
  • Before kill, per job:
    • the pid in job.json equals the Child::id() the test holds;
    • the child leads its own group;
    • the group holds no other process.
  • Every wait is bounded. The supervisors' wait is bounded at 5 minutes, so even an abnormal death of the test cannot leave them behind. A drop guard kills and reaps every child by handle.

Local results on the merged tip

Measured on fix/v1-review-conformance with sccache and its own target dir:

Gate Result
guard_signal, the pre-run step after cargo clean -p candler-core 9/9 pass
cargo fmt --check clean
cargo clippy --workspace --all-targets -- -D warnings clean
cargo nextest run --workspace 351 pass, 17 skipped (the ignored Docker guards)
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps clean
DRIFT (bless, then git status and git diff origin/main -- crates/candler-schemas/schema empty) green, schema files unchanged
cog check origin/main..HEAD, subjects at most 72 characters pass
B36 and B40 with --run-ignored only on Docker Desktop (macOS) both pass (17.9 s, 25.4 s)
docker ps -aq and docker network ls -q before and after the e2e run identical

The Linux container-e2e job decides B06, B08, B09, B12, B13, B17, B18, B36, B37 and the new B40.

Notes for review

  • Trailers. Commits written by the Sonnet 5 implementers carry Co-Authored-By: Claude Sonnet 5.5, the model that wrote them, as CONTRIBUTING asks. Merge commits and commits the coordinator touched also carry Claude Opus 5.5.
  • recover_job_dir_with is public and not gated. ContainerGuard::new was already public, and a caller supplies only a destroy half, never the guard's rules. B16 still refuses any src/ file that names DestroyDriver. Gating the seam behind test-support is an option, but it was not needed here.
  • B36's sweep limit is now the production 2 minutes, up from the rig's 60 s.
  • New tests in the default suite: B41 and both shims' refusal tests. They need no daemon and run in about 1 s each. shim_refusals falls under the container-e2e test group's filter, which only serializes it.
  • Known, harmless gaps:
    • B40's shim passes a read verb that carries --config. Destroy verbs refuse every option they do not know.
    • B41's 5-minute body timeout path is stated, not exercised.
  • Received, not in scope (the meta manager takes them to Eliott as their own follow-up):
    • the docs session's code gaps: no agent container-record/1, and legacy streams missing from import.sources[];
    • the list of 37 #[allow]/#[expect] attributes.

Co-Authored-By: Claude Sonnet 5.5 noreply@anthropic.com
Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com

🤖 Generated with Claude Code

LittleCoinCoin and others added 16 commits November 10, 2026 01:06
The container guards of the cli destroy what they create through the
container guard over a test-side allowlist, as the core's real-daemon
guards do. The allowlist driver lives behind candler-core's
test-support feature, so the cli takes that feature on its
dev-dependency edge only; the binary's own dependency edge is
unchanged and Cargo.lock does not move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The run tree drops every path under a directory that holds the agent
environment's marker file and every path under a read-only input mount
point. That exclusion lives in the core's tree builder, and only the
Claude Code driver's discovery globs had a guard (B23), which never
calls the tree builder.

B39 builds the tree from hand-made outputs: a venv with its marker, a
nested environment, an input mount, and near-miss names that must stay
(`venvish/pyvenv.cfg.bak`, `inputs/corpusx/`). A second case pins that a
marker at the workspace root makes the whole workspace an environment.

Bites, each restored with `git checkout --`: a marker that matches no
output name turns both cases red; dropping the input mount points from
the filter turns the first red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
reap_lost_jobs had no test. Over one hand-built store it now asserts
that the running job is never recovered though it reads as lost, that a
lost sibling is recovered and then reads as ended, that an ended job is
left alone, and that a control cancelled before the call recovers
nothing.

The sweep looks for docker on PATH, so the sweep runs in a re-executed
child whose PATH holds only a logging client that reports every network
and image absent. The call log shows no destroying verb.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
B39 pins that the run tree drops every path under a directory holding
the agent environment's marker and every path under a read-only input
mount point, and that a marker at the workspace root empties the tree.
Review item E1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
B42 pins that the supervisor's sweep of lost jobs recovers a lost
sibling, leaves an ended job alone, never recovers its own job and stops
when the job is told to stop, with the docker client faked in a re-exec
child. Part of review item E2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
R01 forbids a struct written as an array, but the snapshot reader took
["tool-catalog-snapshot/1", "2026-01-01T00:00:00Z", "p@1", {}, {}, []]
as a snapshot, and a server as a list of its fields: both derived
Deserialize impls accept a sequence.

CatalogServer now derives with remote = "Self" and object_only!, as the
rpc records do; the snapshot reader goes through MapOnly. Tool has a
flattened remainder and already refused a list; a test now pins that.
The generated schemas are unchanged.

Unit tests cover each level and the probe. A guard in
guard_wire_shapes turns every snapshot, server and tool of the
committed catalog fixtures into the list of its values and asserts the
reader refuses it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The tool-catalog snapshot reader accepted a snapshot or a server written
as a JSON array, which the wire encodings forbid. `CatalogServer` now
reads through `object_only!` and the snapshot's wire struct through
`MapOnly`, as every other wire struct does. A guard turns every object of
every committed catalog fixture into a list and requires the reader to
refuse it. The schema files are unchanged. Review item W1.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`ps` and the runtime's sweep of lost jobs each built the same
recovery by hand, so a test could not exercise the production
composition without rebuilding it. One `recover_job_dir` now serves
`ps`, `kill`, `watch` and `reap_lost_jobs`, and `recover_job_dir_with`
takes a guard factory so a test can fit its own destroy half without
the seam naming the destroy driver.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
B36 recovered the lost job through a composition the test built
itself, so it could pass while `candler ps` did not sweep. It now
recovers through `recover_job_dir_with`, keeping the allowlist on the
destroy half. The sweep limit is the shared two minutes, not the
rig's sixty seconds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`candler ps`, `kill` and `watch` and the supervisor's sweep of lost jobs
each built the same recovery of a lost job; they now share
`recover_job_dir`. B36, the guard that a supervisor killed with SIGKILL
leaves nothing, recovers through the same composition, with only the
destroy half of its guard behind the test's allowlist, so the gate rests
on the production path. Review item E2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lost-job half of the M2b supervision gate had never run the real
binary against a daemon. B40 makes two lost jobs, an ended job and a
bystander, each with its own testagent container, and runs
`candler kill --all -y` with a forwarding docker first on PATH. The
shim allows a destroy only of the lost jobs' ids and fails closed on
anything else; its refusals are pinned against a fake docker. A dry
run first must list exactly the test's own jobs. Cleanup goes through
the container guard by exact captured id.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
B40 needs a Docker daemon and runs one at a time with the other real
daemon guards. Select its binary in the group, run the candler package
in the workflow, and require its name to pass.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
B40 runs the real `candler kill --all -y` over a store of two lost jobs,
each owning a real container and network, an ended job and a bystander,
all made by the test. The binary reaches docker only through a
fail-closed shim that destroys nothing outside the ids the test made.
The lost jobs' containers and networks go, the others stay, and both
jobs end as failed. It runs in the Linux container-e2e job. This covers
the lost-job half of gate 4 of the execution parity milestone. Review
item E3.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing ran the running branch of `candler kill`. B41 re-executes its
binary as two real supervisors over a body that waits for its cancel,
runs `kill --all -y` on them behind a fail-closed docker shim, and
checks that each ends killed by its own teardown, not by a recovery.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
B41 runs the real `candler kill --all -y` over two live supervisors the
test spawned, each a real `supervise_entry` that holds its job's lock.
The CLI's reap signals only the recorded identities, through the
process guard, and each supervisor ends its own job as killed; no
recovery runs. Docker is reached only through a fail-closed shim that
destroys nothing, and every wait, the supervisors' own included, is
bounded. This covers the running half of gate 4 of the execution parity
milestone. Review item E3.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Grit conventions ask for an `// INDEX:` note on every slice index
and a `// TRAIT_OBJECT:` note on every trait object, and an
`// EXPLICIT:` reason on a catch-all arm. The guards B41, B42 and the
catalog fixture guard, and the guard factory of `recover_job_dir_with`,
lacked them. B41 now asserts the length its slices rely on, and its
doc lines are wrapped like their neighbours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@LittleCoinCoin LittleCoinCoin changed the title merge(schemas): close the wire breach and evidence gaps of the v1 review merge(schemas): close the v1 review's wire breach and evidence gaps Nov 9, 2026
@LittleCoinCoin
LittleCoinCoin marked this pull request as ready for review November 9, 2026 16:38
@LittleCoinCoin
LittleCoinCoin merged commit 7875a64 into main Nov 9, 2026
7 of 9 checks passed
@LittleCoinCoin
LittleCoinCoin deleted the fix/v1-review-conformance branch November 9, 2026 16:38
LittleCoinCoin added a commit that referenced this pull request Nov 10, 2026
This PR closes the three code gaps that the v1 conformance review found
and that #8 left out so it stayed narrow:
- the agent container had no `container-record/1`;
- the legacy streams were not import sources;
- 37 local lint suppressions had never been decided.

R01 already states the rule for the first two, so the code changes and
R01 does not. The campaign is `__roadmap__/v1-review-code-gaps/`. Each
leaf was checked by a spec verifier and the Grit reviewer before it
merged.

## Gap 1: an agent container record

**The gap.** Only the gateway sidecar wrote a `container-record/1`. The
agent container was known only by its cidfile. The guard refuses an
unrecorded container under `SweepScope::CurrentJob`. So an agent whose
run-scoped destroy failed survived job close until its own `timeout`.

**The fix.**
- `Isolation::record_container` is new. Its default is `Ok(None)`.
- `DockerIsolation` reads the cidfile, inspects the container, and
writes `children/container-<cid12>.json` with `role: agent` and the
container's own labels. A job or run label that differs from the plan's
is refused.
- `drive` polls it every 100 ms while the docker client runs, and tries
once more after the wait. The agent's `container.created` is emitted
when the record is written.

**Guards** (`guard_container_record`, 5 tests):
- the record's fields;
- idempotence;
- no file without a container;
- job close: the PATH-fake docker refuses the first `rm`, and the
job-close sweep then destroys the agent as `Recorded`. With the fix
removed, nothing is destroyed.
- ladder: on `Ended::Stopped` the record appears while the run is live.
It waits on the file with a 10 s bound, not on a timer.

Each bite was seen to fail.

**One behaviour shift:** an agent whose cidfile exists but which never
becomes inspectable gets no `container.created` event. Before, the
cidfile alone produced one.

## Gap 2: the legacy streams are import sources

**The gap.** `run_files` set `agent_stream.jsonl.gz` and
`agent_stderr.txt` aside before the sources push. So they were not in
`import.sources[]` and not kept as blobs. The raw header's `source`
digest named a blob the store did not hold, and re-import accepted a
changed stream.

**The fix.** Both files also become run sources. The framing is
untouched.

**Guards:**
- every run lists both files by an independent sha256, with a blob;
- the raw header's `source` digest is among them;
- the one-byte re-import cases gain the stream and the stderr. Each is
refused `Conflict` and fails with the fix removed. The fixture stderr
files are empty, so that case adds one byte.

`guard_legacy_mapping` now asserts that a stream file is also a source.

**Breaking** (`fix(importer)!`): a store imported by ≤0.4.1 fails
re-import with `Conflict`, because its sealed manifests lack the two
sources. Re-import the folder into a fresh store.

## Gap 3: every lint suppression is decided

**The rule now:**
- each crate root denies `clippy::allow_attributes`;
- the workspace denies `allow_attributes_without_reason` and warns on
`exhaustive_enums` (Grit rule 11);
- a kept suppression is `#[expect(<lint>, reason = "…")]`, so it fails
the build once it stops firing.

`tests/` keeps a reasoned `#[allow]` only where an `#[expect]` cannot
hold. There are 12, mostly `dead_code` on support modules shared by
several test binaries. AGENTS.md states the rule.

**`src/`, 40 sites** (the hand-over's 37, plus `ItemOutcome` and two
`cfg_attr` the list missed):
- **27 kept**, each judged acceptable by the Grit reviewer: EXHAUSTIVE
enums, casts with no lossless std conversion, wildcards over foreign
`#[non_exhaustive]` enums, `deprecated` `Cause::Legacy`, serde-imposed
`ref_option`, and so on.
- **11 removed by refactor:**
  - the registration steps borrow;
- `ClaudeCodeDriver::new() -> Result`, with a new
`DriverError::HarnessName`;
  - the schemas' `checked` constructors take their raw struct;
  - one `legacy_cause` helper;
  - `env_args` as an `if let`;
  - `ChangeClass` becomes `#[non_exhaustive]`;
  - the dead `CliError::Unavailable` is deleted;
  - the unread `generator` becomes `_generator`.
- **2 removed as suppressing nothing:** `main.rs`'s `print_stderr`, and
`ChildEntry`'s `large_enum_variant`.

**Failing first:** with only the lint lines applied before the pass,
clippy reports 30 `allow_attributes`, 23
`allow_attributes_without_reason` and 1 `exhaustive_enums`. Replacing
the cast in `micro_usd` while its `#[expect]` stays fails with
`unfulfilled_lint_expectations`.

**Breaking:**
- `register_evaluation_steps` and `register_m2b_steps` take references;
- `ClaudeCodeDriver` loses `Default` and `const fn new`;
- `ChangeClass` is `#[non_exhaustive]`.

## No digest moved

`__reports__/v1-review-code-gaps/digest_invariance_v0.md` imports the
golden corpus (240 runs, 10,320 records) and the mini-corpus with
`main`'s build and this branch's, and diffs the results:
- run keys, and so cell digests: identical;
- every outcome record's context digest: identical;
- every manifest once the new stream and stderr sources are dropped:
identical;
- traces, trees and raw frame files: byte-identical.

Only `import.sources[]` changed, by two entries per run. `guard_golden`
passes on the branch.

## Not done here
- The `unused_async` expect on the `cfg(not(unix))` `evaluate` stub
compiles on no target CI or this host builds.
- Grit follow-ups:
- merge `FrameMode` into the harness's `Framing`, a cross-crate API
change;
- rename the Rust field `Session::parent_session`, keeping its wire
name.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant