Skip to content

soak: a correctness and authorization suite for the P2P management channel - #35

Open
edjroz wants to merge 46 commits into
sourcenetwork:mainfrom
edjroz:edjroz/soak-manage-suite
Open

edjroz wants to merge 46 commits into
sourcenetwork:mainfrom
edjroz:edjroz/soak-manage-suite

Conversation

@edjroz

@edjroz edjroz commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Stacked on #33. This branch is 31 commits on top of edjroz/soak-topology, so until #33 merges
the diff below includes its 14 commits. GitHub cannot target a base branch that lives on a fork, so
the base stays main. Review #33 first; this PR's own change is the crates/soak/src/manage/
module, auth.rs, main.rs, nodes.rs, and the README section.

What

soak manage: a pass/fail evaluation of DefraDB's P2P management channel on a real multi-node
cluster 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.

export DEFRA_RUST_BINARY=<defra built from defradb.rs main>
cargo run -q -p soak -- manage --topology 3r0g --out <dir>                      # libp2p, all cases
cargo run -q -p soak -- manage --transport iroh --topology 3r0g --out <dir>     # same table on iroh
cargo run -q -p soak -- manage --topology 2r2g --cases H1 --out <dir>           # Go peers present

About 90 s for the full libp2p table. <dir> receives summary.json (per case: outcome, per-op
latency, the target's list state after each mutate) and cases.md.

Shape

  • Three actors from generate_identity: admin (every permission), operator (two permissions on
    different ops), outsider (none). Tokens are minted with aud = the target's peer id, the way the
    node's own manage_relay tests do it.
  • A raw HTTP relay client, no retries, so a case controls the exact bytes on the wire and can build an
    oversized request.
  • A table of 19 cases in six groups (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 }, or Infra { error }, so a harness fault never
    reads as a product finding.
  • The runner probes every node a case will use before running it; an unreachable node yields
    Infra { <node> unreachable before <case>; last case to touch it: <name> }, and the bounds group
    runs last, so one case that wedges a node cannot turn later cases into false failures.
  • --transport iroh runs 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 NrMg on libp2p adds Go nodes as replication peers; H1 asserts every Go node converges
    after the Rust-side ops and that its replicator ids still match the mesh. Go is never a target; it
    has no management protocol.
  • NAC is new for the soak harness: with_nac() on the cluster, all mesh wiring through the
    _with_identity verbs as the owner. Go nodes take the same startup identity.

Deferred with explicit refusals: --docker (so P1, the partition case, skips on the process
backend) and Go on Iroh.

What it finds on defradb.rs main (c92cc09b0)

Every Fail below reproduces a node defect; the expectations are the documented behaviour and the
suite 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.

case transport finding
A1, A3, A4 both per-permission NAC grants have no effect on the channel: an identity granted add-p2p-collection is refused exactly like one with no grant; only admin authorizes anything (crates/acp/src/nac/policy.rs:59-66)
R3 both a restarted node loses every NAC relationship while its schema survives (crates/db/src/nac/mod.rs:118-127 takes the first-enable branch)
S4 iroh a document never arrives at a node that was told to subscribe to it, though the node lists it as tracked
B1 iroh a DocumentAdd under the 4 MiB cap parks the endpoint loop; the node accepts nothing until restart
R3 (dial half) iroh dialing a stopped peer takes ~30 s; the relay's 10 s connect budget is not honoured
R3 (restart half) iroh about one run in three, the relay cannot re-dial a peer that restarted and is answering its own HTTP; reported distinctly from the NAC finding
C1 libp2p about one run in three, one document written during a burst concurrent with remote subscription edits is absent from the sink 20 s later

Issues for each are drafted and will be filed against defradb.rs separately; this PR carries the
reproductions, not the fixes.

Verification

cargo test -p soak 167, 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_ms moves between runs. Two case-by-case reviews of the suite itself
against 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

`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
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: fd1451b7-017d-4fc3-ad6e-3d0aca94f14c


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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