Repository navigation
merge(schemas): close the v1 review's wire breach and evidence gaps - #8
Merged
Merged
Conversation
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
marked this pull request as ready for review
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes items W1, E1, E2 and E3 of table C of the conformance review (
__reports__/candler-v1-review/00-findings_v0.md):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-ffmerge. 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
CatalogServerreads throughobject_only!. The snapshot's wire struct reads throughMapOnly. A struct written as a list is refused at every level.guard_wire_shapes.rsfix(schemas): schemas patchrecover_job_dir/recover_job_dir_withinjobs::runtime::sessionreplace the byte-identicalps::recover_jobandsession::recover_one.ps,kill,watchandreap_lost_jobscall it.reap_lost_jobs(spares its own job, recovers a lost sibling, leaves an ended one, stops on cancel).refactor(jobs): core patchcandler kill --all -yagainst real containers, through a fail-closed docker shimcandler kill --all -yagainst two livesupervise_entrysupervisors, which end their own jobs as killedbuild(cli):candlertakescandler-core/test-supporton its dev-dependency edge only.Cargo.lockdoes not move.The schema files are byte-identical. DRIFT is green after a bless, and
git diff origin/main -- crates/candler-schemas/schemais empty.Bite evidence
Every bite was restored with
git checkout -- <file>, thengit diff --quiet.W1
remote = "Self"andobject_only!(CatalogServer). Both tests go red:guard_wire_shapes:bio_opentrons.json: the object at '/servers/0' written as a list was reada server as a listCatalogSnapshotWirewithoutMapOnly. Both go red:the object at '' written as a list was reada snapshot as a list["tool-catalog-snapshot/1","2026-01-01T00:00:00Z","p@1",{},{},[]]returnedOkat the base. It is now refused throughfrom_value,from_str,from_sliceandfrom_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:
The root case also goes red:
left: ["lib/x.py", "out/a.txt", "pyvenv.cfg"],right: [].Input mount points dropped from the filter:
E2 (B36 through the shared path)
The lost-job path skips the container sweep (
jobs/lost.rs). B36 goes red atsupervision_gates_e2e.rs:385: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
ownfilter inreap_lost_jobs:Remove the cancel check:
E3 (B40)
killstops only the first target:docker ps -aqanddocker network ls -qwere identical before and after the bitten run.Shim made permissive (
listed "$1" || true). Both shim tests go red:E3 (B41)
kill's Running branch skipssup.reap. B41 goes red: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
--store <tempdir>, withHOME, the XDG config, data, state and cache directories all inside the tempdir, andCANDLER_STOREunset.-y, akill --alldry run with stdin closed must be refused (exit 2). Its first line must equal exactlywould stop: <the test's own ids, sorted>, or the test aborts. This pre-check passed in every run (implementers and verifiers).B40's shim
PATH.-H,--hostor--contextanywhere;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.ContainerGuardoverAllowlistDriver.B41's shim and signals
-H,--hostand--contextin every form, read controls) runs in the default suite.kill, per job:job.jsonequals theChild::id()the test holds;Local results on the merged tip
Measured on
fix/v1-review-conformancewith sccache and its own target dir:guard_signal, the pre-run step aftercargo clean -p candler-corecargo fmt --checkcargo clippy --workspace --all-targets -- -D warningscargo nextest run --workspaceRUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-depsgit statusandgit diff origin/main -- crates/candler-schemas/schemaempty)cog check origin/main..HEAD, subjects at most 72 characters--run-ignored onlyon Docker Desktop (macOS)docker ps -aqanddocker network ls -qbefore and after the e2e runThe Linux
container-e2ejob decides B06, B08, B09, B12, B13, B17, B18, B36, B37 and the new B40.Notes for review
Co-Authored-By: Claude Sonnet 5.5, the model that wrote them, as CONTRIBUTING asks. Merge commits and commits the coordinator touched also carryClaude Opus 5.5.recover_job_dir_withis public and not gated.ContainerGuard::newwas already public, and a caller supplies only a destroy half, never the guard's rules. B16 still refuses anysrc/file that namesDestroyDriver. Gating the seam behindtest-supportis an option, but it was not needed here.shim_refusalsfalls under thecontainer-e2etest group's filter, which only serializes it.--config. Destroy verbs refuse every option they do not know.container-record/1, and legacy streams missing fromimport.sources[];#[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