Conversation
`soak manage --topology <n>r0g --out <dir> [--cases ...]` runs pass/fail
cases on the P2P management channel. The cluster is the run mesh with
`with_acp_local().with_nac()`; schema, connections and replicators are
wired as the NAC owner, which is also the HTTP courier at every relay.
Three generated actors are granted per node through `acp node
relationship add`: admin (the admin relation), operator
(add-p2p-collection, list-p2p-replicator), outsider (nothing).
Cases speak only through the `Channel` trait, so the runner and every
case are unit-tested against a scripted fake; `Live` sends the raw
`{Target, AuthToken, Op}` POST, records status and latency per op, and
reads the target's list for the op's family after each mutate. An
expectation miss is `Failed` and becomes `Outcome::Fail`; any other
error is `Infra`. `summary.json` and `cases.md` land under `--out`
beside `manifest.json`.
Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
The runner, Channel and shared helpers stay in cases.rs; R2, A2 and S1 move to routing.rs, authz.rs and state.rs with their tests. The fake channel becomes a shared test module. No behaviour change. Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
R1 drops and restores every (target, source) replicator through every
third node; R3 stops the target first and wants a clean 400 inside the
relay's dial budget, then service once it is back. `Channel` grows
`len`, `send_for` (token audience) and `control(Verb)`; the live channel
threads `Nodes::{stop,start_stopped}` through it.
Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
A1 is the operator's op-by-permission matrix, one row per op, every miss naming the grant it had. A3 revokes the operator's grant mid-run and A4 grants the outsider's, both through `acp node relationship` as the owner; A5 relays a token minted for node 1 to node 2; A6 turns NAC off on the target through its own `/acp/node/disable` route. The expectations are the design's: A1, A3 and A4 fail today because per-permission grants have no effect on the manage channel, and their Fail text is the reproduction. Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
On a real 3r0g run the target came back from R3's restart with its
schema but without its NAC relationships ("NAC enabled for the first
time" after the WAL replay), so admin's next request was refused and
every later case on that node inherited the loss. R3 keeps the
design's expectation and fails on the 403, then re-applies the actors'
grants through `Verb::Regrant` so the rest of the run stands on its
own.
Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
A case can now write and list documents at a node as the owner (`gql`, owned so a burst can run beside the ops), record a note the report keeps next to a pass, and bow out with `Skip` where the backend cannot host it. New verbs: partition and rejoin, a delayed SIGSTOP pause and resume, and a replicator added on a node's own API with filters. `age` is immutable so a replication filter may use it; document names never repeat because ids are content-derived. Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
S2 records whether `ReplicatorDelete` of an absent peer is a no-op or an error and wants it quick with the lists unchanged. S3 adds a filtered replicator through the channel, writes both ages at the source and wants only the matching documents at the sink, with the same filter as a locally added twin. S4 tracks a document with `DocumentAdd` and wants it on the target after an update at the source. Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
B3 locates the libp2p bound by doubling then bisecting a `DocumentAdd`'s ref count; it runs only under `--locate-size-bound` (or by name) since a refused probe leaves the target dirty. Located on 2026-09-17 at the target's 16 MiB `max_msg_size`: 258048 refs land, 258112 are refused, so the constant is pinned with an Iroh slot beside it. B1 and B2 sit 64 KiB under and over it. B4 freezes the target with SIGSTOP while it is still applying a 12 MiB request, so the relay's correlator must give up with its 30 s "response timeout" and serve a healthy target meanwhile; the target is resumed and its document list restored either way. Ops past 100 refs skip the after-state list read, which is not report material at that size. Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
P1 sends `CollectionAdd` into a partitioned target, rejoins it, and records whether the op landed or was lost; only a hang fails it. It skips where the backend cannot partition, which is every process cluster today. C1 runs a fixed op sequence on the source beside a 50-document burst written there and wants every op to land, the source's managed state back where it started, and the sink holding as many documents as the source after the settle window. Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
`--transport libp2p|iroh` (default libp2p) on `soak manage`. Under iroh the cluster is built with the harness's iroh transport on the binary `DEFRA_RUST_BINARY` names, which must carry `--features iroh`; the builder would otherwise build the workspace. Peer ids come from the last `/p2p/` segment either way, which is the iroh endpoint id under `host:port/p2p/<id>`. The manifest, `summary.json` and the `cases.md` heading record the transport. Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
The bounds cases read the channel's transport and size against its `Bounds`: the request bound, and B4's silent request with its pause delay. libp2p keeps the located 16 MiB and the 12 MiB / 1.5 s pause; iroh is pre-filled with `MAX_MANAGE_MSG_SIZE` (4 MiB) and a smaller pause, to be pinned by B3 and B4 on the transport. B3's note names the transport it located. Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
`Channel::send_for` takes `&self`, so a case can hold two relayed requests in flight at once. The live channel keeps its token cache and op records behind `RefCell`; the fake's rule likewise. Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
R3 records a verdict line for each of its two properties, the clean 400 from a stopped target within the dial budget and the relay serving the target after its restart, so a passing dial path is no longer hidden inside the restart Fail; the outcome is the first half that fails. The dial budget follows the transport: libp2p keeps 15 s, iroh gets 35 s because it gives up on a dead peer only after ~30 s. The markdown report keeps a case's notes beside every outcome, not only a Pass. Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
S4 polls the target until the document carries the name the update at the source gave it, not only its id, and its Fail says which it saw: no document, or the document without the update. `list_users` selects `name`, `converge` is a `settle` on ids, and the fake store applies `update_` mutations. Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
A3 no longer stops at a refused pre-revoke op, which made it a copy of A1 while per-permission grants are inert. It revokes and sends again regardless, and the verdict names one of three things: revoke enforced (a note on Pass), revoke not enforced, or revoke untested because the pre-revoke op was refused (grant inert, defect 1). The restore runs only when an add landed. Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
C1 timestamps the burst and every op against one clock and notes them; an op is inside the burst window when its reply came back before the burst's own did, and none inside fails the case as sequential, not concurrent. The fake's data plane answers `gql_ms` later in tokio time so a paused test can hold a burst in flight, or not. Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
The healthy-target request is joined with the silent probe and goes out a second after the pause has landed, while the probe still waits on the relay's correlator, instead of after it returned. The fake's `timed` knob holds a reply for its latency in tokio time so the test can see when the request was issued. Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
Before each case the runner sends the cheapest admin query to every node the case uses (`0..min_rust`): node 0 over its own HTTP as the owner, the rest through node 0 as relay. A node that no longer answers makes the case `Infra`, naming the last case that used it, instead of a `Fail` that reads as a finding. The default table puts the bounds group last so a target it wedges cannot poison the state, partition or concurrency cases; `--cases` now runs in the order given. Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
The state projection A2, S2 and C1 compare before and after now keeps each replicator's `filters` beside its peer and collections, so "unchanged" also covers a filter that moved. Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
After Verb::Start the restarted node is polled on its own HTTP as the
owner, half a second apart for up to 30 s, before the restart half runs;
a node that never answers is Infra ("target not reachable N ms after
restart"), not a Fail. The wait is noted beside the restart verdict.
Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
Go nodes join the mesh as replication peers, never as manage targets (Go has no manage protocol): the same wire_mesh gives them the schema (without @immutable, which Go lacks and which does not enter the collection id), connections and a replicator per ordered pair, all as the NAC owner they run under too. Channel::len is the Rust nodes; go_nodes lists the rest. mesh_from drops the source's Go replicators as well, since a Go peer forwards what it receives. The runner probes Go nodes over their own HTTP before each case and names one that is down. The manifest records each node's runtime. --transport iroh still refuses Go nodes. Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
H1 runs R2, A1, A2, S1, S3 and S4 with the Go nodes in the mesh, each its own row through Channel::embed, and its own row is about the Go nodes: after S3's and S4's writes every Go node has exactly the source's documents within the settle window, and its replicator set is the one the mesh gave it (peer id and collection ids; status fields flip on their own, and a count other than one per peer is a finding). Without Go nodes H1 skips. Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
Five solo libp2p 3r0g runs put the sink at 49 of 50 once. C1 alone leaves no earlier-case documents in the mesh, so the missing one was a burst document. C1 now fails on any burst id absent from the sink after the settle window, naming it, and reports the sink's total against the source's as a note: leftover mesh documents are not what C1 tests. Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
The replicator-set check counted entries and compared before to after, so a same-count set with a wrong member passed; the fake fixture itself carried a peer no node owns. Each Go node's replicator ids must now equal the peer ids of every other node in the topology. Claude-Session: https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
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.
What
soak manage: a pass/fail evaluation of DefraDB's P2P management channel on a real multi-nodecluster with node access control enabled. The channel lets one Rust node administer another's
replicators, collection and document subscriptions, and peer connections, relayed through any node's
POST /api/v0/p2p/manage. The two-node cases are already covered by defradb.rs's integration tests;this suite runs only what they cannot express: N-node fan-out through untrusted relays, three actors
with different grants, live grant and revoke, requests at the size bound, a target that stops
replying, ops concurrent with a write burst, and Go peers present in the mesh.
About 90 s for the full libp2p table.
<dir>receivessummary.json(per case: outcome, per-oplatency, the target's list state after each mutate) and
cases.md.Shape
generate_identity:admin(every permission),operator(two permissions ondifferent ops),
outsider(none). Tokens are minted withaud= the target's peer id, the way thenode's own
manage_relaytests do it.oversized request.
routing,authz,state,bounds,partition,hybrid),each restoring what it changes, each with red-first tests against a fake client. Outcomes are
Pass,Fail { expected, got },Skip { reason }, orInfra { error }, so a harness fault neverreads as a product finding.
Infra { <node> unreachable before <case>; last case to touch it: <name> }, and the bounds groupruns last, so one case that wedges a node cannot turn later cases into false failures.
--transport irohruns the identical table on Iroh with per-transport bounds (16 MiB on libp2p,the frame cap, located by bisection once; 4 MiB on Iroh) and per-transport dial budgets.
--topology NrMgon libp2p adds Go nodes as replication peers; H1 asserts every Go node convergesafter the Rust-side ops and that its replicator ids still match the mesh. Go is never a target; it
has no management protocol.
with_nac()on the cluster, all mesh wiring through the_with_identityverbs as the owner. Go nodes take the same startup identity.Deferred with explicit refusals:
--docker(so P1, the partition case, skips on the processbackend) and Go on Iroh.
What it finds on defradb.rs
main(c92cc09b0)Every
Failbelow reproduces a node defect; the expectations are the documented behaviour and thesuite does not bend them. Each was stable across at least three independent runs, and the suite was
reviewed adversarially twice, case by case, before this PR.
add-p2p-collectionis refused exactly like one with no grant; onlyadminauthorizes anything (crates/acp/src/nac/policy.rs:59-66)crates/db/src/nac/mod.rs:118-127takes the first-enable branch)DocumentAddunder the 4 MiB cap parks the endpoint loop; the node accepts nothing until restartIssues for each are drafted and will be filed against defradb.rs separately; this PR carries the
reproductions, not the fixes.
Verification
cargo test -p soak167,cargo clippy -p soak --all-targets -- -D warnings,cargo fmt --all -- check. Full-table runs: libp2p 3× identical at each of three heads, iroh 3× identical, hybrid 3×identical; only
slowest_msmoves between runs. Two case-by-case reviews of the suite itselfagainst its design, the second on the final head, with the review's five "passes but does not
establish the claim" findings fixed in the last eight commits.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Qy6KdB84tUcS6DxiVRmnNo