Skip to content

Release 1.0.0-pre.1: merge develop into master - #1252

Open
RyanKung wants to merge 199 commits into
masterfrom
develop
Open

RyanKung wants to merge 199 commits into
masterfrom
develop

Conversation

@RyanKung

@RyanKung RyanKung commented Nov 10, 2026 •

Copy link
Copy Markdown
Member

Summary

Bump workspace version to 1.0.0-pre.1 and synchronize master with develop covering all 19 1.0 Milestone PRs (#1233–#1250) and the upstream webrtc 0.17.3 upgrade (#1251):

RyanKung and others added 30 commits September 24, 2026 05:12
)

AEZ v5 implemented in-repo on `aes::hazmat::cipher_round`, replacing the
`zears` candidate from #834 (it does not build for wasm32, its GF(2^128)
doubling branches on key-derived bits, and it is unmaintained).

- Model: Block = GF(2^128) (branch-free doubling and scalar action),
  tweakable blockcipher E^{j,i} (AES4 / AES10) with a streamed offset
  sequence, AEZ-hash, AEZ-prf, AEZ-core and AEZ-tiny, each a pure map
  applied in place.
- API: `Aez::new(&[u8; 48])` rejects zero subkeys; `encrypt`/`decrypt`
  for any expansion tau; `encipher`/`decipher` as the length-preserving
  tau = 0 permutation. A failed decrypt zeroizes the buffer; subkeys are
  zeroized on drop; no unsafe, no panics.
- Gate: 1931 known-answer vectors emitted by the official reference code
  (generator committed), single-bit forgery rejection, and law tests
  (inverse laws over every length 0..=130, offset stream = closed form,
  field laws, the #834 carry composition). Every test runs natively and
  in the browser; CI gets aez wasm clippy and browser-test steps.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Add the OnionMonad specification paper (#834)

Freeze definitions D1-D9 and laws L1-L10 and L5' of issue #834 as an
IEEEtran paper before the Phase 2 code: the signature and partial
Sigma-algebras (registering any symbol implies relay), pipelines as
Kleisli composition with relay as the identity, the guard-closed loop
with s = 2 relays per segment (the guard counted in both end segments),
H(n, s) = (s+1)n + s and the derived MAX_ONION_LOOP_HOPS = 14, the
uniform layer and the fixed-length Sphinx-style header with its L5'
hybrid argument, AEZ carry segments on an in-repo AEZ v5 (L9 is
load-bearing for one value per key), SURBs with credit flow control,
noninterference over hop views, path authority (Arrow composition
admitted, bind excluded), unlinkability, replay, the accepted-leakage
table, and a section on the current design: the backward path forwards
one client-bound ciphertext byte-identical across relays that keep
return state, and nested layers leak each hop's distance to the end.

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

* Bound replay and keep SURBs out of the carry in the OnionMonad paper

L9 now states the admission window and capacity that the replay cache
enforces today: a layer is admitted iff arr < x <= arr + V with
V = ONION_FORWARD_MAX_VALIDITY_MS = 150 s, its tag is retained until
arr + V, and a sender's partition of B = 4096 tags rejects when full
without evicting a live tag. The new replay-bounds proposition derives
that no admitted layer outlives its tag, |R_i| <= 64B, a per-neighbour
rate of B/V, and a SURB lifetime of at most V.

SURBs no longer ride in the carry. Under L5' a SURB header has the full
header length, so carrying one would force C > H_max * l on every edge
(L3). A session symbol instead keeps the suffix of each forward loop as
its SURB; credit arrives only as forward loops, and a session idle for
longer than V holds none.

Layer sizes now count 48-byte AEZ keys: l(2, 64) = 649 B and
|beta| = 9086 B.

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

* Address the 580bb08 review of the OnionMonad paper

- Loop classes: C is fixed per loop. The client picks a class b from
  today's cell buckets above 4 KiB, C_b = b - |chi| - F, and the class
  holds on every edge, SURB replies included. tcp and https carry
  Bytes<C0 chunks, one per loop or reply. Add a "loop class b" row to the
  leakage table.
- Relay epoch: every published symbol, relay included, carries a process
  epoch drawn at start, so a restart that empties R_i rejects every older
  layer; L9 and the one-value-per-key proposition hold across restarts.
- Expiry: x is one per loop and quantised to 30 s; add a leakage row for
  the shared quantum; D9 uses x.
- Client demultiplexing: the client is position H+1 and lambda_H carries
  a fresh tag t in place of alpha_{H+1}; the client looks up loop state
  by tag and accepts it once. Header correctness covers i = H.
- Low: name the guard as the exception in L5'; encode () as epsilon so
  every consumer authenticates; add the session branch and Algorithm 2
  (reply); prove "one value per key" and fix its wording; relay has no W.

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

* State that Sigma is closed in the OnionMonad paper

D1: a name outside Sigma is rejected at configuration, at descriptor
decode, and at route construction; nothing falls back to tcp and
operator-named services are not accepted. The Phase 1 row records that
this changes configs, not wire bytes.

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

* Tighten the OnionMonad paper after a full re-read; allow its terms in typos

Paper:
- L4 names the one exception: a WorldFacing session reply leaves when
  world bytes are ready, before the SURB's x; leakage row added.
- D6 fixes the expiry rule x = ceil(t_build/Q)Q + X0 with Q = 30 s and
  X0 = V - Q = 120 s (today's ONION_FORWARD_PAYLOAD_TTL_MS), so x depends
  only on the build quantum; SURB lifetime is at most V.
- L5 is computational (the header rests on PRG/MAC); equality in
  distribution holds for the carry alone.
- L9 states what a sender beyond the 64th gets; Prop. 9 adds the per-hop
  total admission bound 64B/V.
- L3 separates fixed-width loops (least b that fits, v0 included) from
  stream loops (b is the client's choice; leakage row added).
- The L5' proof covers the carry and arrival time; D9 drops the duplicate
  bucket; F is defined; D2 wording; Algorithm 1 decodes before writing to
  the world; Algorithm 2's time is t0; Table III says "no SURB credit".

typos: Tung (bibliography author) under the proper-name words, SURBs as
an identifier, and the amsmath multline environment ignored in tex files.

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

* Rename the paper to Onion Kleisli Pipelines

The exposed structure is not a monad: L6 excludes bind, and a circuit is
a static composite in Kl(M), a path in the free category on Sigma. M is
only the effect inside one hop. Rename papers/onion-monad.* to
papers/onion-kleisli-pipeline.*, retitle the paper, and replace the
sentence that justified the old name; update the README row, the build
command, and the typos comment.

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

* Count the MAC in the layer size and factor the view lemmas

- l(s, A) now includes the 16-byte next-hop MAC gamma that every layer
  carries: l(2, 64) = 665 B, |beta| = 9310 B (raw encodings about 547 B).
- The client position is D6' as in #834, and states that the tag is
  padded to |alpha| so the last cell keeps the loop's length.
- Lemma "Header view" and Lemma "Relay carry" carry the two arguments
  that Props. 6 and 7 previously took from each other.
- The abstract names the onion Kleisli pipeline and the per-loop slot
  width; the two class rows of the leakage table are merged; unused
  theorem environments and a macro are removed; the README lists
  stmaryrd and multirow.

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

* Model AEZ as an ideal enciphering scheme and place applications on nodes

- Assumption 1 models AEZ as an ideal enciphering scheme for tau = 0 and
  tau = 16 alike (E^tau_k(m) = Pi_k(m || 0^tau)), so the last relay's
  carry is exactly uniform and Prop. 6's claim holds; Lemma 2 names its
  dependence on L9.
- D4b takes the functor on Free(Sigma x N), placed applications
  (f, a, h), so one application placed on two nodes is two edges and
  [[P >>> P']] = [[P]] >>> [[P']] holds strictly.
- D6' states where the client tag goes with blinded key transport.
- L3 says stream chunks fill C0 - 1 bytes; the two L3 rows of the
  obligations table are merged.

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

* Scope L5's timing to non-reply paths and fix three wording errors

- L5 excludes arrival times on a session symbol's reply path: they follow
  the world (L4's exception) and may depend on the session hop's
  arguments; that residual is the session-reply row of Table II. Prop.
  6's timing step says so.
- D6': padding to b applies to both key-transport variants; the blinded
  variant's sentence is separate.
- The introduction attaches the name to the general structure, not to
  today's circuit.
- The threat table's position row uses C_b.

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

* Key SURBs by session, state L5' with its timing term, and fix D6'

Addresses the review of 02e5c17 (issuecomment-5803108280).

- Algorithm 2 removes the SURB it spends, so Prop. 4 and Inv. 1 hold.
- New D2': a session symbol carries a client-chosen session id and a
  Frame type (data/fin). SURB pools are Q_{h,sid}, so a reply spends only
  its own session's SURB. Open, credit-only and close are defined.
- L5': x joins the premise, and the law carries the timing term
  |D_i - D'_i'|/Q, because x fixes departure to within Q. Prop. 7 proves
  it tight, the assumption states the uniform build phase, and Table II
  gains the row.
- D6': next_H names the client in both key-transport variants (the
  blinded tag gets its own field), gamma_{H+1} is uniform and ignored,
  and T entries expire at x.
- D1/L3: stream symbols have no W; frames are bounded by C_0.
- The free category is on the application graph G_Sigma (G^N_Sigma
  when placed), not on Sigma.
- Consumed values are hat v_i (segment values v_k), not v_{i-1}.
- L9 note: the guard quota caps a client's total rate at B/V cells/s.

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

* Scope https frames to request/response; TLS tunnels use tcp

Mirrors the #839 ruling that #834's D2' now records: https carries the
HTTPS request/response encoding only, with no decode fallback, and TLS
tunnels use tcp.

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

* Bring the paper in line with the #834 throughput redesign

Target: at least 1 MB/s of download per client at the 16 KiB class.

- D6'': Sphinx single blinded group element, raw 20-byte DIDs, 32-byte
  carry seeds; l = 221 B, |beta| = 3094 B, |chi| = 3143 B, C_16KiB
  about 13.2 KB. Table 1 and the header definition follow; the
  per-layer-envelope variant is gone.
- D7: one segment seed; relay and consumer seeds and 48-byte AEZ keys by
  domain-separated HKDF-SHA256; a weak derived key re-draws the seed.
- D2' session symbol: session id and target, Frame = data | fin |
  credit, data at most C0 - 2 bytes. D6' uses the tag field present in
  every layer and next_H = client DID.
- D8: per-session SURB pools; Prop. "SURB batching" replaces "No SURB in
  the carry" (|upsilon| = 3203 B, k = floor((C0 - 2)/|upsilon|), 4 for
  framing up to 411 B, at least 3 up to 3.6 KB); credit invariant per
  session.
- L9: x-keyed Bloom filters (no false negatives, false positives at most
  2^-20, about 3.8 MB), B = 16384, global 64B/V, replacing the crypto
  limiter; Prop. "Replay bounds" rewritten; new Prop. "Throughput":
  B/V * (C0 - 2), about 1.44 MB/s at 16 KiB.
- L5/L5' use the consumed value v-hat; L5' carries the timing term
  |D_i - D'_i'|/Q, since x fixes t_build within Q; two leakage rows.
- Obligations for throughput, replay store, seeds, session credit and
  client tags; phases and parameters updated, none open.

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

* Bound the replay store honestly and fix three proof statements

- L9: each x-keyed Bloom filter grows by blocks of B tags at
  false-positive rate 2^-26. A filter receives at most 64B tags (arrivals
  in [x - V, x) under the global budget), so at most 64 blocks and a
  false-positive rate of at most 2^-20. Memory tracks admitted tags and is
  at most about 5.4 MB (70 blocks of about 76.8 KB). The earlier 3.8 MB
  assumed one pre-sized filter; pre-sizing six would need about 23 MB.
- Lemma 1: alpha_{i+1} is computed by hop i; DDH makes alpha_i, not
  alpha_{i+1}, uniform to i and unlinkable to other hops.
- D6': every SURB ends in its own tag; a batched SURB gets a fresh one.
- Prop. "Throughput" is for a client alone on its links, and its proof
  covers the forward credit cells; Prop. 9(iii) says the per-neighbour
  budget is shared by every loop on the link.
- Cite Bloom (1970) and Almeida et al. (2007).

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

* Charge admission by class and cap SURB pools

- L9: budgets count 16 KiB units; a class-b cell costs b/16 KiB units,
  as today's cell buckets charge the full visible size. Replacing the
  byte-budgeted crypto limiter with a cell count would let a neighbour
  push 12 MiB cells at 109 cells/s. Per neighbour this is now at most
  about 1.79 MB/s of cells, about 115 MB/s in total; a 16 KiB cell is one
  unit, so the 16 KiB throughput is unchanged.
- D8 / Inv. 1: a session's pool holds at most Q_max = 256 SURBs (about
  820 KB); credit beyond it is dropped, and all pools at h count against
  quota(f). Without a cap one client could make h hold (k+1)B SURBs,
  about 260 MB. Q_max does not bind below a 2.3 s loop round trip.
- Prop. "Throughput": larger classes approach B * 16 KiB / V, about
  1.79 MB/s.
- Threat rows for large-class floods and SURB-pool exhaustion; the credit
  obligation says reading resumes on new credit.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ignature (#834) (#839)

* Pin onion wire bytes before the OnionMonad refactor (#834)

Golden tests fix the exact Rings-codec encodings of
OnionForwardLayer::{Relay, Exit}, OnionWireMessage::{Forward, Backward,
Cover}, the OnionWireCell envelope and its plaintext framing, the
forward-layer and cell AEAD contexts, and the onion-exit descriptor
signing data. Every pinned byte comes from constants; no RNG reaches the
compared bytes.

Phase 1 of #834 is a pure refactor (law L10): these bytes must not change
for the rest of the phase.

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

* Interpret onion circuits as pipelines over a static signature (#834)

Phase 1 of #834: a pure refactor with no wire change and no version bump.
The golden tests from the previous commit stay green unchanged.

- onion/signature.rs: OnionSymbolSpec and the static table ONION_SIGNATURE
  = {relay, tcp, https}. Resolution is total: a name outside the table
  denotes an operator-named backend of `tcp`, which is what native exits
  already serve. The default exit service lists, OnionServiceName::{tcp,
  https} and the proxy service constants all read from the table. An exit
  registration naming the identity symbol `relay` is rejected.
- onion/pipeline.rs: OnionPipeline, a non-empty sequence of
  OnionApplication { symbol, args }. `then` (⋙) rejects any application
  after a world-facing symbol, and `relay` takes no arguments.
- circuit/crypto.rs: forward layers are a right fold over the pipeline's
  positions instead of an index loop.
- circuit/reducer.rs: advance_forward dispatches on the symbol's role.
  Identity takes the relay arm and world-facing takes the exit arm. An
  exit layer naming `relay` is ill-typed and dropped.
- circuit/shell.rs: OnionCircuitHandler::handle_exit becomes the
  symbol-indexed OnionAlgebra. On native, the https-then-tcp chain becomes
  the copairing registered for `https`; the browser registers `https`
  alone.
- route/mod.rs: a route request denotes relay^{k-1} ⋙ service, and
  selection fills each position from nodes registering its symbol. The
  route tests are unchanged.

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

* Close the onion signature: reject service names outside Σ (#834)

The Onion Kleisli pipeline signature Σ = {relay, tcp, https} is closed.
Operator-named services get no backward compatibility.

- OnionServiceName moves into onion/signature.rs as the closed name type:
  it wraps a &'static OnionSymbolSpec, and parsing admits exactly the
  names of Σ. A name outside Σ is therefore rejected at configuration
  (config and CLI parsing), at descriptor decode (serde try_from, where
  it is counted as a rejected registry value) and at RPC. No route,
  layer or algebra entry can name it. spec() is a total projection with
  no tcp fallback, and OnionAlgebra keys its table by the symbol spec.
- The encoding stays the canonical name string: no wire byte changes,
  and the golden tests pass untouched.
- Proxy configuration also rejects the identity symbol relay, via
  OnionServiceName::world_facing_spec.
- Tests and configs that used web/api/custom/ssh now use tcp, or https
  where two distinct services are needed. An exit's reach is restricted
  by its policy targets, not by a service name.
- Docs: SECURITY.md registry paragraph, mdBook config.yaml and
  native-node pages, and the CLI help now name the closed set.

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

* Type the onion pipeline along Σ = {relay} ⊎ Σ_W (#834 review round 1)

- OnionServiceName is the closed name type of Σ_W = {tcp, https}; relay
  is not a service name. Descriptor decode, config and RPC reject it
  like any name outside Σ_W, and the three runtime world_facing_spec
  checks are gone. OnionSymbolRole merges into OnionSymbolPosition.
- pipeline.rs holds the closed normal form relay^k ⋙ s:
  OnionSymbolWord σ (request and route) with a single substitution
  σ[ā], and OnionPipeline<P> as the labelled hop assignment. There are
  no ε placeholder pipelines and no second construction of the word.
  OnionApplication is gone, since OnionCircuitPayload is the terminal
  application.
- crypto.rs: assign_edges labels the route's positions with edge ids.
  build_forward_layers folds right over the relay positions with no
  length check and no unreachable arm. PipelineLengthMismatch,
  CircuitPathLengthMismatch and the other dead variants are removed.
- The reducer matches on the layer directly; Exit applies
  payload.service ∈ Σ_W by type.
- OnionAlgebra : Σ_n ⇀ Kl(M) is keyed by OnionServiceName, and the
  native algebra registers exactly the configured exit services. The
  TCP runtime's second allows_service filter is dropped.
- https ⊑ tcp: native https keeps HTTPS CONNECT tunnels, and its
  interpretation is named for what it is, the left-biased alternative
  ⟦fetch⟧ <|> ⟦tcp⟧. The docs record the exact wire overlap, which
  needs a Phase 2 tag.
- Docs: Kleisli-arrow and hop-effect-monad wording, relay width,
  SECURITY.md.

Wire bytes are unchanged; the golden tests are untouched.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ssion ledgers by link (#834) (#849)

* Bind the cell class in γ, deliver the client tag as γ_{H+1}, recycle only idle admission partitions (#834)

- L8: γ_i = MAC_{K_i}(b ‖ β_i). Under F = 0 the class is only the cell
  length, which τ = 0 relays preserve, so a re-padded cell would carry a
  length tag; the next honest hop's γ check now drops it.
- D6′: t_⋄ := γ_{H+1}, drawn as the next-MAC field of λ_H and forwarded by
  the guard as γ of χ_{H+1}; the per-layer tag field is removed.
  ℓ 221 → 205, |β| 3094 → 2870, |χ| 3143 → 2919, C_16KiB = 13465,
  |υ| 3203 → 2979, k = ⌊13447/2979⌋ = 4, payload rate ≈ 1.47 MB/s.
- L9: a 65th sender only takes an idle partition, else it is rejected,
  so no loaded ledger is reset by identity rotation; per-sender window of
  5 aligned quanta (burst 2B within 4Q + ε, long-run B/V); exact sliced
  Bloom rate (1 − (1 − 1/M)^B)^K ≤ 2⁻²⁶ with K = 26, M = 23680; at most
  V/Q = 5 live filters, 69 blocks ≈ 5.3 MB.

Part of #834

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

* Key admission ledgers by live links and order γ before admission (#834)

L9: replace idle-only partition recycling (at most 64 senders) with
ledgers keyed by the sender DID of a live authenticated link. The table
holds 2R ledgers (R = connection-registry capacity, #723), a ledger is
released only once its link has closed at zero window load, and a
reconnecting DID finds it again; no recycling, no LRU, a full table
fails closed. The global cap G = 64B is its own constant bounding the
replay store. New Prop. "Ledger table"; Replay bounds, threat table and
obligations updated.

D6: state the F = 0 consequences (no per-edge cell AEAD, cover cells
fail γ, γ before replay insertion and admission charge, ECDH cost of a
γ failure charged to the sender) and reorder Algorithm 1 accordingly.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ceipt with an arrival-bound token (#834) (#851)

* Open backend names under closed invoke shapes (D1'), and charge on receipt with an arrival-bound token (#834)

Signature (D1'): Sigma is a finite table of shapes, {relay, tcp, https}
plus invoke<In, Out, W, L> over protocol-fixed W/L classes. Backend
names are open registration data (D2, per (shape, backend), canonical
<= 32 B over [a-z0-9._-], validated against signed descriptors, quota
per (node, shape, backend)) and the first component of the sealed
arguments (D3), so sigma shows shapes only. World-facing shapes stay
exactly {tcp, https}; names are admitted only under invoke shapes.
L5 holds up to registry inference; the leakage table gains registry
inference, backend anonymity set and hosted-backend rows.

Admission (L9): every received cell is charged u(b) once on receipt,
before alpha decoding and the ECDH, through an affine token carrying the
arrival instant; admission judges the window at that instant and
requires a live filter (x > clock). Algorithm 1 is reordered to
charge -> alpha -> ECDH -> gamma -> admit -> insert. A full ledger table
refuses the link at link_opened. Cover cells are rejected at alpha
decoding, otherwise at gamma.

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

* Bind the admission token to (arrival, epoch) and track links as core link generations (#834)

L9: the affine token is tk = (arr, e_tk), the arrival instant of its
charge and the epoch of the state that charged it; admit requires
arr < x <= arr + V, x > clock and e_tk = e = epoch_i. The sender and
unit count leave the token.

Links are core link generations (DID, g) in a live set fed from core's
Admitted/Retired. link_opened may refuse on a full table (the shell
closes the link), link_closed is idempotent, and charge requires the
link to be live, so a cell on a link not yet live, refused or closed
is never charged or peeled. An epoch reset keeps the live set and
clears ledgers, loads, the clock and the replay store.

Algorithm 1, Prop. Ledger table, Prop. Replay bounds, the threat
table and the obligations table follow.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… positions first (#834) (#854)

* Reconcile live links, require a fresh epoch on reset, and draw symbol positions first (#834)

- L9: pure reconcile_i(L) rebuilds the live-link set from core's registry
  snapshot (closes absent generations, opens missing ones with the
  full-table refusal); the shell calls it on callback replacement and on
  a tick of period <= V.
- L9: an epoch reset requires e' != e (a reused epoch would let the
  cleared replay store re-admit a replay, breaking at-most-once and the
  AEZ one-value-per-key bound), takes the same snapshot, and is
  triggered by the pure predicate is_rolled_back_at(now) := now < clock - X0.
- Ledgers are keyed by the sender DID; a DID with several live
  generations has one ledger.  admit_i takes (tk, e, x, nu), as the
  implementation does.
- L7 draw order: symbol positions, then the guard, then relays, under a
  Hall filter over all pending positions; new Prop. (Draw order).
- Threat rows: lost Retired event, same-epoch renewal, exits consumed as
  guard or relay.  Obligation rows: reconcile, fresh-epoch reset, and
  exact-enumeration test of exit and guard marginals.
- Obligations table spans both columns (it overflowed the column).

Part of #834

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

* Reset as clear-then-reconcile, linearise snapshots, draw epochs uniformly (#834)

- reset_i(e', L) = reconcile_i(L) . clear_i(e'): the live set after a
  reset is L \ refused, not L.
- Linearisation obligation: the registry snapshot is delivered on the
  Admitted/Retired channel or applied atomically at the queue-drain
  point; both failure directions stated; premise of Prop. Ledger (vi).
- e' is drawn independently and uniformly from {0,1}^128 (e' = epoch_i
  refused, redrawn); Prop. Replay (vi) bounds epoch reuse over k resets
  by k^2 2^-129 (any earlier epoch) and k 2^-128 (a fixed one).
- Clock: every step, reconcile included, joins now and drops expired
  filters.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ssion (#834) (#844)

* Add the pure L9 admission step with an x-keyed Bloom replay store (#841)

Part of #834 Phase 2a-2. onion::circuit::admission is the pure step
δ : S × Time × I → S × (1 + Rejection). It checks the process epoch,
the window arr < x ≤ arr + V, ν ∉ R_i[x], and the per-sender (B = 16384)
and global (64·B) unit budgets. It commits only on admission.

R_i maps the on-grid expiry x to a sliced Bloom filter. The filter grows
in blocks of B tags at a rate of ≤ 2⁻²⁶ each, from 26 independent
keyed-Keccak addresses, and is dropped whole once x passes. Budgets are
ledgers over the 5 aligned arrival quanta of V. The least recently
active sender partition is recycled with a fresh ledger. The clock is
monotone.

Not wired into the data plane yet: 2a-4 replaces circuit/limiter.rs with
this step.

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

* Address #844 review: link-keyed ledgers, charge-once, exact Bloom bound (#841)

Spec changes (#834 L9, per the dispatcher):
- Sender ledgers are keyed by the DID of an authenticated link. A ledger
  is created by link_opened and released only after link_closed with
  zero window load. The table holds 2·R ledgers (R = the connection
  registry capacity). There is no recycling and no LRU; a full table
  fails closed. The global cap G = 64·B is an independent constant.
- Every cell whose key was computed is charged exactly once, in admit
  or in charge_invalid (γ failure), and replayed or expired cells pay
  too. headroom() is the pure pre-ECDH check that shares the rule with
  the charge.

Review fixes:
- H1: the property test's walk now makes forward progress, and the test
  asserts at least 100 window rotations.
- M1: the exact expected fill is (1 − (1 − 1/M)^B)^K, stated in the doc
  and checked in the geometry test.
- M2: the liveness cost of the monotone clock, and the 2a-4 duty to
  switch epochs on a rollback greater than V, are documented.
- M3: OnionExpiry::admissible_at is used by admission and the reducer.
- Lows:
  - OnionExpiry is a quantum index, and OnionExpiry::of_build is the
    single grid source, replacing quantized_forward_expiry.
  - Probe addresses are u16 over typed [[u64; W]; K] blocks, with
    const-asserted geometry.
  - The ledger's cell selection is total.
  - The filter key is no longer Clone and is zeroised on drop.
  - The budget, memory, idle-drop and representability docs are made
    precise.
  - Items are pub(super).
  - A memory-bound test covers all live filters.

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

* Address #844 round 2: linear charge token, link refusal, counted links (#841)

- H1: charge() commits before α/ECDH and returns a linear token.
  - The token (OnionAdmissionCharge) is not Clone or Copy and can only
    be built by charge().
  - admit() consumes the token. Dropping it settles an invalid α or a
    failed γ.
  - headroom() and charge_invalid() are removed.
  - "Exactly once" is now structural.
- H2: link_opened() refusal (OnionLinkTableFull) is a link refusal. The
  shell closes the link and the peer redials; this is documented and
  tested.
- M1: ledgers count open links, which is correct under any interleaving
  of link generations. The shell precondition is stated.
- M2: advance() sweeps releasable ledgers on every new quantum, so no
  ledger is releasable after any step.
- M3: the reducer change is reverted to develop (it belongs to 2a-4,
  #843). The two exit tests now use on-grid expired and overlong
  instants.
- M4: rejections are split into OnionBudgetRejection (charge),
  OnionAdmissionRejection (admit) and OnionLinkTableFull (link_opened).
- Lows:
  - charge() does a single lookup.
  - The liveness doc is corrected (X₀ threshold, epoch cost), and the
    skew and shared-pool notes are added.
  - The stale "6" is removed and the lower-bound wording is fixed.
  - ReplayProbe has no derives.
  - The churn test now runs under load and uses 2·R.
  - The memory test now saturates over ten quanta.
  - The OnionExpiry grid source moves to circuit/mod.rs.
  - zeroize is a workspace dependency.

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

* Address #844 round 3: token binds arrival and epoch, affine link handle (#841)

- H1: OnionAdmissionCharge records the charge's arrival and epoch.
  admit judges the window at token.arr (token.arr < x ≤ token.arr + V)
  and also requires clock < x, so a late admission neither widens the
  window nor writes a dropped filter, and G ≥ |R_i[x]| holds again. A
  token from another epoch is rejected.
- M1: the Charging law now claims only what the types give: admission
  requires a paid token, admitting at most once. Binding a token to its
  cell and units, and gating peel on it, are 2a-4 shell obligations.
- M2: link_opened returns an affine OnionAdmissionLink that link_closed
  consumes, so a refused link cannot decrement a live generation.
- Lows:
  - OnionExpiry moves to circuit/expiry.rs with a private field; its
    only entry points are of_build and the shell-visible parser from_ms.
  - The saturation doc is corrected, and "linear" becomes "affine".
  - The memory test now saturates G every quantum.
  - The exit tests sit at the window boundary, with an admitted control
    that pins the reason.
  - New tests cover the instant mismatch and a foreign-epoch token.
  - The redial is stated as a liveness assumption, and the doc notes
    that the hop order is charge-on-receipt.

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

* Address #844 round 4: links as core generations, epoch renewal (#841)

- M1–M3 (#834 L9 decision): the affine link handle is replaced by the
  plain value OnionAdmissionLink { did, generation }, fed from core's
  Admitted/Retired events.
  - The state keeps the live generations per DID, capped at 2·R.
  - link_opened is idempotent on a live link.
  - link_closed is idempotent.
  - charge requires that exact generation to be live (LinkNotLive
    replaces UnlinkedSender).
  - This removes the drop-leak, cross-state closes, and charging on
    links that are not yet live or already closed.
- renewed(epoch, key) is the epoch reset. Every DID with a live link
  keeps a zero-load ledger. The clock, loads, replay store and closed
  ledgers are cleared. Tokens from before the reset are StaleEpoch.
- The Charging and link docs now state exactly what the types
  guarantee and what the 2a-4 shell owes.
- Lows:
  - admissible_at and has_passed_at move to expiry.rs as pub(super).
  - Tests use expect instead of if-let.
  - A new property test holds tokens across quanta, admits them late,
    and checks at most G per filter.

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

* Make the late-admission test discriminate the H1 bound (#841)

The random schedule in 4c35aa6 never came close to G per filter (its
fullest filter held 169 of 1365 cells), so it passed even with the
window judged at admission time.

The new schedule is seeded and adversarial. For each of four rounds:
- Phase A: every DID spends its budget one quantum before x's window
  and holds the tokens until x is admissible by the clock.
- Phase B: every DID spends its budget again inside the window.

The fix yields exactly G/u tags for x; judging the window at admission
would yield 2·G/u. Verified locally by temporarily reintroducing the
old check.

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

* Address #844 round 5: fresh-epoch renew, reconcile from core's snapshot (#841)

- M1: renew(&mut self, epoch, key, live) rejects the current epoch
  (OnionEpochNotFresh) and then changes nothing, so a same-epoch reset
  cannot clear the replay store and re-admit a replay. It replaces
  renewed(self, ...), so a refused renewal cannot consume the state.
- M2: reconcile(now, live) rebuilds the live-link set from core's
  registry snapshot. It closes absent generations first, then opens
  missing ones with the full-table refusal, and returns the refused
  links. renew takes the same snapshot. The doc drops "nothing can
  leak" and states the event-pairing obligation: the shell reconciles
  on callback replacement and on a tick of at most V.
- Lows:
  - The token epoch is shown in the pseudo-code.
  - The ledger guarantees are qualified "within one epoch".
  - Add a pure is_rolled_back_at(now).
  - The live-link cap is documented and tested as a memory bound only.
  - live_link_count is derived rather than stored.
  - charge takes the link by value; the link type loses Ord; lookups
    go through a single entry; OnionBudgetRejection is renamed
    OnionChargeRejection.
  - The late-admission test admits each token at its release instant.
  - The churn property picks random, possibly stale, generations.

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

* Address #844 round 6: linearisation obligation, witnessed reconcile laws (#841)

- M1: state the linearisation obligation. The reconcile snapshot must
  arrive through the same ordered channel as Admitted/Retired, or be
  read and applied atomically at the queue-drain point. "Agrees with
  core" holds only under it.
- M2: witness the laws.
  - The renew test now uses a snapshot that differs from the live set.
  - A new seeded property injects lost events and drives two states in
    lockstep, one given sorted snapshots and the other shuffled ones.
    After each reconciliation it asserts: 𝓛 = L \ refused; no
    previously live link is refused; a second reconcile is a no-op;
    shuffling gives the same state. After each step it asserts
    LinkNotLive ⟺ not live.
  - Mutation-checked.
- Lows:
  - State the fresh-epoch obligation (a uniform 128-bit draw).
  - Add #[must_use] OnionRefusedLinks for reconcile and renew.
  - reconcile opens in an explicit loop with no to_owned on Copy.
  - Note that reconcile's full-table priority is DID order.
  - Fix doc drift; the reset ρ is separated from the timed δ.
  - The churn property asserts LinkNotLive ⟺ not live in the model.
  - Split the tests by proposition into tests/{window, budget, links,
    bloom}.rs.

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

* Address #844 round 7 Lows and the epoch-reuse bounds (#841)

- The lockstep reconcile property now justifies every refusal, on both
  the event path and the reconcile path (is_full_for), and asserts that
  opens, closes and both kinds of refusal all occur.
- The linearisation obligation names both reconcile's and renew's
  snapshots, and is stated positively: live = registry \ refused.
- reconcile starts with advance(now), per the clock law.
- The reset ρ is named in the renew doc; the renewal test is renamed
  test_renewal_rebuilds_live_links_from_the_snapshot.
- Over-long doc lines are rewrapped to 100 columns, and the test helper
  live() is renamed live_filters().
- Both epoch-reuse bounds are cited: ≤ k·2⁻¹²⁸ for a fixed earlier
  epoch, and ≤ k²·2⁻¹²⁹ for A → B → A (paper #854).

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

* Address #844 round 8: line widths, split property test, pinned refusals (#841)

- Rewrap admission/mod.rs:153 and test_circuit_protocol.rs:942 to at
  most 100 columns.
- Split test_reconcile_restores_core_truth_under_lost_events into step
  functions (open, retire, reconcile, charge) over one Lockstep model
  struct; no function exceeds 100 lines. is_full_for counts live links
  from the model.
- test_reconcile_recovers_lost_link_events pins the DID-order refusal:
  refused = link(13..20).
- The docs state ρ = reconcile ∘ clear (up to refusals), and that renew
  refuses nothing when |L| ≤ 2·R.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… as pure primitives (#840) (#845)

* Add Sphinx header, carry seeds and AEZ segments as pure onion primitives (#840)

Phase 2a-1 of the onion Kleisli pipeline (#834). New module
`onion::sphinx`, not wired to the data plane; no live wire message
changes.

- layer: the uniform layer λ (D6, D6″) with a fixed-width encoding,
  ℓ = 221 as a const sum of its field widths; relay layers carry no
  argument field, and decoding rejects non-canonical strings.
- header: the fixed-length Sphinx header χ = (α, β, γ) of
  MAX_ONION_LOOP_HOPS = 14 layers (|χ| = 3143) with one blinded
  secp256k1 element, HKDF key schedule, ChaCha20 filler, truncated
  HMAC-SHA256 γ, and a wide-reduced nonzero blinding scalar; peel
  rejects an invalid α before ECDH and checks γ before decoding.
- seed: D7 two-level HKDF-SHA256 seeds and 48-byte AEZ keys, weak-key
  detection and re-draw.
- carry: AEZ carry segments on rings-aez (τ = 16 innermost, τ = 0 per
  relay, () = ε).
- class: loop classes 16 KiB – 12 MiB with C_b = b − |χ| − F, F = 0.

Supporting changes: DelegateeKey::diffie_hellman in rings-core, a
one-byte layer code per symbol of Σ, and to_bytes accessors for the
epoch and replay nonce.

Tests discharge header correctness for every H ≤ 14 and every position
(i = H included), L5′ lengths, L8 edge inequality and whole-value
rejection on a one-bit flip, and the seed derivation. The peel-cost
benchmark measures 1626 cells/s on wasm32 (headless Chrome) against the
109 cells/s target.

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

* Break the peel-cost benchmark down per primitive (#840)

Time ECDH, blinding, the HKDF schedule, the ChaCha20 PRG, the HMAC,
the carry key setup and the AEZ decipherment alone, beside the real
header, carry and whole-cell paths. Every row gets one untimed warm-up
pass and 2000 timed passes over state built outside the timed region,
identically on both targets; the native run uses --test-threads=1.

The breakdown locates the native-slower-than-wasm carry in AES: without
--cfg aes_armv8, aes 0.8's soft hazmat round on 64-bit targets
bitslices four copies of one block per round (two on wasm32).

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

* Address the #845 review: class-bound γ, client tag as γ_{H+1}, typed ecc (#840)

Spec changes decided in #834 (paper 800ea69):
- H1: γ_i = MAC_{K_i}(b ‖ β_i) binds the loop class (b as its 8-byte
  big-endian cell length); a relabelled cell dies at the next honest hop.
- H2: the client tag is t_⋄ = γ_{H+1}, drawn by the client as λ_H's
  next-MAC field and forwarded by the guard. The per-layer tag field is
  gone: ℓ = 205, |β| = 2870, |χ| = 2919, C_16KiB = 13465.

Review fixes:
- M1: the sphinx module and its items are pub(crate) under one scoped
  `#[cfg_attr(not(test), expect(dead_code))]` until #843; the raw-bytes
  OnionCarryKey constructor is removed (KDF₄₈ of a seed is the only one).
- M2/M3: rings-core gains ecc::prime_order (NonIdentityPoint,
  NonZeroScalar: G ∖ {O} and Z_n^* closed under the module action,
  SEC1, wide reduction); node no longer depends on k256.
  DelegateeKey::diffie_hellman takes a NonIdentityPoint and returns the
  zeroizing x-coordinate.
- M4: segment relay keys are an array.
- M5: OnionLoopClass(OnionCellBucket) with TryFrom rejecting KiB4;
  OnionCarry keeps only its bytes.
- M6: the unreachable error paths are gone; the peel doc lists the
  checks in code order.
- L1: layer codes are table positions; OnionSignature::code/by_code,
  with by_code returning the Σ = {relay} ⊎ Σ_W coproduct.
- L2: one field table (layer_record!) derives ℓ, encode and decode;
  take_array is removed. Headers are Ĥ blocks of ℓ bytes, split by
  irrefutable array patterns.
- L3: blind, HMAC key, shared secret and blinding are zeroized; seeds
  compare in constant time; seeds and layers are not Clone; γ has only
  ConstantTimeEq.
- L4: ρ_i is computed once per hop; relay indices are a length-checked
  const array; draw fails closed after four weak draws; the hop count
  is checked once, in OnionHeaderRoute::new.
- L5: tests for −α, the guard key at positions 1 and H, class
  relabelling and the tag reaching the client.
- Benchmark: web_time::Instant (performance.now() in the browser),
  200 warm-up runs, black_box on every result.

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

* Address the #845 round-2 review: OnionCell parser, ecc subtypes, crate-private Σ codec (#840)

- M1: OnionCell is the one parser of a received cell, |w| ↦ (b, χ, y)
  with b the observed length; the class flows from the parsed cell to
  every forwarded cell (peel → relay | consume → produce) and is never
  an argument at a hop. OnionHeader::peel is sphinx-internal. The
  relabel test is cell-level; a new test runs one whole loop through
  cells (two relays, a symbol hop, two relays, the client).
- M2: rings_core::ecc::prime_order is rebuilt as subtypes of the group
  carriers, NonIdentityPoint<C> ↪ Point<C> and NonZeroScalar<C> ↪
  Scalar<C> with TryFrom partial inverses, over a PrimeOrder marker
  (secp256k1, secp256r1). It reuses the carrier sampler, SEC1 codec
  (TryFrom<Point> for PublicKey<33> now factors through the subtype)
  and SecretKey scalar; Scalar<C> gains Zeroize.
- M3: OnionSymbol, its code/from_code, and the epoch/nonce to_bytes
  accessors are pub(crate) under scoped expect(dead_code).
- L1/L6/L7/L8/L9: build draws the loop tag (constant-time equality);
  route validates keys into NonIdentityPoint once; rings-aez gains
  Expanded<τ> with encrypt_expanded/decrypt_expanded, so the carry has
  no unreachable error arm; the MAC label is the bucket's repr(u8)
  discriminant; Σ coding is the coproduct 1 + Σ_W with a Σ_W enum.
- L2/L3/L4/L5/L10: probability and doc fixes; GroupElement and Mac
  are one Invalid outcome (charged alike, #834); ChaCha20 state
  zeroizes; draw's retry is a pure combinator tested with an injected
  derivation; the bench times parse → peel → relay → encode on
  black_box input with no clone in the timed region.

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

* Address the #845 round-3 review: one Σ table, prime_order and AEZ typestates, cell stages (#840)

- M1: onion_signature! declares Σ once; the fields, ONION_SIGNATURE,
  symbols(), the Σ_W enum, ALL, spec() and the OnionServiceName
  constructors are derived from its rows. The codec test enumerates all
  of u8; the layer test derives its code range from symbols().
- M2: prime_order is the one secp256k1 SEC1 encoder (the K256 public
  key, verifying key, affine and Point conversions factor through it,
  with no zero fallback); k256::PublicKey decodes straight into
  G ∖ {O}; the group ↔ prime_order import cycle is gone; PrimeOrder is
  sealed and implemented for secp256k1 only; precise IdentityElement /
  ZeroScalar errors; Mul without clones; no scalar export.
- M3: rings-aez has one private encryption and one decryption core under
  the slice API and a Plaintext<τ> → Ciphertext<τ> typestate (seal /
  open); Plaintext is opaque and wiped on drop;
  DecryptError::Inauthentic(Inauthentic).
- M4: per-stage errors (OnionCellWidth, OnionHeaderRouteError,
  OnionBlindingError, OnionPeelError, OnionStepError, OnionClientError,
  OnionValueTooWide, OnionOpenError); OnionPeeledCell::step dispatches on
  the layer's application; OnionProducer holds the keys of σ_out.
- Lows: the HKDF PRK is wiped (hkdf_extract); OnionCell::client is the
  one client constructor; parse takes the Vec and into_bytes reuses it;
  the blinding factor is z_i and the padded message m_k; OnionLoopTag
  has Eq/Hash with its rationale; test fixes (guard key at 1 and H,
  all-pairs label injectivity, SEGMENT_SEED_DRAWS); a Phase 2b note on
  the invoke coding.

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

* Address the #845 round-4 review: compact SURB, affine SEC1 encoder, checked AEZ arithmetic (#840)

- Medium: a symbol hop's step yields OnionSurb = (class, χ_{i+1}, σ_out),
  about 2.95 KB whatever the class, which derives its keys when spent
  (OnionSurb::produce); the received cell buffer is no longer retained
  by the reply block.
- L1: the relay path's two slot copies are documented; zero-copy over a
  slice view is #843.
- L2: Plaintext::new keeps a buffer that already has room for the slot
  (carry::seal builds it so), and copies once otherwise.
- L4: the one SEC1 encoder works on the affine carrier; k256 public and
  verifying keys encode without a field inversion.
- L5: doc drift (m_k, z·α, #852).
- L6: world_facing is OnionServiceName::world_facing, generated with the
  table.
- L7: Ciphertext records |M| from a checked subtraction; seal/open use it
  and repeat_n instead of arithmetic; the slice decrypt splits by a
  checked split.
- L8: parse documents why the width is the exact reason of every failure.

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

* Address the #845 round-5 review: OnionSurb is D8's υ, split layer head and seeds (#840)

- Medium: OnionSurb = (class, next, χ_{i+1}, σ_out, x), D8's υ with the
  class of its cell (2980 B, class-independent, pinned by a const
  assertion); produce returns (next, OnionCell); expires_at_ms exposes
  x for the pool. OnionLayer is now a key-free OnionLayerHead plus the
  two seeds; step consumes both (σ_in into the key, σ_out into the SURB)
  and yields only the head, so each seed lives in one place.
- Lows: lines over 100 wrapped; measure() split into component and
  real-path rows; TryFrom<AffinePoint> for PublicKey<33> checks the
  identity on the affine point and encodes without an inversion; SURB
  doc (class byte, 2980 B).

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

* Address the #845 acceptance check: unspent SURB on a too-wide value, provenance test, docs (#840)

- OnionSurb::produce returns the block unspent in
  OnionProduceError::ValueTooWide { surb, width }, and OnionSurb::capacity
  (OnionLoopClass::value_capacity, shared with carry::seal) lets a caller
  check first.
- test_cell gives every layer a distinct next and x, asserts that
  admission reads the position's own head and that the SURB carries the
  symbol layer's next and x, and spends a SURB after a too-wide attempt.
- Docs: "D8's 2979 B plus the class byte"; the module diagram shows the
  head; encode_sec1's precondition names every caller.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…) (#863)

Eviction ordered rows by wall-clock milliseconds made monotone only per
row, so rows touched within one timer tick could tie and the index broke
the tie by key, evicting a recently read row (seen in Firefox, whose
timers are clamped).

Each database now keeps one access clock in a companion object store.
Every put and every get hit takes the next clock value as the row's
`access_stamp` and advances the clock in the same read-write transaction.
Law: k accesses receive k distinct, strictly increasing stamps whatever
the timer resolution; no timer is read. The clock stops with an error at
Number.MAX_SAFE_INTEGER instead of repeating a stamp, and `clear` keeps it.

The schema moves to version 2. A version-1 database is migrated in place
on first open: the upgrade adds the clock store and the `access_stamp`
index and drops the wall-clock indexes, then one transaction restamps
every row 0..n in its former eviction order (last_visit_time, then key)
and sets the clock to n. The clock record marks a finished migration, so
an interrupted one reruns from the untouched rows.

Browser tests cover the tick law, the migration order and its rerun, and
bursts of back-to-back accesses with exact eviction; none waits on or
reads wall-clock time.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…#859) (#860)

* Reroute user DHT sends after pre-acceptance refusals, woken by events (#859)

User-facing DHT operations (storage_fetch, and store/append/tombstone/compact
through operate_entry) now retry each remote placement from freshly computed
topology after a send refused before backend acceptance. The retry is
triggered by a topology, link, or capacity event, never by a duration, and is
bounded by REROUTING_BUDGET. No placement is applied twice.

- error::send_class: a total, wildcard-free classification of every Error
  (and backend error) into Deferrable(trigger) / Ambiguous / Fatal, with the
  per-variant proof that every deferrable error precedes backend acceptance.
  is_deferrable_data_plane_send is defined through it.
- swarm::transport::rerouting: the pure automaton (Rerouting::after,
  Awaiting::is_triggered) with laws S1-S3 and L1, plus the transport shell
  (attempt_remote, rerouting_listeners). A detached Cancelled completion is
  a deferral here, no longer a silent success.
- lifecycle::epoch: a monotone event count; PeerRing advances a topology
  epoch on route changes, the lifecycle boundary a link epoch on admission,
  retirement and readiness callbacks, and outbound capacity a release epoch.
- dht storage: lookup_placement / operate_route factor out the per-placement
  Compute that the whole operation and the rerouting share.
- test_model_check: the rejoin model's exhaustive search lifted into a
  generic CheckedModel search. The rejoin model's exact counts are unchanged.
  The rerouting model is a second instance on the production registry and
  automaton (generations <= 3, glare, retire-before-ready, topology change
  mid-wait, exhaustion). It adds mutants for S1 and S2 and shows that L1's
  quiescent budget is tight.
- Dummy-transport conformance: an append or fetch routed to an unlinked hop
  waits instead of failing, completes on the link's admission, and is
  applied exactly once.
- Node callers document the inherited behaviour at their boundary.

Closes #859
Part of #834

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

* Gate detached Cancelled on the admission, add a channel-drain trigger (#859 review)

- Lemma (P): do_send_payload_detached_until passes every detached Cancelled
  through DetachedAdmission::cancelled_outcome. After a claim won (for
  example a worker dropped mid-send), the caller receives the ambiguous
  DetachedSendAbandonedAfterClaim instead of a retriable Cancelled.
  Adds a drop-after-claim regression test.
- Triggers: CapacityRelease now covers only reserve refusals, which hold no
  permit. A new ChannelDrain trigger covers the queue and first-frame
  admission timeouts and wakes on route move, generation change, or
  Idle(hop). No trigger is satisfied by the refused send's own release.
  Waiters listen only to the epochs their trigger reads.
- Model: per-hop channel occupancy and faithful drains. The checker
  re-derived QUIESCENT_DEFERRALS = 4 (tight), so REROUTING_BUDGET = 6.
  REPLACEMENT_DEFERRALS is witnessed by a configuration that never
  exhausts. The redundant NoDuplicateEffect law is removed.
- L1 states TopologyReferencesOnlyAdmitted as a premise. Conformance tests
  follow the modelled path (a dead generation, then close and
  re-admission) and assert the waiting state through the link-epoch
  listener.
- Lows: ReroutingExhausted drops its constant field. The node boundary doc
  is written once. The callback signal and the parse are split.
  Epoch::advance uses fetch_add. The topology-epoch test witnesses a
  non-route change. An operation's placements run concurrently.

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

* Wake capacity waits on Room, channel waits on peer progress (#859 review R2)

- CapacityRelease now waits for Room(hop, demand): the admission rule
  itself, dry-run on the peer and global states (the reserved() steps
  factored out of try_acquire_*). It is exact for whichever scope refused,
  so releases in another scope, or too small, no longer wake it.
  attempt_remote records the refused request's TransferDemand.
- ChannelDrain now wakes on Moved, Replaced, peer progress, or Idle.
  Progress is counted against a per-peer release epoch stamped after the
  refusal (PeerStamp). The epoch is advanced by every PeerCapacityPermit
  drop, including half reservations, so a busy but flowing channel no
  longer starves the placement. Waiters listen only to the epochs their
  trigger reads.
- Model: scoped capacity (per-hop full, shared congestion); Drain and
  Close free only their scope; peer stamps; a scoped capacity
  configuration. QUIESCENT_DEFERRALS = 4 stays tight.
  REPLACEMENT_DEFERRALS = 1 is now pinned exactly, so REROUTING_BUDGET is 5.
- The registration daemon races each pass against its stop token. User
  operations are cancelled by dropping them.
- The drop-after-claim regression now drives the real
  do_send_payload_detached_until boundary (the worker runtime is dropped
  mid-claim).
- join_placements reports the Ambiguous class when any placement's outcome
  is unknown, and logs the other errors.
- DetachedAdmission::cancelled_outcome is renamed settle_cancelled. The
  node doc links core's ChordStorageInterface. Stale model docs are fixed.
  The append test counts OperateEntry receipts.

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

* Queue-aware Room, cooperative rerouting stop, deterministic #862 fixture (#859 review R3)

- Room is the admission path: admits_now = reserved(Fixed) or (queue empty
  and reserved(Shared)), a single combinator for the global, peer and
  unheld-peer checks. FairWaitQueue gains a departures epoch (admission
  and cancellation), which capacity waits also listen to. The model gains
  a queued scope and calls the production admits_now. Its L1 premise now
  states at most one foreign transfer per channel at quiescence.
  QUIESCENT_DEFERRALS = 4 stays tight.
- Stopping is cooperative. Swarm::scoped_storage(stop) is a stop-scoped
  ChordStorageInterface whose rerouting waits, and nothing else, end with
  Error::ReroutingStopped. Registration uses it with its stop token. The
  registration select! race is removed.
- The rerouting driver (reroute, Placement, Route) moves into
  swarm::transport::rerouting, and internal types narrow to the transport
  module.
- Channel progress is a separate epoch, advanced when a whole
  TransferCapacityPermit ends.
- The worker-lost boundary test is table-driven over the post-permit and
  irrevocable gates. It awaits the dummy send_gate_entered notification
  instead of busy-yielding.
- #862: the physical-destination sync test uses fixed secret scalars
  0x10..=0x15, with the routing derivation asserted, instead of a random
  draw.

Closes #862

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

* Single-attempt inbound writes, drained-ahead channel trigger, one capacity reading per wait (#859 review R4)

- Inbound-path writes (relay hold, inbox retirement) make one attempt
  (Attempts::Single) and never hold an inbound lane on a rerouting wait.
- ChannelDrain wakes once the transfers ahead at the refusal drained
  (PeerStamp: drained count and ahead, one locked reading); the L1
  premise of one foreign transfer per channel is removed from the model.
- Progress counts only transfers that reached the link, in the critical
  section of their release; drain waits also listen to peer releases.
- CapacityView: one capacity reading per wait iteration for listeners
  and Room; stop is checked before every wait iteration.
- rerouting split into automaton, shell and driver; Epoch count is test
  instrumentation; split_redundant_entry uses a fixed key fixture (#862).

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

* Classify the error variants develop added in send_class

IdentityElement and ZeroScalar (#845) join the cryptography group, and
IdbAccessClockMissing and IdbAccessClockExhausted (#856) the wasm host
group; all are Fatal. The exhaustive table rejected the merge until
they were classified.

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

* Name the single-attempt refusal, keep Epoch's count, check deep occupancy (#859 review R5)

- Attempts::Single folds its one attempt with Verdict::single; a
  pre-acceptance refusal is Error::SingleAttemptRefused (Fatal), not
  ReroutingExhausted. The inbound error surface change is documented at
  its call sites.
- Epoch keeps its count in every build; only current() is a test hook.
- L1 states the occupancy argument; the model adds a jams = 3
  configuration. PeerStamp documents the early wake under load and why
  the exact cohort predicate is not taken.

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

* Explicit peer-quality arms for rerouting outcomes, at-least-once inbox doc (#859 review R6)

- records_peer_send_failure: ReroutingExhausted, SingleAttemptRefused and
  ReroutingStopped are explicit false arms, witnessed by a test.
- deliver_inbox states application delivery is at least once.
- Re-wrap two doc lines to 100 columns.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…sed loop selection (#842) (#846)

* Select onion routes as guard-closed loops with process epochs for every node (#842)

Part of #834 Phase 2a (D2, D4, D5, L7).

Registration (D2):
- OnionExitEpoch becomes OnionProcessEpoch, drawn afresh at every start.
- OnlineNodeDescriptor::capabilities is typed: onion_relay = Some(e_n)
  registers relay at the process epoch; the "onion-relay" label is gone.
- Symbol descriptors carry the same e_n; advertise_onion_exit requires
  advertise_onion_relay (exit => relay => presence), and the exit-enabling
  config helpers register relay too.
- A symbol registrant is eligible only while its (DID, session key, e_n)
  equals a live relay registration, so missing and stale epochs are rejected.

Loop selection (D4, D5, L7):
- New loop_shape module: ONION_SEGMENT_RELAYS = 2, MAX_ONION_LOOP_SYMBOLS = 4,
  derived MAX_ONION_LOOP_HOPS = 14, H(n, s) = (s + 1)n + s; OnionLoop stores
  the guard once and places it at positions 1 and H only.
- select_loop draws the guard, then each symbol hop from its registrants
  (Hall's condition keeps the greedy draw complete), then the relays; it
  fails closed below H - 1 distinct eligible relays and rejects n > 4.
- has_duplicate_dids admits exactly the guard twice.
- OnionRoute holds the loop; the data plane keeps the Phase 1 view (the
  forward prefix g, r, exit) until 2a-4.

Removals: DEFAULT_ONION_ROUTE_HOPS, OnionRouteRequest::{hop_count,
allow_short_paths}, MAX_ONION_CIRCUIT_HOPS, the proxy/gateway/CLI hop and
short-path options, the browser API arguments and the frontend controls.
Docs, SECURITY.md and the docker cluster check are updated.

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

* Address the #846 review: one onion role type, circuit hops, typed rejections

M1: the browser API and WorkBench report OnionRoute::circuit_hops, the
    forward prefix g, r, exit the data plane uses until 2a-4.
M2: OnionRoute::new is pub(crate); loop_shape is private with one public
    path through the onion root re-exports.
M3: OnionRole<X> = Client | Relay | Exit(X) is the only role type. It is
    OnionRole<OnionExitOffer> in ProcessorConfig/Builder/Processor,
    OnionRole<NativeOnionTcpExitConfig> at native install, and
    OnionCircuitCapabilities = OnionRole<OnionProcessEpoch> in the reducer.
    The textual flags are parsed once (OnionRole::from_flags), so exit
    without relay has no typed inhabitant. The browser exit handler reads its
    policy from the role; install_onion_https_exit takes no policy and
    rejects a non-exit provider.
M4: gateway.onion_hop_count / onion_allow_short_paths and the
    ONION_HTTP_PROXY_{HOP_COUNT,ALLOW_SHORT_PATHS} env vars are rejected by
    name.
M5: OnionLoop::try_unfold walks the pipeline's list zipper, and select_loop
    draws each position with Hall's filter over the pending symbols; the
    exit is drawn first among exits a permitted guard can precede. The
    parallel symbol array, the lookup errors, RouteHasNoHops and
    HopCountOutOfBounds are gone.
Low: StaleExitRegistration / ExitWithoutRelayRegistration; seeded
     deterministic selection tests with a pinned Hall-filter guard test;
     fold-based Hall check without shifts, casts or clones; circuit_hops
     avoids per-call allocation and route_first_hop reads the guard; typed
     OnlineNodeCapabilitiesInfo DTO; no-op BTreeMap in eligible_relays
     removed; the frontend e2e drops its fixed sleep and uses OnionRole.

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

* Address the #846 round-2 review: drawable Hall, role-derived installs

N1: select_loop is generic over symbol registrations projected to hops and
    keeps only candidates whose hop is a relay, so Hall's condition counts
    only drawable DIDs. The exit is drawn at its loop position by the same
    mechanism; the hand-written exit pre-filter is gone.
N2: NativeOnionCircuitHandle::install reads the role from the processor
    (Core::onion_role / onion_circuit_capabilities); the role parameter is
    removed.
N3: a browser exit offering anything but https is rejected when its runtime
    is installed, and browser constructors install the circuit protocol for
    every role that registers relay. install_onion_https_exit becomes
    install_onion_runtime.
Low: OnionLoop<P, T> stores the terminal symbol structurally and unfolds a
     non-empty OnionPipelineSymbols with threaded state; Hall's check is an
     augmenting-path matching; ExitRelayRegistrationMismatch names the
     whole-hop mismatch; forward_path/circuit_hops are structural;
     OnionExitOffer holds a service set; every unknown gateway key is
     rejected; OnionRole::try_map; the browser e2e waits on its directory
     precondition and navigates once; a Phase 2b note in role.rs; relays
     are drawn in DID order.

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

* Address the #846 round-3 review and CI: L7 draw order, role-derived runtimes

Medium: select_loop draws the symbol positions first, then the guard,
then the relays (#834 L7), keeping a Kuhn matching over every pending
position with the guard as one of them. An exact-enumeration test pins
P(exit = x) = 2/3 and P(guard = x) = 1/6.

Low: NativeOnionCircuitHandle::install(extensions) reads key, network and
role from Core; NativeOnionTcpExitConfig is folded into OnionExitOffer and
the dead native-proxy setter removed; a browser exit offering a service its
runtime cannot interpret is rejected at config/build time with
Error::UninterpretableOnionService (tested on the production constructor),
and browser listen() installs the runtime of a relay-registering role;
D2 admission once, by binary search over DID-ordered relays, drawing on
references; D5 is checked before the guard policy; loop internals are
pub(crate) and the loop shape is derived; role.rs cites D1'.

CI: the workflow names the renamed gateway tests; the gateway fixture
accepts edges the overlay already opened (AlreadyConnected); the browser
e2e opens its edges concurrently, starts listeners only after the edges
connect, and waits on documented, bounded preconditions.

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

* Address the #846 round-4 review: one guard source, typed draw invariant, structural installs

- Condition the exit-marginal claim on every registrant extending to a loop
  (route docs and SECURITY.md), as the paper does.
- Replace the unreachable ok_or fallbacks of the loop draw with
  Error::LoopDrawInvariant; draw the guard from guards \ T only.
- OnionLoop::try_unfold places pre-drawn symbols and labels only the relays;
  symbols are drawn by OnionPipelineSymbols::try_map_with_later, which hands
  each symbol the symbols after it, so no cursor couples the two passes.
- RelayRegistrants::admit dedupes by DID: one registration per DID.
- Browser listen installs the onion runtime before spawning, and the task
  holds only the outcome; native Provider::listen/listen_with install the
  circuit runtime of a relay role and return Result.
- runtime_interprets is pub(crate).
- Frontend e2e waits for each edge to be Connected on both sides.
- Delete the dead native HTTPS proxy egress (field, getter, branch, tests).

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

* Address the #846 round-5 review: line and function length, h1 marginal, proof premises

- Split every line this PR added over 100 characters (concat! or string
  continuation); OnionRouteError derives its Display with thiserror, one
  #[error] per variant, instead of a 127-line fmt.
- Extract the browser onion loop fixture from run_browser_onion_webview_flow.
- Restrict the quality-share marginal to h1 and state the premises of the
  no-empty-draw proof (D2 admission, the matching check, D5).
- SymbolRegistrants carries its DIDs, so every matching borrows them;
  PendingPositions views the registrants directly.
- Test that the relay and symbol registrants keep one registration per DID.
- Native install_onion_runtime runs its check-and-install under a lock shared
  by the provider's clones, with its scope documented.
- Draw the install step in the browser listener flowchart.

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

* Join directory reads into a monotone per-process view (#864)

A directory read returns whichever replica answered last: fetch_storage_entry
answers from the cache, and every FoundEntry reply replaces it. While
ownership of a registry moves, two reads therefore need not agree, and a loop
precondition observed once (|R| >= H - 1) could vanish before selection.

- descriptor::DescriptorView joins every read into a view: the newest live
  descriptor per key under the heartbeat order, dropped at expiry. The join is
  commutative, associative and idempotent, so reads are monotone up to expiry.
- latest_valid_by_key generalizes latest_valid_by_did; the exit registry keys
  by OnionExitDescriptor::registration_key through it.
- Processor::lookup_online_nodes / lookup_onion_exits answer from the view,
  shared by every clone, so readiness and route selection observe one growing
  snapshot. No retry on NotEnoughLoopHops; D5 stays fail-closed.
- Laws tested: monotone, newest heartbeat in any read order, expiry.
- SECURITY.md states the view and that a withdrawn registration stays a
  candidate until its TTL.

Part of #864

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

* Join fetched replies into the core cache instead of a node-side view (#864)

Address the #846 review of 05554e8. User decision on H1: take the join in
core, where Entry is already a bounded join-semilattice.

- local_cache_put joins the reply into the cached carrier,
  cache[k] <- cache[k] ⊔ reply (Entry::join), under the ring's storage
  transition, so two concurrent replies cannot lose one side. It shares
  join_live_entry with join_storage_entry; local_cache_get takes the same
  transition for its lazy retirement.
- The cache laws are stated on ChordStorageCache: order-independent join,
  tombstones kept, bounds inherited from admission, the per-carrier cap and
  LOCAL_CACHE_CAPACITY, and monotonicity only per resident key (eviction,
  expiry and restart un-observe). Tests: both reply orders including a
  same-instant tie from two writers, and a tombstone against a stale reply.
- DescriptorView is deleted; the lookups read the joined carrier (H1, M3, L2,
  L3). latest_valid_by_key breaks an equal heartbeat by (expiry, signed body,
  signature), a total order, tested in both read orders (M1).
- A failed fetch is the empty read: it answers from the live cache and fails
  only without one (M2).
- The browser e2e awaits BackendObserver admissions on both ends of every
  edge; the registry wait stays a poll until #866 adds a publish signal (M4).
- OnionPipeline::hop_count is NonZeroUsize, so edge ids for zero hops are
  unrepresentable (L4). SECURITY.md describes the cache join, its bounds and
  the remote-directory case, and re-wraps (L1); the Docker README states 5
  nodes for an in-cluster client (L6).

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

* Address the #846 round-2 review: precise cache laws, own cache lock, real ties

- State the fetch-cache laws precisely (ChordStorageCache, SECURITY.md, #864):
  the carrier ascends in the lattice order while resident, but its visible
  elements shrink under tombstones, overwrite and compaction floors and the
  element cap; the registry publisher compacts on every heartbeat, so a
  floor from an owner that never held a descriptor erases it from the join
  until the node heartbeats again. That pre-existing compaction loss is #867;
  #864 is no longer claimed closed.
- The fetch cache gets its own cache_transition lock: the two stores share no
  slot or invariant, one lock per store, never nested, so a directory read no
  longer waits behind a storage scan.
- Tie tests that tie: one payload added by two writers at the same instant
  (dot max) and two same-instant overwrites (register max), each in both
  reply orders; plus a join through the FoundEntry handler path.
- The empty-read rule is shared by the exit refresh path, and documented as
  also serving the registration publisher.
- OnionSymbolWord::hop_count is NonZeroUsize, like OnionPipeline::hop_count.
- Re-wrap lines over 100 columns in storage.rs and SECURITY.md.

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

* Address the #846 round-3 review: withdrawn lookup claim, residency, debug trace

- lookup_online_nodes states the core law instead of the withdrawn
  "observed once stays listed": the carrier ascends while cached, but a
  descriptor leaves on a tombstone, an overwrite or compaction floor (#867),
  or at the element cap.
- local_cache_put's post-condition holds since the key last became resident.
- answer_failed_fetch traces the fetch error at debug when it answers from
  the cache, so a refresh's transport error is not lost behind NoLiveExit.
- Shorten the fixture's lint reason under 100 columns.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…rate emission (#881)

* Freeze the #843 session-frame, reply-authentication and emission decisions in the paper

Records the user's decisions on the #843 plan questions (2026-09-26):
- D2': a (sid, SHA-256(t)) application, target carried under flag T until
  the first reply, a per-direction sequence n and flags byte phi in data
  and fin frames (|w| <= C0 - 6), the tcp open result, and fail-closed gaps.
- Proposition (Reply authentication): no exit signature; replies are
  authenticated by AEZ under k_{c_n} and are deniable by design.
- D6/L9: constant-rate per-link emission rho*B/V with real cells
  substituting for cover (#880); throughput bound restated (~1.32 MB/s).
- Leakage, security and verification tables updated.

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

* Correct the session data bound to C0 - 7

pad(v) spends one marker byte, so a frame must satisfy |enc| <= C0 - 1;
data spends six frame bytes (tag, n, phi), hence |w| <= C0 - 7 (13442 B at
16 KiB), and |w'| <= C0 - 9 - |t| under flag T. The throughput figure is
unchanged to three digits (~1.32 MB/s).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
)

* Order IndexedDB LRU eviction by a store-wide logical clock (#853) (#856)

Eviction ordered rows by wall-clock milliseconds made monotone only per
row, so rows touched within one timer tick could tie and the index broke
the tie by key, evicting a recently read row (seen in Firefox, whose
timers are clamped).

Each database now keeps one access clock in a companion object store.
Every put and every get hit takes the next clock value as the row's
`access_stamp` and advances the clock in the same read-write transaction.
Law: k accesses receive k distinct, strictly increasing stamps whatever
the timer resolution; no timer is read. The clock stops with an error at
Number.MAX_SAFE_INTEGER instead of repeating a stamp, and `clear` keeps it.

The schema moves to version 2. A version-1 database is migrated in place
on first open: the upgrade adds the clock store and the `access_stamp`
index and drops the wall-clock indexes, then one transaction restamps
every row 0..n in its former eviction order (last_visit_time, then key)
and sets the clock to n. The clock record marks a finished migration, so
an interrupted one reruns from the untouched rows.

Browser tests cover the tick law, the migration order and its rerun, and
bursts of back-to-back accesses with exact eviction; none waits on or
reads wall-clock time.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Deliver toward a node DID without overshooting it (#865, #873) (#876)

* Deliver toward a node DID without overshooting it (#865, #873)

Replies routed by DID cycled on an unconverged ring until MAX_RELAY_HOPS
dropped them (#865). The root cause (#873) is that delivery to a node
reused the owner lookup: find_successor answers Local(head) with head past
the key, which is right for "who owns position k" and wrong for "next hop
toward node T". A hop that did not know T handed the payload past it, and
greedy routing from the far side circled among the nodes whose views skip
T.

- dht::delivery: a dedicated delivery step over a carrier stage
  (aim, handed_off). Greedy hops forward to the known peer on (n, aim]
  nearest the aim, so dist(., aim) strictly decreases and no route can
  cycle. When no known peer lies on that arc, the hop hands off once to
  its successor head and marks the carrier; the receiver delivers over a
  direct link or fails fast with RelayDestinationUnreachable, and never
  routes greedily again. A route ends within 2(|V| + 1) + 1 hops in any
  views, so RelayHopBudgetExhausted is unreachable on the delivery path.
- find_successor keeps its owner-lookup semantics; its closest-preceding
  scan now covers successors as well as fingers.
- Transaction::reply_via (signed, in the transaction hash): while a node
  has no predecessor, i.e. no node is known to route to it, its requests
  name its successor head. Reports start in stage (via reply_via) and
  return through that peer, which hands them over its direct link. This
  keeps joins working under fail-fast: a joiner is known only to its
  bootstrap until its ring neighbours learn it.
- Checked model (dht::delivery::tests): exhaustive over every knowledge
  assignment of a 4-node ring crossed with off-view links and reply_via
  choices, plus a fixed-seed randomized pass; asserts linked hops, no
  overshoot, at most one crossing per aim, no repeated state, the hop
  bound, converged => delivered, the join-window law, and the #865
  topology regression.
- Docs: relay module, Transaction return-address law, SECURITY.md Layer
  Contracts, docs chord.md, MAX_RELAY_HOPS, Chord trait, find_successor.
- Version 0.32.0: the carrier stage and reply_via change the wire format.

Closes #865
Closes #873

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

* Address review round 1 and CI on #876

- One origination path: PayloadSender::originate is the only constructor
  of a locally originated request. It decides the first hop and the
  signed reply_via from one topology snapshot
  (dht::delivery::origination). SwarmTransport::signed_payload is removed
  and its callers (finger fixes, ProbeRequest, storage sync, manual
  offer/answer) use it, so a node without a predecessor names its
  successor head on every request.
- Bounded reflection: only FindSuccessorReport and ConnectNodeReport
  honour reply_via (Message::returns_through_reply_via); every other
  report routes straight to the origin.
- find_successor is master's version again (fingers only). The successor
  list may name peers a stabilization report introduced before any link
  exists, which made storage sync pick an unlinked next hop
  (SwarmMissDidInTable in the sync-storm replay). Delivery scans
  (succ ∪ fingers) ∩ Linked instead, and hands off to the first linked
  known node.
- Derived hop bound |V| + 2 (one greedy run, one handoff, two terminal
  deliveries); the "budget exhaustion is unreachable" claim is replaced
  by its premise (|V| + 2 > MAX_RELAY_HOPS only with a sparse finger
  table). reply_via is disclosed whenever a node has no predecessor, not
  only while joining. Docs, SECURITY.md and CHANGELOG follow.
- One form of each API: MessageRelay::{new, forward} take a NextHop,
  new_send_with_sequence takes the hop and reply_via; NextHop::toward
  names a fixed first hop; RouteStage no longer derives Default.
- Model: views may name unlinked peers; the always-true crossing counter
  and a duplicated assertion are removed; the bound |V| + 2 is asserted.
- rustdoc: fix intra-doc links in dht::delivery and a private link.

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

* Move dht::delivery to delivery/mod.rs

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

* Test converged and unconverged delivery separately

The delivery model now states what each regime guarantees instead of
one shared outcome assertion.

- tests/mod.rs: the shared overlay model, the safety laws every overlay
  obeys (Linked, NeverOvershoots, Acyclic, Bounded, Exact), and the
  per-stage unit laws; converged views now carry their predecessor.
- tests/converged.rs: on the Chord fixpoint every route is delivered by
  greedy hops alone (no handoff), every non-delivering hop at least
  halves the remaining distance (so a route takes at most
  ceil(log2 dist) + 1 hops), and no node names a reply_via.
- tests/unconverged.rs: one test per kind of stale view, each with its
  own expected outcome: a stale successor is routed around (#865 from D
  and C), an unknown destination fails fast after exactly one handoff
  (#865 from A), a joiner's answers arrive through reply_via while
  unhinted routes fail fast, unlinked view peers are never hopped to,
  and arbitrary views (exhaustive 4-node, fixed-seed random) satisfy the
  safety laws.

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

* Name the delivery test model and its files with the test_ prefix

delivery/tests/ becomes delivery/test_model/, and converged.rs and
unconverged.rs become test_converged.rs and test_unconverged.rs.

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

* Self-review of #876: linked reply_via, delivery placement, one constructor

- reply_via names the nearest *linked* successor. A successor head can
  be a candidate a stabilization report introduced before any link to
  it exists; naming it would route a joiner's answers to a peer that
  cannot hand them on (the same class as the sync-storm failure).
- The greedy delivery step (RouteStep, route_toward, reaches,
  linked_known_peers) and reply_via move from dht::topology into
  dht::delivery, where they belong; topology.rs differs from master
  only in the find_successor and closest_preceding_finger docs.
- Transaction::new takes reply_via; the parallel
  Transaction::replying_via is removed and its 22 callers migrated.
- Error::NoNextHop is removed: its only use was infer_next_hop.
- Docs: the handoff goes to the first linked known node, not the
  successor head (types.rs, chord.md, RouteStage); the fixpoint hop
  bound is stated as ceil(log2 dist) + 1; reply_via covers only the
  successor and connection answers; originate documents that a manual
  connection answer names the hint harmlessly.
- Test: test_reply_via_names_the_nearest_linked_successor_only_without_a_predecessor.

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

* Let the handoff receiver route on; forbid only a second crossing

A terminal handoff receiver broke every sender with a sparse view. A
leaf linked only to its guard, its ring predecessor, finds the guard past
almost every destination and hands off on its first hop; a terminal
guard then delivered only to its own neighbours although its full view
routes anywhere. A joiner's ConnectNodeSend to its successor failed the
same way unless the bootstrap was linked to that successor. What cycled
in #865 was the unbounded repetition of crossings, not one crossing.

- delivery_step: after a handoff the receiver forwards greedily again;
  only a second handoff is refused (RelayDestinationUnreachable). A route
  toward one aim is at most two greedy runs joined by one handoff, so it
  takes at most 2|V| hops and never cycles.
- Model: per-role liveness tests, each its own proposition, added before
  the rule changed and failing on the terminal rule:
  test_sparse_sender_reaches_every_member,
  test_leaf_behind_its_predecessor_is_reached_by_every_member,
  test_destination_without_links_fails_fast. The #865 route from A is now
  delivered as A -> C -> B -> T. A differential "no regression against
  the owner-lookup router" law was tried and dropped: on arbitrary views
  two greedy choice rules each win where the other dead-ends, so no
  greedy router dominates another (counterexample recorded in the model
  doc).
- Docs: the 2|V| bound and the one-crossing rule in dht::delivery,
  SECURITY.md, chord.md, MAX_RELAY_HOPS, the error doc, CHANGELOG.

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

* Correct two model doc claims after the one-crossing rule

- NeverOvershoots: a single crossing per aim is not a property of the
  type. It follows from the check itself: nothing clears the handoff
  flag, so hops of a flagged stage are held to the no-overshoot law, and
  a second crossing would be rejected as an overshoot.
- The joiner test: without reply_via an answer arrives only if one of
  the two greedy runs happens to pass the bootstrap; the two fixed
  topologies are ones where neither does, so the hint is what makes
  delivery certain.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Apply serif typography and update header tagline (#894)

* Use serif typography and refreshed feature illustrations

* Correct landing header tagline

* Keep landing PR limited to typography and header copy

* Fix flaky tests at the root: event-woken waits everywhere, deterministic fixtures, node test transport (#889)

* test(core): drive test_handle_connect_node on the controlled dummy queue (#850)

test_handle_connect_node ran three nodes over real webrtc-rs loopback and
polled each step against the 5 s TEST_WAIT_TIMEOUT. Under suite load, the
handshakes outran the deadline.

The test now runs on the dummy transport under ControlledDeliveryGuard:
- identities are fixed (fixed_secret_keys) and the dummy ids are seeded;
- deliver_until delivers FIFO until a named predicate holds, bounded by a
  step count and never by a clock;
- P0 (the n1-n2-n3 path joined, with no admitted or pending n1-n3 link)
  is reached first, then Swarm::connect(n3), then P1 (C(n1,n3), C(n3,n1)
  and exact successor lists);
- after P1 the run is driven to quiescence, decided by state (no queued
  event, and no handshake, inbound or outbound on any node), and P1 is
  re-asserted;
- the relay is witnessed: n3 received n1's ConnectNodeSend and n1
  received n3's ConnectNodeReport, each with n2 as the hop signer.

deliver_until takes a RetiredTarget policy, so the strict drain helper
keeps its live-connection check. has_connection_in_state is the shared
predicate behind wait_for_connection_state.

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

* test(core): await retirement events in the native storage-repair test (#858)

The test read the global outbound-transfer counter once right after
disconnect. The retiring worker releases its permits asynchronously, so
under load the read could see a permit about to be released. The repair
and the physical close were polled against deadlines.

- The repair is awaited on a write generation that node2's test storage
  publishes on every put; the generation is marked before the read.
- Maintenance and repair pressure keep running through the retirement.
- After disconnect, the test asserts that node2 is in none of node1's
  topology slots, then awaits deallocation of node2's outbound capacity.
  Deallocation is terminal, and each permit holds the capacity.
- The physical close is awaited through NativePhysicalCloseWitness.
- Repair-test nodes use host-only ICE.

Hooks: rings-transport's NativePhysicalCloseWitness is watch-backed and
gains completed(), documented as supported API. rings-core test builds
give TransferCapacity a never-sent watch whose closing witnesses
deallocation.

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

* test(node): await a complete E2E stream and use host-only processor fixtures (#857)

wait_for_e2e_stream_frames returned on the first final frame, although
the link does not guarantee order. It now waits on e2e_stream_complete,
a monotone set predicate (∃ final f. {0..=f.seq} ⊆ S), and returns the raw
frames in arrival order, so the test's shape assertions still check what
the sender emitted. A unit test covers all 24 arrival orders plus a
trailing duplicate and a stray frame.

Every processor fixture, including tests::native::prepare_processor,
uses host-only ICE.

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

* test(node): await admission and retirement events in the wasm tests (#870)

The browser provider and processor tests slept for fixed durations after
connecting, sending and disconnecting.

- A TransitionRecorder, installed at creation, logs (peer, Admitted |
  Retired) into an unbounded channel, and PeerTransitions consumes
  matching records, so no transition can be missed.
- create_connection awaits both admissions through the shared
  await_mutual_admission helper. disconnect is followed by awaiting
  Retired.
- test_handle_backend_message awaits a JS Promise that the handler
  resolves.
- The processor tests await exactly the expected texts.
- test_processor_connect_with_did reads the listing through
  settled_peer_states, which consumes a superseded generation's
  Admitted/Retired pair.
- Browser fixtures use host-only ICE.
- Each connection test runs under one 20 s with_hang_guard that names
  it, sized so that even five simultaneous hangs stay inside the 120 s
  runner budget.

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

* test(core): mint the cancellation test's admitted connection without a link (#875)

test_cancellation_after_scan_releases_successor_behind_waiting_head only
needs an AdmittedConnection token, but its wasm fixture set up a real
browser WebRTC link and polled admission. A stalled handshake then
consumed the wasm binary's whole 120 s budget.

- admit_detached_peer reserves a pending generation, creates its local
  transport object (no offer, no ICE), and activates it through
  activate_connection_for_test. That hook and its registry counterpart
  widen from cfg(all(test, not(wasm))) to cfg(test). The fixture is the
  same on native and wasm, and uses fixed identities.
- Core's browser fixtures use host-only ICE.
- tests::wasm::with_hang_guard fails a test by name. The real-transport
  handshake tests use 15 s guards, and the repair soak's 60 s is named a
  scenario budget.
- CI runs the soak as its own core wasm invocation, so that budget
  cannot starve the rest of the binary.

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

* test: share fixed test identities and the host-only ICE constant (#886)

Now that the flaky-test fixes land together, the facts that were stated
twice are stated once:
- fixed_secret_keys::<N>() moves into core's crate::tests, which both
  platforms compile, and the outbound cancellation test uses it;
- one node crate::tests::TEST_ICE_SERVERS serves the native and browser
  fixtures.

The core and node with_hang_guard helpers share one shape and reference
each other. They stay separate definitions because each lives in its
crate's cfg(test) module.

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

* test(node): run processor network tests on a controlled in-memory transport (#883, #857)

Processor network tests ran over real webrtc-rs handshakes bounded by
5 s deadlines, so under load the handshake latency decided the outcome
(#850's mechanism). This was #857's remaining root cause.

- rings-node gains a test feature, dummy = [node, rings-core/dummy,
  rings-transport/dummy], documented as not a production configuration.
  It adds no code path and only selects core's in-memory transport.
- Under dummy, network_test_guard(), which every connecting processor
  test already takes, also starts a ControlledNetwork. It enables the
  controlled dummy queue with a fixed seed and runs a FIFO pump on the
  test's thread. No message is delayed by a clock, and the handshake is
  in-memory, so the helpers' remaining bounds are hang guards only.
- The E2E stream test runs on the controlled network. The pump is paused
  while the stream is sent, and the frames are then delivered
  newest-first. Reordering is exercised deterministically, and the test
  asserts that it happened, along with completion, the raw shape and
  decryption. Its identities are fixed.
- The protocol-logic connecting tests (online registry, direct message,
  measurements, E2E handshake and stream, bootstrap probe) run in the
  dummy build. The default build keeps test_processor_create_offer and
  test_processor_handshake_msg as host-only real-WebRTC smoke tests,
  serialized by NETWORK_TEST_LOCK. The gateway tests, which need a real
  public network, run in the default build only.
- CI: the dummy_tests job compiles and runs rings-node --features dummy.

Browser tests are unchanged here: the dummy transport is native-only.

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

* test(core): wake every test wait on observed activity, never on a timer (#882, #890)

Core's shared test helpers polled state on timers and decided quiescence
by a 500 ms silence window, and core's browser soak polled every 25 ms.
Every wait now probes state and is woken by a state change.

- tests::activity is a runtime-neutral activity generation (mutex plus
  wakers, no tokio) used natively and in the browser. Waits follow
  mark, probe, then await generation != mark, so no wake-up is lost.
  Activity is recorded by each test node's observer (message
  sent/forwarded/received/stored, lookup started/finished) and callback
  (validated, handled, connection event).
- Node::build(builder) attaches the observer and callback to every test
  node. prepare_node_without_stun is folded into prepare_node, and every
  in-process fixture uses host-only ICE.
- wait_until_result (and its wait_for_* wrappers), wait_for_storage_state
  and connection.rs's wait helpers are activity-woken probes.
- wait_for_msgs drops the silence window. Quiescence is decided by
  message conservation: no handshake, inbound or outbound transfer, or
  queued dummy event on any node, and sum(delivered) = sum(received) over
  the test's nodes (wire messages only).
- Test builds record activity when a transfer permit is released, both
  outbound and inbound (one cfg(test) line in each Drop): the release is
  a state change the predicate reads, and it follows the delivery or
  handling notification. The in-flight test accessors widen to every
  test build.
- File-local polls became probes: the native-transport interleaving
  trace, the continuous-repair convergence, and the chunk-tail "no
  second chunk" checks, which now await the cancelled transfer's
  release.
- Browser: core's wasm fixtures carry the ledger and callback. The
  repair soak's mesh, convergence, repair and trace waits are
  activity-woken, and its stabilization rounds are separated by
  observed mesh quiescence instead of a 25 ms sleep.
- The pending-measure scheduler test waits for admission only: its
  measure never completes, so quiescence is unreachable by design, and
  the old silence window had masked that.

Hang guards remain only as failure bounds. TEST_HANG_GUARD is 5 s in
dummy builds and 60 s in native builds, where real webrtc-rs handshakes
under load exceeded 5 s (#850); no wait is paced by it.

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

* test(node): activity-woken processor waits and a shared test-support crate (#883, #890, #886)

Review follow-up on #889 (M1, L1–L5), plus the user's direction that no
test wait is paced by a timer:

- rings-test-support, a dev-only crate (publish = false), holds the
  runtime-neutral activity cell, the activity-woken probe and
  with_hang_guard. Core and node share one definition of each, which
  resolves the duplicated hang guard from #886.
- ProcessorBuilder gains a cfg(test) hook that chains one extra observer
  after Observability. Every test processor, native and browser,
  records activity through it; the test callback and the controlled
  pump record activity too.
- The node processor helpers are activity-woken probes: processor
  admission, inbound messages, the mutual DHT topology, the registry
  lookup and storage, and peer measurements. The Notify fields and the
  20 ms polls are gone. Hang guards are failure bounds only (5 s dummy,
  60 s real WebRTC). The two real-transport smoke tests document what
  only the real transport checks.
- The pre-stopped listener tests assert completion on the first poll
  instead of within 100 ms: returning before the first stabilization
  tick means waiting for nothing.
- The browser listener test decides "started is still pending" with a
  microtask flush plus Promise.race against a resolved sentinel,
  instead of a 10 ms sleep.
- The dummy seed is per test (base seed mixed with the test's name), and
  the controlled network is torn down before the serial lock is
  released. The rings binary refuses to build with the dummy feature,
  and CI's feature-check step lists its features instead of
  --all-features.

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

* test(browser): activity-woken waits in the dweb and frontend browser tests (#890)

The remaining browser tests outside rings-core and rings-node waited on
timers. dweb connected and retried a fetch every 500 ms. The frontend
onion flow slept 1 s after connecting and retried navigation every
250 ms, and its listener test slept 20 ms before asserting.

- ProcessorBuilder::observer(SharedSwarmObserver) is now public API. It
  chains one extra observer after the processor's own Observability, as
  SwarmBuilder::observer does for a bare swarm, so embedders, and these
  external tests, can observe swarm activity. It replaces the
  cfg(test)-only hook.
- dweb: a host-only test node (build_node_with) records activity. The
  test probes listPeers on activity until A lists B as connected, sends
  the request once, and probes for B's page on activity. The demo app
  keeps its STUN server.
- frontend: host-only ICE. Admission is probed on activity, and each
  failed gateway navigation is re-armed on the next activity after the
  failure rather than on a timer. The listener check flushes the
  microtask queue instead of sleeping 20 ms.
- Both use rings-test-support as a path dev-dependency.

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

* test: count arrivals, stabilise quiescence, relayed-connect smoke test (#889 review round 2)

- M1: conservation now balances deliveries against arrivals counted by
  the receiving transport before validation. A cfg(test) counter is
  bumped in process_event for logical-lane events, so a message the
  receiver drops or rejects still balances its delivery. The
  quiescence fold holds only if the activity generation did not move
  while the nodes were sampled, which is safe on multi-thread runtimes.
  Both preconditions are documented.
- M2: the frontend navigation retry marks activity before each
  attempt, so a change during or after the attempt wakes the next one,
  and it keeps the last error for the guard report.
- M3: test_relayed_connect_over_real_webrtc, a four-node real-webrtc-rs
  ring with one successor and no fingers. It always issues n1's connect
  to n3 from a state with no n1-n3 link (a join-time link is retired
  first) and witnesses the ConnectNodeSend relayed by n2.
- L2: rings-node refuses to build ffi together with dummy.
- L3: ProcessorBuilder::observer documents the non-blocking observer
  contract and that a later call replaces an earlier one.
- L4: the listener lifecycle tests decide "still waiting" with a direct
  futures::poll! of the listener future instead of 20-100 ms sleeps and
  timeouts.
- L5: an unnamed test thread fails instead of sharing a fallback seed.
- L6: rings-test-support gains `within` and, behind its swarm-observer
  feature, the one activity observer used by node, dweb and frontend.
  Its lint table is the workspace's minus clippy::panic, since failing
  tests is its purpose.

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

* docs(node): state the public ProcessorBuilder::observer contract (#889 L1)

User decision: keep ProcessorBuilder::observer public as an embedder hook.
The method now states its contract. The observer is synchronous and
bounded on the send and receive paths. It runs after the processor's own
Observability, which alone feeds /status?view=observability, so that
output is unchanged. Message observations carry no payload, key or
identifier. Lookup events carry a correlation key that exporters must
aggregate or redact. A later call replaces an earlier observer.

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

* test: frame-level conservation, independent progress signals (#889 review round 3)

- H1/M1: quiescence balances frames at the swarm's two chokepoints. A
  cfg(test) FrameLedger counts sending/sent around SwarmConnection::send_data
  and arrived plus an in-flight token at submit_inbound_message entry, released
  at the inbound actor's handoff. Replaces the observer delivered counter and
  the process_event arrival counter; MessageLedger and TestSwarm are removed.
- L1: the relayed-connect smoke test serializes its joins, asserts P0 on both
  sides and asserts both exact paths (send n1->n2->n3, report back via n2).
- L2: the frontend onion retry wakes only on DHT storage writes.
- L3: the stale-stop check polls the restarted listener directly.
- L4/L5/L6: feature-unification, ffi+dummy and soak hang-guard docs.
- zkjoie P2: the registry test looks up once after the decisive storage
  state; control rounds wait on the storage transfer's own progress.
- CI: rings-test-support workspace dependency carries a version for
  cargo-deny's wildcard ban.

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

* test: ordered frame sampling, commit-point send counting (#889 review round 4)

- M1: arrive() raises in_flight before arrived; the quiescence fold reads
  each swarm through sample_swarm, loading sending, sent, arrived, in_flight
  in the ledger's sampling-law order, then the actor and scheduler witnesses.
- M2: a send counts as sent once the transport committed to it (Ok or an
  irrevocable acceptance), so losses below the swarm leave sent >= arrived;
  on_invalid_inbound_frame counts its frame as arrived.
- M3: rings-test-support stays a version-less path dev-dependency, so it is
  stripped from packaged manifests; deny.toml allows wildcard paths.
- L1: CI runs the relayed-connect smoke test in its own invocation.
- L2: control rounds wait for a storage frame after the traced control.
- L3/L4: read-repair wakes documented; the stale-stop check asserts the
  restarted generation's token.
- Fix dead-code under dummy and wasm clippy for the new test helpers.

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

* test: outbound-first sampling, stated injection precondition (#889 review round 5)

- L1: sample_swarm reads the outbound permits before the ledger, since a
  transfer's permit is released only after its sends were counted.
- L2: the control-round predicate doc and the browser soak comment state
  that maintenance controls share the link there.
- L3: swarms_quiescent states that every received frame was sent through
  send_data; tests that inject frames do not wait on it.
- L4: the stale-stop comment claims only what the poll proves; the
  tautological token witness is removed.

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

* test(core): wait on the whole network while an offline peer's message is held

hold_message_for_offline_peer waited for quiescence over node1 and node3
only. In the rejoin test the departed peer still exists, and admission's
successor synchronization can reconnect it while the message is held, so
frames from a node outside the fold broke conservation (CI: sent=45,
arrived=46; locally 12/40 failures). The helper now takes every node of
the test for its waits.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Joie <69368441+zkjoie@users.noreply.github.com>
…#905)

* Order IndexedDB LRU eviction by a store-wide logical clock (#853) (#856)

Eviction ordered rows by wall-clock milliseconds made monotone only per
row, so rows touched within one timer tick could tie and the index broke
the tie by key, evicting a recently read row (seen in Firefox, whose
timers are clamped).

Each database now keeps one access clock in a companion object store.
Every put and every get hit takes the next clock value as the row's
`access_stamp` and advances the clock in the same read-write transaction.
Law: k accesses receive k distinct, strictly increasing stamps whatever
the timer resolution; no timer is read. The clock stops with an error at
Number.MAX_SAFE_INTEGER instead of repeating a stamp, and `clear` keeps it.

The schema moves to version 2. A version-1 database is migrated in place
on first open: the upgrade adds the clock store and the `access_stamp`
index and drops the wall-clock indexes, then one transaction restamps
every row 0..n in its former eviction order (last_visit_time, then key)
and sets the clock to n. The clock record marks a finished migration, so
an interrupted one reruns from the untouched rows.

Browser tests cover the tick law, the migration order and its rerun, and
bursts of back-to-back accesses with exact eviction; none waits on or
reads wall-clock time.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Deliver toward a node DID without overshooting it (#865, #873) (#876)

* Deliver toward a node DID without overshooting it (#865, #873)

Replies routed by DID cycled on an unconverged ring until MAX_RELAY_HOPS
dropped them (#865). The root cause (#873) is that delivery to a node
reused the owner lookup: find_successor answers Local(head) with head past
the key, which is right for "who owns position k" and wrong for "next hop
toward node T". A hop that did not know T handed the payload past it, and
greedy routing from the far side circled among the nodes whose views skip
T.

- dht::delivery: a dedicated delivery step over a carrier stage
  (aim, handed_off). Greedy hops forward to the known peer on (n, aim]
  nearest the aim, so dist(., aim) strictly decreases and no route can
  cycle. When no known peer lies on that arc, the hop hands off once to
  its successor head and marks the carrier; the receiver delivers over a
  direct link or fails fast with RelayDestinationUnreachable, and never
  routes greedily again. A route ends within 2(|V| + 1) + 1 hops in any
  views, so RelayHopBudgetExhausted is unreachable on the delivery path.
- find_successor keeps its owner-lookup semantics; its closest-preceding
  scan now covers successors as well as fingers.
- Transaction::reply_via (signed, in the transaction hash): while a node
  has no predecessor, i.e. no node is known to route to it, its requests
  name its successor head. Reports start in stage (via reply_via) and
  return through that peer, which hands them over its direct link. This
  keeps joins working under fail-fast: a joiner is known only to its
  bootstrap until its ring neighbours learn it.
- Checked model (dht::delivery::tests): exhaustive over every knowledge
  assignment of a 4-node ring crossed with off-view links and reply_via
  choices, plus a fixed-seed randomized pass; asserts linked hops, no
  overshoot, at most one crossing per aim, no repeated state, the hop
  bound, converged => delivered, the join-window law, and the #865
  topology regression.
- Docs: relay module, Transaction return-address law, SECURITY.md Layer
  Contracts, docs chord.md, MAX_RELAY_HOPS, Chord trait, find_successor.
- Version 0.32.0: the carrier stage and reply_via change the wire format.

Closes #865
Closes #873

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

* Address review round 1 and CI on #876

- One origination path: PayloadSender::originate is the only constructor
  of a locally originated request. It decides the first hop and the
  signed reply_via from one topology snapshot
  (dht::delivery::origination). SwarmTransport::signed_payload is removed
  and its callers (finger fixes, ProbeRequest, storage sync, manual
  offer/answer) use it, so a node without a predecessor names its
  successor head on every request.
- Bounded reflection: only FindSuccessorReport and ConnectNodeReport
  honour reply_via (Message::returns_through_reply_via); every other
  report routes straight to the origin.
- find_successor is master's version again (fingers only). The successor
  list may name peers a stabilization report introduced before any link
  exists, which made storage sync pick an unlinked next hop
  (SwarmMissDidInTable in the sync-storm replay). Delivery scans
  (succ ∪ fingers) ∩ Linked instead, and hands off to the first linked
  known node.
- Derived hop bound |V| + 2 (one greedy run, one handoff, two terminal
  deliveries); the "budget exhaustion is unreachable" claim is replaced
  by its premise (|V| + 2 > MAX_RELAY_HOPS only with a sparse finger
  table). reply_via is disclosed whenever a node has no predecessor, not
  only while joining. Docs, SECURITY.md and CHANGELOG follow.
- One form of each API: MessageRelay::{new, forward} take a NextHop,
  new_send_with_sequence takes the hop and reply_via; NextHop::toward
  names a fixed first hop; RouteStage no longer derives Default.
- Model: views may name unlinked peers; the always-true crossing counter
  and a duplicated assertion are removed; the bound |V| + 2 is asserted.
- rustdoc: fix intra-doc links in dht::delivery and a private link.

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

* Move dht::delivery to delivery/mod.rs

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

* Test converged and unconverged delivery separately

The delivery model now states what each regime guarantees instead of
one shared outcome assertion.

- tests/mod.rs: the shared overlay model, the safety laws every overlay
  obeys (Linked, NeverOvershoots, Acyclic, Bounded, Exact), and the
  per-stage unit laws; converged views now carry their predecessor.
- tests/converged.rs: on the Chord fixpoint every route is delivered by
  greedy hops alone (no handoff), every non-delivering hop at least
  halves the remaining distance (so a route takes at most
  ceil(log2 dist) + 1 hops), and no node names a reply_via.
- tests/unconverged.rs: one test per kind of stale view, each with its
  own expected outcome: a stale successor is routed around (#865 from D
  and C), an unknown destination fails fast after exactly one handoff
  (#865 from A), a joiner's answers arrive through reply_via while
  unhinted routes fail fast, unlinked view peers are never hopped to,
  and arbitrary views (exhaustive 4-node, fixed-seed random) satisfy the
  safety laws.

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

* Name the delivery test model and its files with the test_ prefix

delivery/tests/ becomes delivery/test_model/, and converged.rs and
unconverged.rs become test_converged.rs and test_unconverged.rs.

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

* Self-review of #876: linked reply_via, delivery placement, one constructor

- reply_via names the nearest *linked* successor. A successor head can
  be a candidate a stabilization report introduced before any link to
  it exists; naming it would route a joiner's answers to a peer that
  cannot hand them on (the same class as the sync-storm failure).
- The greedy delivery step (RouteStep, route_toward, reaches,
  linked_known_peers) and reply_via move from dht::topology into
  dht::delivery, where they belong; topology.rs differs from master
  only in the find_successor and closest_preceding_finger docs.
- Transaction::new takes reply_via; the parallel
  Transaction::replying_via is removed and its 22 callers migrated.
- Error::NoNextHop is removed: its only use was infer_next_hop.
- Docs: the handoff goes to the first linked known node, not the
  successor head (types.rs, chord.md, RouteStage); the fixpoint hop
  bound is stated as ceil(log2 dist) + 1; reply_via covers only the
  successor and connection answers; originate documents that a manual
  connection answer names the hint harmlessly.
- Test: test_reply_via_names_the_nearest_linked_successor_only_without_a_predecessor.

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

* Let the handoff receiver route on; forbid only a second crossing

A terminal handoff receiver broke every sender with a sparse view. A
leaf linked only to its guard, its ring predecessor, finds the guard past
almost every destination and hands off on its first hop; a terminal
guard then delivered only to its own neighbours although its full view
routes anywhere. A joiner's ConnectNodeSend to its successor failed the
same way unless the bootstrap was linked to that successor. What cycled
in #865 was the unbounded repetition of crossings, not one crossing.

- delivery_step: after a handoff the receiver forwards greedily again;
  only a second handoff is refused (RelayDestinationUnreachable). A route
  toward one aim is at most two greedy runs joined by one handoff, so it
  takes at most 2|V| hops and never cycles.
- Model: per-role liveness tests, each its own proposition, added before
  the rule changed and failing on the terminal rule:
  test_sparse_sender_reaches_every_member,
  test_leaf_behind_its_predecessor_is_reached_by_every_member,
  test_destination_without_links_fails_fast. The #865 route from A is now
  delivered as A -> C -> B -> T. A differential "no regression against
  the owner-lookup router" law was tried and dropped: on arbitrary views
  two greedy choice rules each win where the other dead-ends, so no
  greedy router dominates another (counterexample recorded in the model
  doc).
- Docs: the 2|V| bound and the one-crossing rule in dht::delivery,
  SECURITY.md, chord.md, MAX_RELAY_HOPS, the error doc, CHANGELOG.

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

* Correct two model doc claims after the one-crossing rule

- NeverOvershoots: a single crossing per aim is not a property of the
  type. It follows from the check itself: nothing clears the handoff
  flag, so hops of a flagged stage are held to the no-overshoot law, and
  a second crossing would be rejected as an overshoot.
- The joiner test: without reply_via an answer arrives only if one of
  the two greedy runs happens to pass the bootstrap; the two fixed
  topologies are ones where neither does, so the hint is what makes
  delivery certain.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Apply serif typography and update header tagline (#894)

* Use serif typography and refreshed feature illustrations

* Correct landing header tagline

* Keep landing PR limited to typography and header copy

* Fix flaky tests at the root: event-woken waits everywhere, deterministic fixtures, node test transport (#889)

* test(core): drive test_handle_connect_node on the controlled dummy queue (#850)

test_handle_connect_node ran three nodes over real webrtc-rs loopback and
polled each step against the 5 s TEST_WAIT_TIMEOUT. Under suite load, the
handshakes outran the deadline.

The test now runs on the dummy transport under ControlledDeliveryGuard:
- identities are fixed (fixed_secret_keys) and the dummy ids are seeded;
- deliver_until delivers FIFO until a named predicate holds, bounded by a
  step count and never by a clock;
- P0 (the n1-n2-n3 path joined, with no admitted or pending n1-n3 link)
  is reached first, then Swarm::connect(n3), then P1 (C(n1,n3), C(n3,n1)
  and exact successor lists);
- after P1 the run is driven to quiescence, decided by state (no queued
  event, and no handshake, inbound or outbound on any node), and P1 is
  re-asserted;
- the relay is witnessed: n3 received n1's ConnectNodeSend and n1
  received n3's ConnectNodeReport, each with n2 as the hop signer.

deliver_until takes a RetiredTarget policy, so the strict drain helper
keeps its live-connection check. has_connection_in_state is the shared
predicate behind wait_for_connection_state.

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

* test(core): await retirement events in the native storage-repair test (#858)

The test read the global outbound-transfer counter once right after
disconnect. The retiring worker releases its permits asynchronously, so
under load the read could see a permit about to be released. The repair
and the physical close were polled against deadlines.

- The repair is awaited on a write generation that node2's test storage
  publishes on every put; the generation is marked before the read.
- Maintenance and repair pressure keep running through the retirement.
- After disconnect, the test asserts that node2 is in none of node1's
  topology slots, then awaits deallocation of node2's outbound capacity.
  Deallocation is terminal, and each permit holds the capacity.
- The physical close is awaited through NativePhysicalCloseWitness.
- Repair-test nodes use host-only ICE.

Hooks: rings-transport's NativePhysicalCloseWitness is watch-backed and
gains completed(), documented as supported API. rings-core test builds
give TransferCapacity a never-sent watch whose closing witnesses
deallocation.

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

* test(node): await a complete E2E stream and use host-only processor fixtures (#857)

wait_for_e2e_stream_frames returned on the first final frame, although
the link does not guarantee order. It now waits on e2e_stream_complete,
a monotone set predicate (∃ final f. {0..=f.seq} ⊆ S), and returns the raw
frames in arrival order, so the test's shape assertions still check what
the sender emitted. A unit test covers all 24 arrival orders plus a
trailing duplicate and a stray frame.

Every processor fixture, including tests::native::prepare_processor,
uses host-only ICE.

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

* test(node): await admission and retirement events in the wasm tests (#870)

The browser provider and processor tests slept for fixed durations after
connecting, sending and disconnecting.

- A TransitionRecorder, installed at creation, logs (peer, Admitted |
  Retired) into an unbounded channel, and PeerTransitions consumes
  matching records, so no transition can be missed.
- create_connection awaits both admissions through the shared
  await_mutual_admission helper. disconnect is followed by awaiting
  Retired.
- test_handle_backend_message awaits a JS Promise that the handler
  resolves.
- The processor tests await exactly the expected texts.
- test_processor_connect_with_did reads the listing through
  settled_peer_states, which consumes a superseded generation's
  Admitted/Retired pair.
- Browser fixtures use host-only ICE.
- Each connection test runs under one 20 s with_hang_guard that names
  it, sized so that even five simultaneous hangs stay inside the 120 s
  runner budget.

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

* test(core): mint the cancellation test's admitted connection without a link (#875)

test_cancellation_after_scan_releases_successor_behind_waiting_head only
needs an AdmittedConnection token, but its wasm fixture set up a real
browser WebRTC link and polled admission. A stalled handshake then
consumed the wasm binary's whole 120 s budget.

- admit_detached_peer reserves a pending generation, creates its local
  transport object (no offer, no ICE), and activates it through
  activate_connection_for_test. That hook and its registry counterpart
  widen from cfg(all(test, not(wasm))) to cfg(test). The fixture is the
  same on native and wasm, and uses fixed identities.
- Core's browser fixtures use host-only ICE.
- tests::wasm::with_hang_guard fails a test by name. The real-transport
  handshake tests use 15 s guards, and the repair soak's 60 s is named a
  scenario budget.
- CI runs the soak as its own core wasm invocation, so that budget
  cannot starve the rest of the binary.

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

* test: share fixed test identities and the host-only ICE constant (#886)

Now that the flaky-test fixes land together, the facts that were stated
twice are stated once:
- fixed_secret_keys::<N>() moves into core's crate::tests, which both
  platforms compile, and the outbound cancellation test uses it;
- one node crate::tests::TEST_ICE_SERVERS serves the native and browser
  fixtures.

The core and node with_hang_guard helpers share one shape and reference
each other. They stay separate definitions because each lives in its
crate's cfg(test) module.

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

* test(node): run processor network tests on a controlled in-memory transport (#883, #857)

Processor network tests ran over real webrtc-rs handshakes bounded by
5 s deadlines, so under load the handshake latency decided the outcome
(#850's mechanism). This was #857's remaining root cause.

- rings-node gains a test feature, dummy = [node, rings-core/dummy,
  rings-transport/dummy], documented as not a production configuration.
  It adds no code path and only selects core's in-memory transport.
- Under dummy, network_test_guard(), which every connecting processor
  test already takes, also starts a ControlledNetwork. It enables the
  controlled dummy queue with a fixed seed and runs a FIFO pump on the
  test's thread. No message is delayed by a clock, and the handshake is
  in-memory, so the helpers' remaining bounds are hang guards only.
- The E2E stream test runs on the controlled network. The pump is paused
  while the stream is sent, and the frames are then delivered
  newest-first. Reordering is exercised deterministically, and the test
  asserts that it happened, along with completion, the raw shape and
  decryption. Its identities are fixed.
- The protocol-logic connecting tests (online registry, direct message,
  measurements, E2E handshake and stream, bootstrap probe) run in the
  dummy build. The default build keeps test_processor_create_offer and
  test_processor_handshake_msg as host-only real-WebRTC smoke tests,
  serialized by NETWORK_TEST_LOCK. The gateway tests, which need a real
  public network, run in the default build only.
- CI: the dummy_tests job compiles and runs rings-node --features dummy.

Browser tests are unchanged here: the dummy transport is native-only.

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

* test(core): wake every test wait on observed activity, never on a timer (#882, #890)

Core's shared test helpers polled state on timers and decided quiescence
by a 500 ms silence window, and core's browser soak polled every 25 ms.
Every wait now probes state and is woken by a state change.

- tests::activity is a runtime-neutral activity generation (mutex plus
  wakers, no tokio) used natively and in the browser. Waits follow
  mark, probe, then await generation != mark, so no wake-up is lost.
  Activity is recorded by each test node's observer (message
  sent/forwarded/received/stored, lookup started/finished) and callback
  (validated, handled, connection event).
- Node::build(builder) attaches the observer and callback to every test
  node. prepare_node_without_stun is folded into prepare_node, and every
  in-process fixture uses host-only ICE.
- wait_until_result (and its wait_for_* wrappers), wait_for_storage_state
  and connection.rs's wait helpers are activity-woken probes.
- wait_for_msgs drops the silence window. Quiescence is decided by
  message conservation: no handshake, inbound or outbound transfer, or
  queued dummy event on any node, and sum(delivered) = sum(received) over
  the test's nodes (wire messages only).
- Test builds record activity when a transfer permit is released, both
  outbound and inbound (one cfg(test) line in each Drop): the release is
  a state change the predicate reads, and it follows the delivery or
  handling notification. The in-flight test accessors widen to every
  test build.
- File-local polls became probes: the native-transport interleaving
  trace, the continuous-repair convergence, and the chunk-tail "no
  second chunk" checks, which now await the cancelled transfer's
  release.
- Browser: core's wasm fixtures carry the ledger and callback. The
  repair soak's mesh, convergence, repair and trace waits are
  activity-woken, and its stabilization rounds are separated by
  observed mesh quiescence instead of a 25 ms sleep.
- The pending-measure scheduler test waits for admission only: its
  measure never completes, so quiescence is unreachable by design, and
  the old silence window had masked that.

Hang guards remain only as failure bounds. TEST_HANG_GUARD is 5 s in
dummy builds and 60 s in native builds, where real webrtc-rs handshakes
under load exceeded 5 s (#850); no wait is paced by it.

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

* test(node): activity-woken processor waits and a shared test-support crate (#883, #890, #886)

Review follow-up on #889 (M1, L1–L5), plus the user's direction that no
test wait is paced by a timer:

- rings-test-support, a dev-only crate (publish = false), holds the
  runtime-neutral activity cell, the activity-woken probe and
  with_hang_guard. Core and node share one definition of each, which
  resolves the duplicated hang guard from #886.
- ProcessorBuilder gains a cfg(test) hook that chains one extra observer
  after Observability. Every test processor, native and browser,
  records activity through it; the test callback and the controlled
  pump record activity too.
- The node processor helpers are activity-woken probes: processor
  admission, inbound messages, the mutual DHT topology, the registry
  lookup and storage, and peer measurements. The Notify fields and the
  20 ms polls are gone. Hang guards are failure bounds only (5 s dummy,
  60 s real WebRTC). The two real-transport smoke tests document what
  only the real transport checks.
- The pre-stopped listener tests assert completion on the first poll
  instead of within 100 ms: returning before the first stabilization
  tick means waiting for nothing.
- The browser listener test decides "started is still pending" with a
  microtask flush plus Promise.race against a resolved sentinel,
  instead of a 10 ms sleep.
- The dummy seed is per test (base seed mixed with the test's name), and
  the controlled network is torn down before the serial lock is
  released. The rings binary refuses to build with the dummy feature,
  and CI's feature-check step lists its features instead of
  --all-features.

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

* test(browser): activity-woken waits in the dweb and frontend browser tests (#890)

The remaining browser tests outside rings-core and rings-node waited on
timers. dweb connected and retried a fetch every 500 ms. The frontend
onion flow slept 1 s after connecting and retried navigation every
250 ms, and its listener test slept 20 ms before asserting.

- ProcessorBuilder::observer(SharedSwarmObserver) is now public API. It
  chains one extra observer after the processor's own Observability, as
  SwarmBuilder::observer does for a bare swarm, so embedders, and these
  external tests, can observe swarm activity. It replaces the
  cfg(test)-only hook.
- dweb: a host-only test node (build_node_with) records activity. The
  test probes listPeers on activity until A lists B as connected, sends
  the request once, and probes for B's page on activity. The demo app
  keeps its STUN server.
- frontend: host-only ICE. Admission is probed on activity, and each
  failed gateway navigation is re-armed on the next activity after the
  failure rather than on a timer. The listener check flushes the
  microtask queue instead of sleeping 20 ms.
- Both use rings-test-support as a path dev-dependency.

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

* test: count arrivals, stabilise quiescence, relayed-connect smoke test (#889 review round 2)

- M1: conservation now balances deliveries against arrivals counted by
  the receiving transport before validation. A cfg(test) counter is
  bumped in process_event for logical-lane events, so a message the
  receiver drops or rejects still balances its delivery. The
  quiescence fold holds only if the activity generation did not move
  while the nodes were sampled, which is safe on multi-thread runtimes.
  Both preconditions are documented.
- M2: the frontend navigation retry marks activity before each
  attempt, so a change during or after the attempt wakes the next one,
  and it keeps the last error for the guard report.
- M3: test_relayed_connect_over_real_webrtc, a four-node real-webrtc-rs
  ring with one successor and no fingers. It always issues n1's connect
  to n3 from a state with no n1-n3 link (a join-time link is retired
  first) and witnesses the ConnectNodeSend relayed by n2.
- L2: rings-node refuses to build ffi together with dummy.
- L3: ProcessorBuilder::observer documents the non-blocking observer
  contract and that a later call replaces an earlier one.
- L4: the listener lifecycle tests decide "still waiting" with a direct
  futures::poll! of the listener future instead of 20-100 ms sleeps and
  timeouts.
- L5: an unnamed test thread fails instead of sharing a fallback seed.
- L6: rings-test-support gains `within` and, behind its swarm-observer
  feature, the one activity observer used by node, dweb and frontend.
  Its lint table is the workspace's minus clippy::panic, since failing
  tests is its purpose.

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

* docs(node): state the public ProcessorBuilder::observer contract (#889 L1)

User decision: keep ProcessorBuilder::observer public as an embedder hook.
The method now states its contract. The observer is synchronous and
bounded on the send and receive paths. It runs after the processor's own
Observability, which alone feeds /status?view=observability, so that
output is unchanged. Message observations carry no payload, key or
identifier. Lookup events carry a correlation key that exporters must
aggregate or redact. A later call replaces an earlier observer.

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

* test: frame-level conservation, independent progress signals (#889 review round 3)

- H1/M1: quiescence balances frames at the swarm's two chokepoints. A
  cfg(test) FrameLedger counts sending/sent around SwarmConnection::send_data
  and arrived plus an in-flight token at submit_inbound_message entry, released
  at the inbound actor's handoff. Replaces the observer delivered counter and
  the process_event arrival counter; MessageLedger and TestSwarm are removed.
- L1: the relayed-connect smoke test serializes its joins, asserts P0 on both
  sides and asserts both exact paths (send n1->n2->n3, report back via n2).
- L2: the frontend onion retry wakes only on DHT storage writes.
- L3: the stale-stop check polls the restarted listener directly.
- L4/L5/L6: feature-unification, ffi+dummy and soak hang-guard docs.
- zkjoie P2: the registry test looks up once after the decisive storage
  state; control rounds wait on the storage transfer's own progress.
- CI: rings-test-support workspace dependency carries a version for
  cargo-deny's wildcard ban.

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

* test: ordered frame sampling, commit-point send counting (#889 review round 4)

- M1: arrive() raises in_flight before arrived; the quiescence fold reads
  each swarm through sample_swarm, loading sending, sent, arrived, in_flight
  in the ledger's sampling-law order, then the actor and scheduler witnesses.
- M2: a send counts as sent once the transport committed to it (Ok or an
  irrevocable acceptance), so losses below the swarm leave sent >= arrived;
  on_invalid_inbound_frame counts its frame as arrived.
- M3: rings-test-support stays a version-less path dev-dependency, so it is
  stripped from packaged manifests; deny.toml allows wildcard paths.
- L1: CI runs the relayed-connect smoke test in its own invocation.
- L2: control rounds wait for a storage frame after the traced control.
- L3/L4: read-repair wakes documented; the stale-stop check asserts the
  restarted generation's token.
- Fix dead-code under dummy and wasm clippy for the new test helpers.

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

* test: outbound-first sampling, stated injection precondition (#889 review round 5)

- L1: sample_swarm reads the outbound permits before the ledger, since a
  transfer's permit is released only after its sends were counted.
- L2: the control-round predicate doc and the browser soak comment state
  that maintenance controls share the link there.
- L3: swarms_quiescent states that every received frame was sent through
  send_data; tests that inject frames do not wait on it.
- L4: the stale-stop comment claims only what the poll proves; the
  tautological token witness is removed.

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

* test(core): wait on the whole network while an offline peer's message is held

hold_message_for_offline_peer waited for quiescence over node1 and node3
only. In the rejoin test the departed peer still exists, and admission's
successor synchronization can reconnect it while the message is held, so
frames from a node outside the fold broke conservation (CI: sent=45,
arrived=46; locally 12/40 failures). The helper now takes every node of
the test for its waits.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Remove the paced direct-edge throughput bottlenecks (#887, #888) (#900)

* Confirm delivery on buffered-amount events instead of a 300 ms poll (#887)

Both WebRTC backends polled `buffered_amount` every 300 ms to resolve a
send's delivery future, so every awaited send cost about one poll period
and a paced link carried about 3.4 messages/s whatever its capacity.

Each data channel now owns a delivery tracker. A pure registry
multiplexes the channel's single `bufferedAmountLowThreshold` over every
pending end offset: it arms tau(E) = E - min(pending end offset), so
`bufferedamountlow` fires exactly when the earliest pending send has
left the buffer, and each settle round applies the existing flush
predicate to every pending send. A single-flight round arms the
threshold before it reads `bufferedAmount` (the lost-wakeup rule), and a
request that arrives mid-round forces one more arm-and-read step.
`close` and `error` settle every pending send as not delivered. No
timer takes part in a verdict.

The backends only supply the two channel effects (arm, observe) and wire
the three events: webrtc-rs `on_buffered_amount_low`/`on_close`/
`on_error`, and web-sys `bufferedamountlow`/`close`/`error`.

Tests check soundness and one-event liveness over every schedule of six
send/drain operations, and cover concurrent pending sends on one
channel, a flush before registration, an event during a round, closure,
dropped waiters and an abandoned round. They also run awaited,
concurrent and closed sends over a real native loopback link.

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

* Admit paced direct-edge traffic at the rate its protocol supplies (#888)

Core charged every Application message to one per-origin quota of 8
msg/s with a burst of 32. A protocol that paces its direct-edge traffic
and bounds each sending neighbour itself was refused far below its own
admission rate: the onion data plane emits about 98 cells/s under its
per-link budget B/V.

A final-destination transaction is now charged to an OriginQuotaLane.
This is its class lane, except that Application traffic from the
handshake-authenticated neighbour that originated it may use a paced
lane, if the protocol owning the payload registered one. Core takes only
a PacedRate (budget per period, burst = budget) and an opaque
PacedLaneId. It has no vocabulary of the owning protocol. The lane
replaces only the message bucket, whose refill is now rational; the byte
bucket and the record bound stay the Application lane's. All other
Application namespaces keep the configured 8 msg/s default.

- core: OriginQuotaLane::select states the law in one place and
  consults the application only where the lane may apply. EdgeRelation
  is Neighbour only for the authenticated connection of the origin
  itself: a relayed or foreign origin would multiply a per-link rate by
  its path count, and the unsigned relay carrier cannot witness
  "direct". Quota keys store only the lane identity
  (OriginQuotaLaneId). SwarmCallback::paced_lane is the application
  hook. Paced drops are counted in one bounded aggregate.
- node: Protocol::paced_direct_rate declares a protocol's rate. The
  registry installs it under the same write lock as the handler, and
  keeps the lane identity across a replace. Backend resolves an
  envelope's lane from its namespace prefix alone.
- Tests: the selection law over every class, edge and registration.
  On a simulated clock, a sender at 98 cells/s for 30 minutes is never
  refused in the paced lane, while the default Application lane admits
  at most burst + 8 msg/s. A flooding neighbour is bounded by
  B + B*T/V. Paced and class records are independent. End to end, the
  neighbour's own paced traffic is not refused, while a relayed origin
  and an unregistered namespace of the same neighbour are refused in
  the Application lane. Registration installs and replacement
  withdraws the rate.
- Docs: SECURITY.md origin-rate boundary, config.yaml origin_quota.

The TransactionSequenceStale drops seen with this traffic are genuine
reordering beyond the 32-sequence window, not a knock-on of the quota
refusals. The sequence stream is shared across classes, and the
outbound scheduler reorders classes. The window and the ordering are
unchanged here, and the ordering change is tracked in #898.

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

* Settle delivery against one E snapshot taken before b (#887 review)

H1: a settle round re-read E after reading bufferedAmount. A send that
landed between the two reads counted its bytes in E but not in the b
the round saw. So φ(E, b, e) could hold while earlier bytes were still
unacknowledged, a false flush on native. M1: E was also loaded before
begin_step took the registry lock, so a registration in that gap had its
Rerun absorbed against a stale E, which broke one-event liveness.

The round now snapshots E once, under the registry lock, in the step
that arms τ, and settles against that same E. The send path stores E
only after the write has counted the bytes in b, so the snapshot never
over-counts released bytes. A registration's request absorbed by the
step is visible to it through the lock.

- Tests: an enqueue between the read of b and the settlement fabricates
  no flush; a registration during a round re-arms τ against the new E.
  The dropped-waiter test is now driven by events.
- Docs: the registry states both arguments. The saturation comments
  now say saturation cannot occur (τ < b). The abandoned-round comments
  say only a later registration is sure to recover. The drop semantics
  are stated, as is the native meaning of "flushed" (acknowledged by
  the peer's SACK).

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

* Bound paced-lane identity, trust and exposure (#888 review)

- M2: core does not bound a declared rate. The PacedRate,
  SwarmCallback::paced_lane, Protocol::paced_direct_rate and
  SECURITY.md docs state that the rate is trusted local configuration,
  with the authority of raising the Application message quota for that
  namespace's neighbour-originated traffic.
- M3: a namespace receives its PacedLaneId the first time it declares a
  rate and keeps it for the registry's lifetime. Withdrawing and
  re-declaring therefore grants no fresh allowance and orphans no
  records. Exhausted identities are an error, not a saturated shared
  id, and a batch applies all-or-nothing.
- L4: core docs and tests are protocol-neutral (window admission of
  budget per period).
- L5: SECURITY.md states that no protocol on master declares a paced
  rate yet, gives the table bound (4 + k) * max_records, and repairs
  the broken sentence.
- L6: EdgeRelation and OriginQuotaLane::select are crate-private;
  PacedLaneId::get is removed.
- L7: the end-to-end tests assert the lane of the origin's quota
  record, with no clock. They cover the neighbour's own paced traffic,
  an unregistered namespace, a relayed origin, the origin arriving
  over another neighbour, an unauthenticated peer, and the reassembly
  path, and they check that the registry is consulted only for
  eligible traffic.
- L8: the new modules list their imports.

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

* Close the round-2 review lows of #900

- L1: the registry states the precondition of one-event liveness. A
  caller cancelled between committing E and registering leaves tau
  armed low by that send's bytes. Earlier sends are then delayed by at
  most one message's drain, and no verdict is lost.
- L2: the registration-during-a-round test documents that it covers
  the Rerun path, and that a registration between the load of E and
  begin_step is excluded by construction (E is loaded under the lock).
- L3: the unauthenticated-peer case asserts that the frame is admitted
  into the Application lane, not merely that it is not paced.
- L4: the dropped-waiter note says "at least as high", and the merged
  paragraph is split.
- L5: the SECURITY.md paragraphs are reflowed within 100 columns, and
  the two paced-rate entry points are named.
- L6: paced lane identities cover every u32, including u32::MAX, before
  exhaustion is reported. The exhaustion path is tested.

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

* Adopt the #889 test patterns in the #887 and #888 tests

- Transport native loopback tests: the hang guard is rings-test-support's
  with_hang_guard, which names the test that hung. rings-test-support is
  a transport dev-dependency, without the swarm-observer feature.
- Paced-lane end-to-end tests: every identity comes from
  fixed_secret_keys, so runs are reproducible.

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

* Replace the paced lane with delegated admission (#888 revised decision)

The paced lane enforced the rate B/V that the declaring protocol's own
admission already enforces. Without a core ceiling it bounded nothing at
the core layer. Per the revised decision on #888, it is replaced by a
flag with no rate.

- core: admission_delegation replaces paced_lane. An Application
  namespace may declare delegated admission. For a transaction that
  satisfies the unchanged EdgeRelation::Neighbour predicate in such a
  namespace, OriginQuotaCharge::select yields MessageLimit::Delegated.
  The admission then neither needs nor consumes a message token, and is
  still charged to the same Application record's byte bucket under the
  same record bound. Relayed, foreign-origin, local and unauthenticated
  traffic keeps the enforced limits. OriginQuotaKey, the per-lane limits
  and the counters are back to master's shape; OriginQuota::admit and
  the table take the MessageLimit.
- SwarmCallback::delegates_admission and Protocol::delegates_admission
  replace the rate-carrying paced_lane and paced_direct_rate. The node
  registry keeps a set of delegating namespaces, installed under the
  handler write lock. PacedRate, PacedLaneId, lane-id allocation and
  exhaustion are removed.
- Tests:
  - The selection law, and a simulated-clock acceptance test: one
    neighbour's record carries a delegated namespace at 98 msg/s and
    another at 20 msg/s for 30 minutes. The delegated one is never
    refused by the message limit; the other still is, beyond
    burst + 8 T.
  - Delegated traffic is still refused by the byte bucket.
  - Clock-free e2e cases (own traffic, unregistered namespace, relayed
    origin, origin N via neighbour M, unauthenticated peer, reassembly)
    assert the exact stored tokens: message limit skipped (bucket full)
    or charged (burst - 1), and bytes charged (byte_burst - cost).
- SECURITY.md and config.yaml: delegation is trusted local
  configuration, a declaring namespace must enforce its own
  per-neighbour admission, both entry points are named, and it is inert
  on master.

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

* Charge delegated admissions a 16 KiB byte floor (#888 addendum)

H1 (round 4): with delegation, core's own per-message admission work
(two signature checks and a replay-snapshot persist under the replay
lock) was bounded only by bytes. About 50-byte messages would allow
roughly 80k admissions per second, and no delegating protocol can bound
that work, because it runs before the protocol sees the message.

Per the maintainer's addendum on #888, a delegated admission is charged
max(len, DELEGATED_MIN_CHARGE) bytes against the per-origin byte
bucket. The floor is a 16 KiB core constant documented as a generic
bound on per-message work. Under the default 4 MiB/s and 64 MiB burst
it bounds a neighbour's delegated admissions to 256 per second with a
burst of 4096. Messages of 16 KiB or more pay exactly their length.

- Tests:
  - 50-byte delegated messages at 1000/s are bounded by the floor rate.
  - The acceptance test runs the delegated namespace at 98 msg/s of
    16 KiB and is still never refused.
  - Unit and e2e tests check the exact floor and length charges.
  - A small (and an undecodable) delegated payload is charged the
    floor end to end.
  - node pins that a malformed tail after a delegating namespace
    still resolves as delegated (L5).
- L1: the delegation verdict is documented as taken at admission time,
  with a replace-while-queued exposure bounded by the mailbox depth and
  the floor.
- L2: the protocol admits "before its own processing, after core's
  admission".
- L3: SECURITY.md and config.yaml state that delegated and plain
  traffic share the neighbour's Application byte bucket.
- L4: CHANGELOG entries for #887 and for the breaking #888 API.

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

* Close the round-5 review lows of #900

- L1: the docs no longer claim that the byte floor bounds the signature
  checks. It bounds how many delegated messages core admits, and so the
  per-message work that follows admission (the replay-snapshot persist,
  the logical lane, validation and dispatch). The decode and signature
  checks precede the quota and are not bounded by it. Reworded in the
  DELEGATED_MIN_CHARGE doc, the admission_delegation module and
  SECURITY.md.
- L2: the floor-rate test asserts the exact count on its simulated
  clock, floor((byte_burst + byte_rate * t_last) / floor) =
  4096 + 256 * 59.999, so an over-charge also fails it.
- L3: the e2e helpers use u128::from/try_from instead of `as` casts.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Joie <69368441+zkjoie@users.noreply.github.com>
…ck deadlock (#945, #946, #947) (#949)

* Evaluate the link predicate outside the topology lock (#946)

The delivery views (delivery_step, reply_via, origination) evaluated the
transport's link predicate while holding topology_transition. That
predicate takes the connection lifecycle boundary, which admission holds
across a topology transition, so a routed send could deadlock against a
concurrent admission (an ABBA inversion, observed as a hang of the #945
gateway test).

The views now take their snapshot under the lock and evaluate the
predicate after releasing it, restoring the single order
lifecycle < topology_transition, stated as a law. The read-only views
move into peer_ring/views.rs, so peer_ring.rs drops under 1000 lines. A
test probes the lock with try_lock from inside the predicate, per view.

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

* Return connect signaling along its recorded path (#947, #945)

Crossing offers were resolved by a DID tie-break that assumed the
winner's offer is delivered. When it was lost (a relay dead-end on an
unconverged ring), both nodes kept pending attempts until the 180 s
timeout and never formed the edge (#945).

- Connect signaling records its relay path. ConnectNodeSend records the
  hops it crosses, and its replies (the answer or a counter-offer)
  retrace that path, each hop a linked neighbour. The path sits in the
  unsigned relay header, bounded by MAX_RELAY_HOPS, with pairwise
  distinct hops excluding the holder and the destination. Inbound
  validation refuses a path on any other message and a missing path on
  connect signaling, so every other message stays pathless (#736).
- The pending attempt carries its handshake state in the lifecycle
  registry: Offering { owner, Creating | Sent | Answered } or
  Answering { reply_sent }. A crossing offer meets a pure verdict: a
  Creating offer yields in either DID order; a Sent overlay offer of the
  smaller DID returns itself as a counter-offer along the loser's path;
  Answered and Answering refuse. So whenever either offer is delivered
  the edge forms.
- An answer is bound to the offer it answers (its transaction id), so a
  late answer never touches a newer attempt; a reply that cannot be
  retraced reserves nothing.
- A step failure is ConnectionAttemptSuperseded only when the peer's
  answering generation took the slot, otherwise the new
  ConnectionAttemptCancelled; this also fixes #945's "Local connection
  closed" mode, a Creating attempt cancelled mid ICE gathering.
- HTTP/manual handshakes own their offers (never counter-offered over
  the DHT). answerOffer may return a counter-offer, which the node's
  HTTP handshake answers and delivers with accept_answer, reporting the
  edge in flight.
- Breaking: wire cutover; MessageRelay.path; answer_remote_connection
  and answer_offer return an OfferReply.

The #945 gateway fixture waits for admission on events instead of a
25 ms sleep poll (providers install an admission-activity observer), and
completes a counter-offer when its manual exchange meets one; it now
passes 140 runs with no edge failure.

Files over 1000 lines are split: the handshake out of transport.rs,
cancellation out of pending.rs, connection handler tests, held-message
transport tests, and the node processor's handshake and E2E stream code.

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

* Complete a counter-offer in the relay example's handshake (#947)

answer_offer now returns an OfferReply; the relay example's connect
answers a counter-offer instead of assuming an answer.

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

* Keep MessageRelay::forward's docs free of private links (#947)

rustdoc -D warnings refused the public forward's links to the
crate-private return_hop and SignalingPath::forwarded; the doc states
the same contract in prose.

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

* Point CI at the relayed-connect test's new module path (#947)

The connection handler's tests moved to test_connection, so the QACI
skip and filter for test_relayed_connect_over_real_webrtc matched no
test and the run-ci-tests guard refused the exclusion.

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

* Release a yielding attempt only in the state its verdict read (#947)

A crossing offer was decided on the attempt's signaling state read under
the lifecycle boundary, and the yield then cancelled the attempt by
identity alone after the boundary was released. An offer decided as
Creating could meanwhile leave as Sent; cancelling it then dropped an
offer already on its way, while the peer, meeting that offer, yielded
too, and both ends released the edge until the pending timeout.

The yield now releases the attempt only while its signaling state is the
one decided on (remove_pending_in, CancellationScope::PendingIn); a
release that finds the state moved on decides again (Law (Decided
state)), which ends since the phases only advance.

Also from the review:
- Swarm::answer_offer matches the outcome directly; the SignalingReply
  alias and its From impl, used only there, are removed.
- The Answering verdict and superseded_by_peer docs state what the code
  decides.
- test_connection imports its items explicitly.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…and develop's #949 (#941)

* Order IndexedDB LRU eviction by a store-wide logical clock (#853) (#856)

Eviction ordered rows by wall-clock milliseconds made monotone only per
row, so rows touched within one timer tick could tie and the index broke
the tie by key, evicting a recently read row (seen in Firefox, whose
timers are clamped).

Each database now keeps one access clock in a companion object store.
Every put and every get hit takes the next clock value as the row's
`access_stamp` and advances the clock in the same read-write transaction.
Law: k accesses receive k distinct, strictly increasing stamps whatever
the timer resolution; no timer is read. The clock stops with an error at
Number.MAX_SAFE_INTEGER instead of repeating a stamp, and `clear` keeps it.

The schema moves to version 2. A version-1 database is migrated in place
on first open: the upgrade adds the clock store and the `access_stamp`
index and drops the wall-clock indexes, then one transaction restamps
every row 0..n in its former eviction order (last_visit_time, then key)
and sets the clock to n. The clock record marks a finished migration, so
an interrupted one reruns from the untouched rows.

Browser tests cover the tick law, the migration order and its rerun, and
bursts of back-to-back accesses with exact eviction; none waits on or
reads wall-clock time.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Deliver toward a node DID without overshooting it (#865, #873) (#876)

* Deliver toward a node DID without overshooting it (#865, #873)

Replies routed by DID cycled on an unconverged ring until MAX_RELAY_HOPS
dropped them (#865). The root cause (#873) is that delivery to a node
reused the owner lookup: find_successor answers Local(head) with head past
the key, which is right for "who owns position k" and wrong for "next hop
toward node T". A hop that did not know T handed the payload past it, and
greedy routing from the far side circled among the nodes whose views skip
T.

- dht::delivery: a dedicated delivery step over a carrier stage
  (aim, handed_off). Greedy hops forward to the known peer on (n, aim]
  nearest the aim, so dist(., aim) strictly decreases and no route can
  cycle. When no known peer lies on that arc, the hop hands off once to
  its successor head and marks the carrier; the receiver delivers over a
  direct link or fails fast with RelayDestinationUnreachable, and never
  routes greedily again. A route ends within 2(|V| + 1) + 1 hops in any
  views, so RelayHopBudgetExhausted is unreachable on the delivery path.
- find_successor keeps its owner-lookup semantics; its closest-preceding
  scan now covers successors as well as fingers.
- Transaction::reply_via (signed, in the transaction hash): while a node
  has no predecessor, i.e. no node is known to route to it, its requests
  name its successor head. Reports start in stage (via reply_via) and
  return through that peer, which hands them over its direct link. This
  keeps joins working under fail-fast: a joiner is known only to its
  bootstrap until its ring neighbours learn it.
- Checked model (dht::delivery::tests): exhaustive over every knowledge
  assignment of a 4-node ring crossed with off-view links and reply_via
  choices, plus a fixed-seed randomized pass; asserts linked hops, no
  overshoot, at most one crossing per aim, no repeated state, the hop
  bound, converged => delivered, the join-window law, and the #865
  topology regression.
- Docs: relay module, Transaction return-address law, SECURITY.md Layer
  Contracts, docs chord.md, MAX_RELAY_HOPS, Chord trait, find_successor.
- Version 0.32.0: the carrier stage and reply_via change the wire format.

Closes #865
Closes #873

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

* Address review round 1 and CI on #876

- One origination path: PayloadSender::originate is the only constructor
  of a locally originated request. It decides the first hop and the
  signed reply_via from one topology snapshot
  (dht::delivery::origination). SwarmTransport::signed_payload is removed
  and its callers (finger fixes, ProbeRequest, storage sync, manual
  offer/answer) use it, so a node without a predecessor names its
  successor head on every request.
- Bounded reflection: only FindSuccessorReport and ConnectNodeReport
  honour reply_via (Message::returns_through_reply_via); every other
  report routes straight to the origin.
- find_successor is master's version again (fingers only). The successor
  list may name peers a stabilization report introduced before any link
  exists, which made storage sync pick an unlinked next hop
  (SwarmMissDidInTable in the sync-storm replay). Delivery scans
  (succ ∪ fingers) ∩ Linked instead, and hands off to the first linked
  known node.
- Derived hop bound |V| + 2 (one greedy run, one handoff, two terminal
  deliveries); the "budget exhaustion is unreachable" claim is replaced
  by its premise (|V| + 2 > MAX_RELAY_HOPS only with a sparse finger
  table). reply_via is disclosed whenever a node has no predecessor, not
  only while joining. Docs, SECURITY.md and CHANGELOG follow.
- One form of each API: MessageRelay::{new, forward} take a NextHop,
  new_send_with_sequence takes the hop and reply_via; NextHop::toward
  names a fixed first hop; RouteStage no longer derives Default.
- Model: views may name unlinked peers; the always-true crossing counter
  and a duplicated assertion are removed; the bound |V| + 2 is asserted.
- rustdoc: fix intra-doc links in dht::delivery and a private link.

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

* Move dht::delivery to delivery/mod.rs

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

* Test converged and unconverged delivery separately

The delivery model now states what each regime guarantees instead of
one shared outcome assertion.

- tests/mod.rs: the shared overlay model, the safety laws every overlay
  obeys (Linked, NeverOvershoots, Acyclic, Bounded, Exact), and the
  per-stage unit laws; converged views now carry their predecessor.
- tests/converged.rs: on the Chord fixpoint every route is delivered by
  greedy hops alone (no handoff), every non-delivering hop at least
  halves the remaining distance (so a route takes at most
  ceil(log2 dist) + 1 hops), and no node names a reply_via.
- tests/unconverged.rs: one test per kind of stale view, each with its
  own expected outcome: a stale successor is routed around (#865 from D
  and C), an unknown destination fails fast after exactly one handoff
  (#865 from A), a joiner's answers arrive through reply_via while
  unhinted routes fail fast, unlinked view peers are never hopped to,
  and arbitrary views (exhaustive 4-node, fixed-seed random) satisfy the
  safety laws.

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

* Name the delivery test model and its files with the test_ prefix

delivery/tests/ becomes delivery/test_model/, and converged.rs and
unconverged.rs become test_converged.rs and test_unconverged.rs.

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

* Self-review of #876: linked reply_via, delivery placement, one constructor

- reply_via names the nearest *linked* successor. A successor head can
  be a candidate a stabilization report introduced before any link to
  it exists; naming it would route a joiner's answers to a peer that
  cannot hand them on (the same class as the sync-storm failure).
- The greedy delivery step (RouteStep, route_toward, reaches,
  linked_known_peers) and reply_via move from dht::topology into
  dht::delivery, where they belong; topology.rs differs from master
  only in the find_successor and closest_preceding_finger docs.
- Transaction::new takes reply_via; the parallel
  Transaction::replying_via is removed and its 22 callers migrated.
- Error::NoNextHop is removed: its only use was infer_next_hop.
- Docs: the handoff goes to the first linked known node, not the
  successor head (types.rs, chord.md, RouteStage); the fixpoint hop
  bound is stated as ceil(log2 dist) + 1; reply_via covers only the
  successor and connection answers; originate documents that a manual
  connection answer names the hint harmlessly.
- Test: test_reply_via_names_the_nearest_linked_successor_only_without_a_predecessor.

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

* Let the handoff receiver route on; forbid only a second crossing

A terminal handoff receiver broke every sender with a sparse view. A
leaf linked only to its guard, its ring predecessor, finds the guard past
almost every destination and hands off on its first hop; a terminal
guard then delivered only to its own neighbours although its full view
routes anywhere. A joiner's ConnectNodeSend to its successor failed the
same way unless the bootstrap was linked to that successor. What cycled
in #865 was the unbounded repetition of crossings, not one crossing.

- delivery_step: after a handoff the receiver forwards greedily again;
  only a second handoff is refused (RelayDestinationUnreachable). A route
  toward one aim is at most two greedy runs joined by one handoff, so it
  takes at most 2|V| hops and never cycles.
- Model: per-role liveness tests, each its own proposition, added before
  the rule changed and failing on the terminal rule:
  test_sparse_sender_reaches_every_member,
  test_leaf_behind_its_predecessor_is_reached_by_every_member,
  test_destination_without_links_fails_fast. The #865 route from A is now
  delivered as A -> C -> B -> T. A differential "no regression against
  the owner-lookup router" law was tried and dropped: on arbitrary views
  two greedy choice rules each win where the other dead-ends, so no
  greedy router dominates another (counterexample recorded in the model
  doc).
- Docs: the 2|V| bound and the one-crossing rule in dht::delivery,
  SECURITY.md, chord.md, MAX_RELAY_HOPS, the error doc, CHANGELOG.

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

* Correct two model doc claims after the one-crossing rule

- NeverOvershoots: a single crossing per aim is not a property of the
  type. It follows from the check itself: nothing clears the handoff
  flag, so hops of a flagged stage are held to the no-overshoot law, and
  a second crossing would be rejected as an overshoot.
- The joiner test: without reply_via an answer arrives only if one of
  the two greedy runs happens to pass the bootstrap; the two fixed
  topologies are ones where neither does, so the hint is what makes
  delivery certain.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Apply serif typography and update header tagline (#894)

* Use serif typography and refreshed feature illustrations

* Correct landing header tagline

* Keep landing PR limited to typography and header copy

* Fix flaky tests at the root: event-woken waits everywhere, deterministic fixtures, node test transport (#889)

* test(core): drive test_handle_connect_node on the controlled dummy queue (#850)

test_handle_connect_node ran three nodes over real webrtc-rs loopback and
polled each step against the 5 s TEST_WAIT_TIMEOUT. Under suite load, the
handshakes outran the deadline.

The test now runs on the dummy transport under ControlledDeliveryGuard:
- identities are fixed (fixed_secret_keys) and the dummy ids are seeded;
- deliver_until delivers FIFO until a named predicate holds, bounded by a
  step count and never by a clock;
- P0 (the n1-n2-n3 path joined, with no admitted or pending n1-n3 link)
  is reached first, then Swarm::connect(n3), then P1 (C(n1,n3), C(n3,n1)
  and exact successor lists);
- after P1 the run is driven to quiescence, decided by state (no queued
  event, and no handshake, inbound or outbound on any node), and P1 is
  re-asserted;
- the relay is witnessed: n3 received n1's ConnectNodeSend and n1
  received n3's ConnectNodeReport, each with n2 as the hop signer.

deliver_until takes a RetiredTarget policy, so the strict drain helper
keeps its live-connection check. has_connection_in_state is the shared
predicate behind wait_for_connection_state.

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

* test(core): await retirement events in the native storage-repair test (#858)

The test read the global outbound-transfer counter once right after
disconnect. The retiring worker releases its permits asynchronously, so
under load the read could see a permit about to be released. The repair
and the physical close were polled against deadlines.

- The repair is awaited on a write generation that node2's test storage
  publishes on every put; the generation is marked before the read.
- Maintenance and repair pressure keep running through the retirement.
- After disconnect, the test asserts that node2 is in none of node1's
  topology slots, then awaits deallocation of node2's outbound capacity.
  Deallocation is terminal, and each permit holds the capacity.
- The physical close is awaited through NativePhysicalCloseWitness.
- Repair-test nodes use host-only ICE.

Hooks: rings-transport's NativePhysicalCloseWitness is watch-backed and
gains completed(), documented as supported API. rings-core test builds
give TransferCapacity a never-sent watch whose closing witnesses
deallocation.

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

* test(node): await a complete E2E stream and use host-only processor fixtures (#857)

wait_for_e2e_stream_frames returned on the first final frame, although
the link does not guarantee order. It now waits on e2e_stream_complete,
a monotone set predicate (∃ final f. {0..=f.seq} ⊆ S), and returns the raw
frames in arrival order, so the test's shape assertions still check what
the sender emitted. A unit test covers all 24 arrival orders plus a
trailing duplicate and a stray frame.

Every processor fixture, including tests::native::prepare_processor,
uses host-only ICE.

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

* test(node): await admission and retirement events in the wasm tests (#870)

The browser provider and processor tests slept for fixed durations after
connecting, sending and disconnecting.

- A TransitionRecorder, installed at creation, logs (peer, Admitted |
  Retired) into an unbounded channel, and PeerTransitions consumes
  matching records, so no transition can be missed.
- create_connection awaits both admissions through the shared
  await_mutual_admission helper. disconnect is followed by awaiting
  Retired.
- test_handle_backend_message awaits a JS Promise that the handler
  resolves.
- The processor tests await exactly the expected texts.
- test_processor_connect_with_did reads the listing through
  settled_peer_states, which consumes a superseded generation's
  Admitted/Retired pair.
- Browser fixtures use host-only ICE.
- Each connection test runs under one 20 s with_hang_guard that names
  it, sized so that even five simultaneous hangs stay inside the 120 s
  runner budget.

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

* test(core): mint the cancellation test's admitted connection without a link (#875)

test_cancellation_after_scan_releases_successor_behind_waiting_head only
needs an AdmittedConnection token, but its wasm fixture set up a real
browser WebRTC link and polled admission. A stalled handshake then
consumed the wasm binary's whole 120 s budget.

- admit_detached_peer reserves a pending generation, creates its local
  transport object (no offer, no ICE), and activates it through
  activate_connection_for_test. That hook and its registry counterpart
  widen from cfg(all(test, not(wasm))) to cfg(test). The fixture is the
  same on native and wasm, and uses fixed identities.
- Core's browser fixtures use host-only ICE.
- tests::wasm::with_hang_guard fails a test by name. The real-transport
  handshake tests use 15 s guards, and the repair soak's 60 s is named a
  scenario budget.
- CI runs the soak as its own core wasm invocation, so that budget
  cannot starve the rest of the binary.

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

* test: share fixed test identities and the host-only ICE constant (#886)

Now that the flaky-test fixes land together, the facts that were stated
twice are stated once:
- fixed_secret_keys::<N>() moves into core's crate::tests, which both
  platforms compile, and the outbound cancellation test uses it;
- one node crate::tests::TEST_ICE_SERVERS serves the native and browser
  fixtures.

The core and node with_hang_guard helpers share one shape and reference
each other. They stay separate definitions because each lives in its
crate's cfg(test) module.

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

* test(node): run processor network tests on a controlled in-memory transport (#883, #857)

Processor network tests ran over real webrtc-rs handshakes bounded by
5 s deadlines, so under load the handshake latency decided the outcome
(#850's mechanism). This was #857's remaining root cause.

- rings-node gains a test feature, dummy = [node, rings-core/dummy,
  rings-transport/dummy], documented as not a production configuration.
  It adds no code path and only selects core's in-memory transport.
- Under dummy, network_test_guard(), which every connecting processor
  test already takes, also starts a ControlledNetwork. It enables the
  controlled dummy queue with a fixed seed and runs a FIFO pump on the
  test's thread. No message is delayed by a clock, and the handshake is
  in-memory, so the helpers' remaining bounds are hang guards only.
- The E2E stream test runs on the controlled network. The pump is paused
  while the stream is sent, and the frames are then delivered
  newest-first. Reordering is exercised deterministically, and the test
  asserts that it happened, along with completion, the raw shape and
  decryption. Its identities are fixed.
- The protocol-logic connecting tests (online registry, direct message,
  measurements, E2E handshake and stream, bootstrap probe) run in the
  dummy build. The default build keeps test_processor_create_offer and
  test_processor_handshake_msg as host-only real-WebRTC smoke tests,
  serialized by NETWORK_TEST_LOCK. The gateway tests, which need a real
  public network, run in the default build only.
- CI: the dummy_tests job compiles and runs rings-node --features dummy.

Browser tests are unchanged here: the dummy transport is native-only.

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

* test(core): wake every test wait on observed activity, never on a timer (#882, #890)

Core's shared test helpers polled state on timers and decided quiescence
by a 500 ms silence window, and core's browser soak polled every 25 ms.
Every wait now probes state and is woken by a state change.

- tests::activity is a runtime-neutral activity generation (mutex plus
  wakers, no tokio) used natively and in the browser. Waits follow
  mark, probe, then await generation != mark, so no wake-up is lost.
  Activity is recorded by each test node's observer (message
  sent/forwarded/received/stored, lookup started/finished) and callback
  (validated, handled, connection event).
- Node::build(builder) attaches the observer and callback to every test
  node. prepare_node_without_stun is folded into prepare_node, and every
  in-process fixture uses host-only ICE.
- wait_until_result (and its wait_for_* wrappers), wait_for_storage_state
  and connection.rs's wait helpers are activity-woken probes.
- wait_for_msgs drops the silence window. Quiescence is decided by
  message conservation: no handshake, inbound or outbound transfer, or
  queued dummy event on any node, and sum(delivered) = sum(received) over
  the test's nodes (wire messages only).
- Test builds record activity when a transfer permit is released, both
  outbound and inbound (one cfg(test) line in each Drop): the release is
  a state change the predicate reads, and it follows the delivery or
  handling notification. The in-flight test accessors widen to every
  test build.
- File-local polls became probes: the native-transport interleaving
  trace, the continuous-repair convergence, and the chunk-tail "no
  second chunk" checks, which now await the cancelled transfer's
  release.
- Browser: core's wasm fixtures carry the ledger and callback. The
  repair soak's mesh, convergence, repair and trace waits are
  activity-woken, and its stabilization rounds are separated by
  observed mesh quiescence instead of a 25 ms sleep.
- The pending-measure scheduler test waits for admission only: its
  measure never completes, so quiescence is unreachable by design, and
  the old silence window had masked that.

Hang guards remain only as failure bounds. TEST_HANG_GUARD is 5 s in
dummy builds and 60 s in native builds, where real webrtc-rs handshakes
under load exceeded 5 s (#850); no wait is paced by it.

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

* test(node): activity-woken processor waits and a shared test-support crate (#883, #890, #886)

Review follow-up on #889 (M1, L1–L5), plus the user's direction that no
test wait is paced by a timer:

- rings-test-support, a dev-only crate (publish = false), holds the
  runtime-neutral activity cell, the activity-woken probe and
  with_hang_guard. Core and node share one definition of each, which
  resolves the duplicated hang guard from #886.
- ProcessorBuilder gains a cfg(test) hook that chains one extra observer
  after Observability. Every test processor, native and browser,
  records activity through it; the test callback and the controlled
  pump record activity too.
- The node processor helpers are activity-woken probes: processor
  admission, inbound messages, the mutual DHT topology, the registry
  lookup and storage, and peer measurements. The Notify fields and the
  20 ms polls are gone. Hang guards are failure bounds only (5 s dummy,
  60 s real WebRTC). The two real-transport smoke tests document what
  only the real transport checks.
- The pre-stopped listener tests assert completion on the first poll
  instead of within 100 ms: returning before the first stabilization
  tick means waiting for nothing.
- The browser listener test decides "started is still pending" with a
  microtask flush plus Promise.race against a resolved sentinel,
  instead of a 10 ms sleep.
- The dummy seed is per test (base seed mixed with the test's name), and
  the controlled network is torn down before the serial lock is
  released. The rings binary refuses to build with the dummy feature,
  and CI's feature-check step lists its features instead of
  --all-features.

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

* test(browser): activity-woken waits in the dweb and frontend browser tests (#890)

The remaining browser tests outside rings-core and rings-node waited on
timers. dweb connected and retried a fetch every 500 ms. The frontend
onion flow slept 1 s after connecting and retried navigation every
250 ms, and its listener test slept 20 ms before asserting.

- ProcessorBuilder::observer(SharedSwarmObserver) is now public API. It
  chains one extra observer after the processor's own Observability, as
  SwarmBuilder::observer does for a bare swarm, so embedders, and these
  external tests, can observe swarm activity. It replaces the
  cfg(test)-only hook.
- dweb: a host-only test node (build_node_with) records activity. The
  test probes listPeers on activity until A lists B as connected, sends
  the request once, and probes for B's page on activity. The demo app
  keeps its STUN server.
- frontend: host-only ICE. Admission is probed on activity, and each
  failed gateway navigation is re-armed on the next activity after the
  failure rather than on a timer. The listener check flushes the
  microtask queue instead of sleeping 20 ms.
- Both use rings-test-support as a path dev-dependency.

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

* test: count arrivals, stabilise quiescence, relayed-connect smoke test (#889 review round 2)

- M1: conservation now balances deliveries against arrivals counted by
  the receiving transport before validation. A cfg(test) counter is
  bumped in process_event for logical-lane events, so a message the
  receiver drops or rejects still balances its delivery. The
  quiescence fold holds only if the activity generation did not move
  while the nodes were sampled, which is safe on multi-thread runtimes.
  Both preconditions are documented.
- M2: the frontend navigation retry marks activity before each
  attempt, so a change during or after the attempt wakes the next one,
  and it keeps the last error for the guard report.
- M3: test_relayed_connect_over_real_webrtc, a four-node real-webrtc-rs
  ring with one successor and no fingers. It always issues n1's connect
  to n3 from a state with no n1-n3 link (a join-time link is retired
  first) and witnesses the ConnectNodeSend relayed by n2.
- L2: rings-node refuses to build ffi together with dummy.
- L3: ProcessorBuilder::observer documents the non-blocking observer
  contract and that a later call replaces an earlier one.
- L4: the listener lifecycle tests decide "still waiting" with a direct
  futures::poll! of the listener future instead of 20-100 ms sleeps and
  timeouts.
- L5: an unnamed test thread fails instead of sharing a fallback seed.
- L6: rings-test-support gains `within` and, behind its swarm-observer
  feature, the one activity observer used by node, dweb and frontend.
  Its lint table is the workspace's minus clippy::panic, since failing
  tests is its purpose.

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

* docs(node): state the public ProcessorBuilder::observer contract (#889 L1)

User decision: keep ProcessorBuilder::observer public as an embedder hook.
The method now states its contract. The observer is synchronous and
bounded on the send and receive paths. It runs after the processor's own
Observability, which alone feeds /status?view=observability, so that
output is unchanged. Message observations carry no payload, key or
identifier. Lookup events carry a correlation key that exporters must
aggregate or redact. A later call replaces an earlier observer.

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

* test: frame-level conservation, independent progress signals (#889 review round 3)

- H1/M1: quiescence balances frames at the swarm's two chokepoints. A
  cfg(test) FrameLedger counts sending/sent around SwarmConnection::send_data
  and arrived plus an in-flight token at submit_inbound_message entry, released
  at the inbound actor's handoff. Replaces the observer delivered counter and
  the process_event arrival counter; MessageLedger and TestSwarm are removed.
- L1: the relayed-connect smoke test serializes its joins, asserts P0 on both
  sides and asserts both exact paths (send n1->n2->n3, report back via n2).
- L2: the frontend onion retry wakes only on DHT storage writes.
- L3: the stale-stop check polls the restarted listener directly.
- L4/L5/L6: feature-unification, ffi+dummy and soak hang-guard docs.
- zkjoie P2: the registry test looks up once after the decisive storage
  state; control rounds wait on the storage transfer's own progress.
- CI: rings-test-support workspace dependency carries a version for
  cargo-deny's wildcard ban.

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

* test: ordered frame sampling, commit-point send counting (#889 review round 4)

- M1: arrive() raises in_flight before arrived; the quiescence fold reads
  each swarm through sample_swarm, loading sending, sent, arrived, in_flight
  in the ledger's sampling-law order, then the actor and scheduler witnesses.
- M2: a send counts as sent once the transport committed to it (Ok or an
  irrevocable acceptance), so losses below the swarm leave sent >= arrived;
  on_invalid_inbound_frame counts its frame as arrived.
- M3: rings-test-support stays a version-less path dev-dependency, so it is
  stripped from packaged manifests; deny.toml allows wildcard paths.
- L1: CI runs the relayed-connect smoke test in its own invocation.
- L2: control rounds wait for a storage frame after the traced control.
- L3/L4: read-repair wakes documented; the stale-stop check asserts the
  restarted generation's token.
- Fix dead-code under dummy and wasm clippy for the new test helpers.

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

* test: outbound-first sampling, stated injection precondition (#889 review round 5)

- L1: sample_swarm reads the outbound permits before the ledger, since a
  transfer's permit is released only after its sends were counted.
- L2: the control-round predicate doc and the browser soak comment state
  that maintenance controls share the link there.
- L3: swarms_quiescent states that every received frame was sent through
  send_data; tests that inject frames do not wait on it.
- L4: the stale-stop comment claims only what the poll proves; the
  tautological token witness is removed.

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

* test(core): wait on the whole network while an offline peer's message is held

hold_message_for_offline_peer waited for quiescence over node1 and node3
only. In the rejoin test the departed peer still exists, and admission's
successor synchronization can reconnect it while the message is held, so
frames from a node outside the fold broke conservation (CI: sent=45,
arrived=46; locally 12/40 failures). The helper now takes every node of
the test for its waits.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Remove the paced direct-edge throughput bottlenecks (#887, #888) (#900)

* Confirm delivery on buffered-amount events instead of a 300 ms poll (#887)

Both WebRTC backends polled `buffered_amount` every 300 ms to resolve a
send's delivery future, so every awaited send cost about one poll period
and a paced link carried about 3.4 messages/s whatever its capacity.

Each data channel now owns a delivery tracker. A pure registry
multiplexes the channel's single `bufferedAmountLowThreshold` over every
pending end offset: it arms tau(E) = E - min(pending end offset), so
`bufferedamountlow` fires exactly when the earliest pending send has
left the buffer, and each settle round applies the existing flush
predicate to every pending send. A single-flight round arms the
threshold before it reads `bufferedAmount` (the lost-wakeup rule), and a
request that arrives mid-round forces one more arm-and-read step.
`close` and `error` settle every pending send as not delivered. No
timer takes part in a verdict.

The backends only supply the two channel effects (arm, observe) and wire
the three events: webrtc-rs `on_buffered_amount_low`/`on_close`/
`on_error`, and web-sys `bufferedamountlow`/`close`/`error`.

Tests check soundness and one-event liveness over every schedule of six
send/drain operations, and cover concurrent pending sends on one
channel, a flush before registration, an event during a round, closure,
dropped waiters and an abandoned round. They also run awaited,
concurrent and closed sends over a real native loopback link.

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

* Admit paced direct-edge traffic at the rate its protocol supplies (#888)

Core charged every Application message to one per-origin quota of 8
msg/s with a burst of 32. A protocol that paces its direct-edge traffic
and bounds each sending neighbour itself was refused far below its own
admission rate: the onion data plane emits about 98 cells/s under its
per-link budget B/V.

A final-destination transaction is now charged to an OriginQuotaLane.
This is its class lane, except that Application traffic from the
handshake-authenticated neighbour that originated it may use a paced
lane, if the protocol owning the payload registered one. Core takes only
a PacedRate (budget per period, burst = budget) and an opaque
PacedLaneId. It has no vocabulary of the owning protocol. The lane
replaces only the message bucket, whose refill is now rational; the byte
bucket and the record bound stay the Application lane's. All other
Application namespaces keep the configured 8 msg/s default.

- core: OriginQuotaLane::select states the law in one place and
  consults the application only where the lane may apply. EdgeRelation
  is Neighbour only for the authenticated connection of the origin
  itself: a relayed or foreign origin would multiply a per-link rate by
  its path count, and the unsigned relay carrier cannot witness
  "direct". Quota keys store only the lane identity
  (OriginQuotaLaneId). SwarmCallback::paced_lane is the application
  hook. Paced drops are counted in one bounded aggregate.
- node: Protocol::paced_direct_rate declares a protocol's rate. The
  registry installs it under the same write lock as the handler, and
  keeps the lane identity across a replace. Backend resolves an
  envelope's lane from its namespace prefix alone.
- Tests: the selection law over every class, edge and registration.
  On a simulated clock, a sender at 98 cells/s for 30 minutes is never
  refused in the paced lane, while the default Application lane admits
  at most burst + 8 msg/s. A flooding neighbour is bounded by
  B + B*T/V. Paced and class records are independent. End to end, the
  neighbour's own paced traffic is not refused, while a relayed origin
  and an unregistered namespace of the same neighbour are refused in
  the Application lane. Registration installs and replacement
  withdraws the rate.
- Docs: SECURITY.md origin-rate boundary, config.yaml origin_quota.

The TransactionSequenceStale drops seen with this traffic are genuine
reordering beyond the 32-sequence window, not a knock-on of the quota
refusals. The sequence stream is shared across classes, and the
outbound scheduler reorders classes. The window and the ordering are
unchanged here, and the ordering change is tracked in #898.

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

* Settle delivery against one E snapshot taken before b (#887 review)

H1: a settle round re-read E after reading bufferedAmount. A send that
landed between the two reads counted its bytes in E but not in the b
the round saw. So φ(E, b, e) could hold while earlier bytes were still
unacknowledged, a false flush on native. M1: E was also loaded before
begin_step took the registry lock, so a registration in that gap had its
Rerun absorbed against a stale E, which broke one-event liveness.

The round now snapshots E once, under the registry lock, in the step
that arms τ, and settles against that same E. The send path stores E
only after the write has counted the bytes in b, so the snapshot never
over-counts released bytes. A registration's request absorbed by the
step is visible to it through the lock.

- Tests: an enqueue between the read of b and the settlement fabricates
  no flush; a registration during a round re-arms τ against the new E.
  The dropped-waiter test is now driven by events.
- Docs: the registry states both arguments. The saturation comments
  now say saturation cannot occur (τ < b). The abandoned-round comments
  say only a later registration is sure to recover. The drop semantics
  are stated, as is the native meaning of "flushed" (acknowledged by
  the peer's SACK).

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

* Bound paced-lane identity, trust and exposure (#888 review)

- M2: core does not bound a declared rate. The PacedRate,
  SwarmCallback::paced_lane, Protocol::paced_direct_rate and
  SECURITY.md docs state that the rate is trusted local configuration,
  with the authority of raising the Application message quota for that
  namespace's neighbour-originated traffic.
- M3: a namespace receives its PacedLaneId the first time it declares a
  rate and keeps it for the registry's lifetime. Withdrawing and
  re-declaring therefore grants no fresh allowance and orphans no
  records. Exhausted identities are an error, not a saturated shared
  id, and a batch applies all-or-nothing.
- L4: core docs and tests are protocol-neutral (window admission of
  budget per period).
- L5: SECURITY.md states that no protocol on master declares a paced
  rate yet, gives the table bound (4 + k) * max_records, and repairs
  the broken sentence.
- L6: EdgeRelation and OriginQuotaLane::select are crate-private;
  PacedLaneId::get is removed.
- L7: the end-to-end tests assert the lane of the origin's quota
  record, with no clock. They cover the neighbour's own paced traffic,
  an unregistered namespace, a relayed origin, the origin arriving
  over another neighbour, an unauthenticated peer, and the reassembly
  path, and they check that the registry is consulted only for
  eligible traffic.
- L8: the new modules list their imports.

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

* Close the round-2 review lows of #900

- L1: the registry states the precondition of one-event liveness. A
  caller cancelled between committing E and registering leaves tau
  armed low by that send's bytes. Earlier sends are then delayed by at
  most one message's drain, and no verdict is lost.
- L2: the registration-during-a-round test documents that it covers
  the Rerun path, and that a registration between the load of E and
  begin_step is excluded by construction (E is loaded under the lock).
- L3: the unauthenticated-peer case asserts that the frame is admitted
  into the Application lane, not merely that it is not paced.
- L4: the dropped-waiter note says "at least as high", and the merged
  paragraph is split.
- L5: the SECURITY.md paragraphs are reflowed within 100 columns, and
  the two paced-rate entry points are named.
- L6: paced lane identities cover every u32, including u32::MAX, before
  exhaustion is reported. The exhaustion path is tested.

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

* Adopt the #889 test patterns in the #887 and #888 tests

- Transport native loopback tests: the hang guard is rings-test-support's
  with_hang_guard, which names the test that hung. rings-test-support is
  a transport dev-dependency, without the swarm-observer feature.
- Paced-lane end-to-end tests: every identity comes from
  fixed_secret_keys, so runs are reproducible.

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

* Replace the paced lane with delegated admission (#888 revised decision)

The paced lane enforced the rate B/V that the declaring protocol's own
admission already enforces. Without a core ceiling it bounded nothing at
the core layer. Per the revised decision on #888, it is replaced by a
flag with no rate.

- core: admission_delegation replaces paced_lane. An Application
  namespace may declare delegated admission. For a transaction that
  satisfies the unchanged EdgeRelation::Neighbour predicate in such a
  namespace, OriginQuotaCharge::select yields MessageLimit::Delegated.
  The admission then neither needs nor consumes a message token, and is
  still charged to the same Application record's byte bucket under the
  same record bound. Relayed, foreign-origin, local and unauthenticated
  traffic keeps the enforced limits. OriginQuotaKey, the per-lane limits
  and the counters are back to master's shape; OriginQuota::admit and
  the table take the MessageLimit.
- SwarmCallback::delegates_admission and Protocol::delegates_admission
  replace the rate-carrying paced_lane and paced_direct_rate. The node
  registry keeps a set of delegating namespaces, installed under the
  handler write lock. PacedRate, PacedLaneId, lane-id allocation and
  exhaustion are removed.
- Tests:
  - The selection law, and a simulated-clock acceptance test: one
    neighbour's record carries a delegated namespace at 98 msg/s and
    another at 20 msg/s for 30 minutes. The delegated one is never
    refused by the message limit; the other still is, beyond
    burst + 8 T.
  - Delegated traffic is still refused by the byte bucket.
  - Clock-free e2e cases (own traffic, unregistered namespace, relayed
    origin, origin N via neighbour M, unauthenticated peer, reassembly)
    assert the exact stored tokens: message limit skipped (bucket full)
    or charged (burst - 1), and bytes charged (byte_burst - cost).
- SECURITY.md and config.yaml: delegation is trusted local
  configuration, a declaring namespace must enforce its own
  per-neighbour admission, both entry points are named, and it is inert
  on master.

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

* Charge delegated admissions a 16 KiB byte floor (#888 addendum)

H1 (round 4): with delegation, core's own per-message admission work
(two signature checks and a replay-snapshot persist under the replay
lock) was bounded only by bytes. About 50-byte messages would allow
roughly 80k admissions per second, and no delegating protocol can bound
that work, because it runs before the protocol sees the message.

Per the maintainer's addendum on #888, a delegated admission is charged
max(len, DELEGATED_MIN_CHARGE) bytes against the per-origin byte
bucket. The floor is a 16 KiB core constant documented as a generic
bound on per-message work. Under the default 4 MiB/s and 64 MiB burst
it bounds a neighbour's delegated admissions to 256 per second with a
burst of 4096. Messages of 16 KiB or more pay exactly their length.

- Tests:
  - 50-byte delegated messages at 1000/s are bounded by the floor rate.
  - The acceptance test runs the delegated namespace at 98 msg/s of
    16 KiB and is still never refused.
  - Unit and e2e tests check the exact floor and length charges.
  - A small (and an undecodable) delegated payload is charged the
    floor end to end.
  - node pins that a malformed tail after a delegating namespace
    still resolves as delegated (L5).
- L1: the delegation verdict is documented as taken at admission time,
  with a replace-while-queued exposure bounded by the mailbox depth and
  the floor.
- L2: the protocol admits "before its own processing, after core's
  admission".
- L3: SECURITY.md and config.yaml state that delegated and plain
  traffic share the neighbour's Application byte bucket.
- L4: CHANGELOG entries for #887 and for the breaking #888 API.

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

* Close the round-5 review lows of #900

- L1: the docs no longer claim that the byte floor bounds the signature
  checks. It bounds how many delegated messages core admits, and so the
  per-message work that follows admission (the replay-snapshot persist,
  the logical lane, validation and dispatch). The decode and signature
  checks precede the quota and are not bounded by it. Reworded in the
  DELEGATED_MIN_CHARGE doc, the admission_delegation module and
  SECURITY.md.
- L2: the floor-rate test asserts the exact count on its simulated
  clock, floor((byte_burst + byte_rate * t_last) / floor) =
  4096 + 256 * 59.999, so an over-charge also fails it.
- L3: the e2e helpers use u128::from/try_from instead of `as` casts.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Pipeline outbound lanes and key replay by traffic class (#899, #898) (#906)

* Keep a bounded window of transfers in flight per outbound lane (#899)

Each outbound class lane admitted no second transfer until its active
transfer's delivery was confirmed. On native, "delivered" means the
peer's SCTP SACK, which the receiver delays by up to 200 ms when a
message ends on an unpaired packet. So every stop-and-wait frame could
pay that tail, and a lane carried about 14 messages/s on loopback.

A lane now keeps up to OUTBOUND_LANE_WINDOW (8) transfers in flight.
Consecutive messages reach the peer back to back, their SACKs pair, and
the delayed-ACK timer no longer paces the lane. A compile-time assertion
keeps the window strictly below TRANSACTION_REPLAY_WINDOW, so in-lane
pipelining never reorders a class beyond the receiver's replay window.

- queue: each lane is an ordered window of slots (Runnable, Sending,
  Waiting on its delivery) plus the FIFO backlog. Laws, stated on the
  lane:
  - bound: at most W in flight;
  - FIFO: the oldest runnable slot goes first, so transfers start in
    push order;
  - contiguity: a fresh transfer does not start while an earlier one
    still has frames to admit, so chunked transfers never interleave
    on the wire;
  - attribution: slots are addressed by their own token or delivery id.
  Each transfer keeps its capacity permit and delivery future, so the
  capacity bound is unchanged, and cancellation and shutdown settle
  every in-flight transfer.
- The outbound transfer reports whether frames remain after the one
  admitted, from the chunk header.
- Tests:
  - the 13^6-trace model now runs with a window of two and checks FIFO,
    contiguity, the in-flight bound, and that no transfer admits a
    frame while its previous one is undelivered;
  - a unit test pipelines a full window and then waits;
  - the scheduler tests that queue a transfer behind a blocked lane run
    their workers with a lane window of one (a test-only thread-local
    override). One paused delivery then blocks the lane, exactly as
    before, and none of them races the harness's delivery deadline
    under load; the window's own laws are tested in the queue module.
- SECURITY.md: the outbound scheduler section states the window laws.

Native loopback, 512 awaited 16 KiB sends, same test with the window
at 1 and at 8:

  W=1: 36.9 s, 13.9 msg/s, p90 204 ms, 179 sends over 150 ms
  W=8: 0.376 s, 1362 msg/s, p90 1.2 ms, none over 150 ms

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

* Key transaction replay by traffic class (#898)

One sequence stream per (network, origin, destination) was shared by
all four traffic classes, while the outbound scheduler preserves order
only within a class lane and deliberately reorders across lanes. When
DHT control traffic overtook an Application backlog longer than the
32-sequence replay window, the backlog's head arrived below the window
and honest transactions were rejected as TransactionSequenceStale.

StreamKey is now (network, origin, destination, class). The class is
the class of the message the transaction carries, derived from its
signed data on both sides (Transaction::class), so it adds no wire
field. Order is claimed only within a class. A lane keeps at most
OUTBOUND_LANE_WINDOW (8 < 32) transfers in flight (#899), so an honest
sender's per-class transactions stay within the window, however the
lanes are interleaved. This assumes a class's transactions reach the
scheduler in signing order, as they do from one sending task.

- Sender: PayloadSender's originate and send methods take a Message,
  and reserve_transaction_sequences takes the class, so a sequence is
  reserved in the stream of the message it signs. A probe completion,
  carried inside its offer and never admitted on its own, takes its
  sequence from the offer's stream.
- Receiver: admission keys by Transaction::stream_key, which now
  returns a Result because it decodes the class.
- Persistence cutover (maintainer's decision, option a): the snapshot
  moves to rings-core:transaction-replay:class-streams, and the first
  load deletes rings-core:transaction-replay unread. That resets every
  replay window once, as deleting the store does. A failed deletion
  fails closed and is counted.
- Rollout: the wire format is unchanged, but replay is not
  interoperable across the upgrade, so the release is a mandatory
  network-wide upgrade, seeds included. This is stated as breaking in
  the CHANGELOG. The version bump is left to the release.
- Tests:
  - The law over 200 seeded cross-class interleavings, with in-lane
    reordering inside the lane window: per-class streams never reject
    an honest transaction, while the same arrivals on one shared stream
    do.
  - End to end over the dummy transport: a 48-transfer Application
    backlog is overtaken by DhtControl probes with zero stale drops.
    With the class removed from the key, the same test fails.
  - The cutover: the old key is deleted unread, and a failed deletion
    fails closed.
- Docs: SECURITY.md transaction replay boundary and
  docs/transaction-replay.md.

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

* Pin class lanes to channels; make the delivery deadline progress-based (#906)

Review of #906, M1 and M2.

M1. A frame's delivery deadline measured the time since its own admission,
so the frames queued behind the head of a full window on a slow link
expired although the link kept delivering. The deadline now measures a
stall: a frame expires once `now - max(admitted, last_settled) >= D`, the
last confirmed delivery on the connection. The pure law is
`delivery_stalled`; the worker tracks `DeliveryProgress` per peer. The
test-only W=1 lane-window override is removed and the scheduler tests run
at W=8, filling the window through a paused worker and held deliveries.

M2. The transport rotated every send over the pool's data channels, so a
receive handler stalled on one channel let later frames of the same class
overtake it on the others. The pool now pins a lane to a channel,
`channel(lane) = pool[lane mod |pool|]`, and core sends DHT control (and
link control), storage, E2E and application on lanes 0..3.
`send_message_with_permit` takes a `ChannelLane`; `RoundRobin`/
`RoundRobinPool` become `LanePool`/`ChannelPool` and
`RoundRobinPoolEmpty` becomes `ChannelPoolEmpty`.

A native two-node test stalls the receiver's Application `on_validate`:
control proceeds on its own channel, and the held backlog (twice the
replay window) arrives afterwards in send order with no stale verdict.
Rotating Application frames over the channels fails it.

L3: the lane-window docs state that a chunked message still pays the
per-frame delayed-ACK tail and holds its lane's wire.

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

* Bound class-keyed replay by class count; retire the old key best effort (#906)

Review of #906, M3, L1, L2 and L5, with the M2 law in the replay docs.

M3. `TRANSACTION_REPLAY_STREAM_CAPACITY` is now `MessageCategory::COUNT *
4096`: every class stream of 4096 account-destination pairs. A full
snapshot encodes to at most `TRANSACTION_REPLAY_SNAPSHOT_MAX_BYTES`
(20,643,852 bytes, new export), derived from the per-record maxima that a
test pins. The native daemon's replay file store grows from 16 MiB to
32 MiB so a full snapshot fits it (a test asserts it); at 16 MiB the store
budget, not the stream bound, would have failed first.

L1. A failed deletion of the pre-#898 snapshot key no longer fails every
reserve and admit: the key is never read, so the failure is counted and
logged and admission continues. The load that finds no class-stream
snapshot persists the empty one, so no later start touches the former key.

L2. The module doc names the class in the stream identity, states the
in-class order law (lane FIFO, window below the replay window, one pinned
channel per class), the snapshot bound and the one-time cutover.

L5. The `arrivals` test uses `try_from` instead of `as` casts.

SECURITY.md, CHANGELOG and the replay docs cover lane pinning (a new
breaking entry for the transport API), the progress-based deadline, the
chunked delayed-ACK tail, the capacity and snapshot bound, and the
best-effort deletion.

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

* Qualify in-class order for held frames; fix stall-test doc and line widths (#906)

Review round 2 of #906, L1 to L3.

L1. A frame held on a delegation-reference miss is released in a task of
its own, independently of the later frames of its lane, because the session
link promises no order among held frames (delegation-references law: a
frame that resolves is never queued behind one that does not). Later frames
of its class may therefore reach replay admission first, and a held frame
released after a window's worth of them is rejected as stale. Forcing lane
order on release would contradict that law, so the in-class order law is
qualified instead, in the replay module doc, `StreamKey`, SECURITY.md, the
replay chapter and the delegation-references chapter: it covers the frames
that resolve on arrival, and a held frame can be lost as stale (counted),
never falsely admitted.

L2. The stall test's doc now says it discriminates through its control and
order checks; the stale count stays zero even under rotation, since the
single Application actor holds every channel's handler after one frame.
The test is renamed `..._keeps_its_order` accordingly.

L3. Lines over 100 characters added by this PR are rewrapped (SECURITY.md,
the replay module doc, the replay chapter, rings.rs), and the pool test
returns a `Result` instead of calling `unwrap`.

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

* Persist transaction replay per stream instead of per snapshot (#906)

Review of #906, M-a (performance). Every admission rewrote the whole
replay snapshot under the replay lock, so the cost of one admission grew
with the number of streams retained: about 1.2 MB per admission at 1000
streams and up to about 20 MB at the 4 x 4096 capacity, which would cancel
the throughput #899 gains and, in browsers, write a large IndexedDB value on
the UI thread.

The store now keeps one record per stream, under
`rings-core:transaction-replay:stream:{sender|receiver}:<hex key>`. A
transition writes only its own stream's record, at most
`TRANSACTION_REPLAY_RECORD_MAX_BYTES` (1161 bytes), and commits it to
memory only after the write succeeds.

- `replay::store` is the pure codec. `restore` rebuilds the tables from
  every record and fails closed on a record that does not decode, is not
  under its own key, holds an invalid window, or exceeds a table's
  capacity. The pre-#898 shared-stream snapshot is reported without being
  decoded.
- A load that finds that snapshot deletes it (best effort: counted, logged,
  retried by the next load until it succeeds).
- `ReplayStorage` stores the opaque `ReplayRecord`, which replaces
  `ReplaySnapshot`. New exports: `TRANSACTION_REPLAY_RECORD_MAX_BYTES`,
  `TRANSACTION_REPLAY_STORE_MAX_RECORDS` (32,768) and
  `TRANSACTION_REPLAY_STORE_MAX_BYTES` (28,295,168). A test pins all three
  exactly against maximal encodings.
- Both default stores evict beyond their limits, and an evicted record would
  reopen replay for its stream. They are sized never to reach them: the
  native file store's budget is 40 MiB (a full store plus the former
  snapshot, asserted by a test) and the browser IndexedDB row capacity is
  the record bound plus one.

The storage format already changes in this release (#898), so this adds no
further upgrade. Docs reference #908 where the in-class order law is
qualified. The exhaustion of the never-evicting table by free Sybil origins
is tracked separately as #907.

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

* Qualify the replay fail-closed law for native storage; note the replay lock (#906)

Review round 3 of #906: M1 (docs), L1 and L2.

M1. "Every record must decode or the store fails closed" holds only for the
records the storage returns. The native file store retires a file whose
`(key, record)` framing does not decode before `restore` sees it, and it
writes without fsync, so a torn file left by a crash is dropped and its
stream forgotten, which reopens replay for that stream. Only corruption of
a record's inner bytes reaches `restore` and fails closed. The law is
qualified in `replay::store`, SECURITY.md and the replay chapter, with
durability and surfacing tracked in #909.

L1. `TransactionReplay` and the replay chapter note that one mutex
serializes every replay transition and is held across the record write, so
store write latency bounds the node-wide transition rate; a per-stream lock
would let writes of different streams overlap.

L2. The replay chapter no longer names `ReplaySnapshot`, and the rings.rs
budget test doc speaks of a full store rather than a snapshot.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Keep bad replay records and fail closed on their stream only (#909, #910) (#914)

* Flush the replay store's writes and keep its undecodable records (#909)

Native FileStorage wrote each record to a temporary file and renamed it
without fsync, and deleted any record whose (key, value) framing did not
decode on the read that found it. A power loss after an admission could
tear a stream's record; the next load deleted it, the stream restarted
from First, and an unexpired transaction could be admitted again.

- FileStorage gains a RecordAuthority, fixed at open:
  - Disposable (new_with_cap_and_path, unchanged): no flush, and an
    undecodable record is retired and reported absent;
  - Authoritative (new_authoritative_with_cap_and_path): put flushes the
    temporary file before the rename and the directory after it, and
    removals flush the directory; an undecodable record is reported as
    Error::StorageRecordUndecodable and kept.
- UndecodableRecord names the record by its file and, when its key
  prefix decodes and hashes to that file, by its key. A record truncated
  to nothing is named by its file alone.
- The native daemon opens the replay store as authoritative. It is the
  only FileStorage holding replay state; the DHT, measurement, evidence
  and onion entry-guard stores stay disposable.
- Tests: a torn record and an empty record are reported (with and
  without the key) and stay on disk across a reopen; a key prefix that
  hashes to another file is not reported as the key; authoritative
  writes, rewrites and removals round-trip through a reopen.
- store.rs, SECURITY.md and the replay chapter replace the #906
  qualification "a torn native file is dropped and its stream's replay
  reopens" with the flush and report behaviour; the node-wide failed
  load that remains is #910, fixed next.

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

* Fail a bad replay record closed on its own stream, restoring once (#910)

A record that failed to restore left the replay tables unloaded. Every
later reserve and admit then re-read the whole store (up to 32,768
records) under the global replay lock and failed again, so one corrupt
record took the node offline, and the only recovery was deleting the
store, which resets every replay window.

- Storage: KvStorageInterface gains scan (every record, decoded or
  reported as an UndecodableRecord named by record_name of its key, and
  deleting nothing) and record_name (the key by default), both with
  defaults. FileStorage names records by file (hex SHA-1 of the key) and
  scans without retiring, whatever its authority; IdbStorage reports an
  undecodable row by its key.
- store.rs: restore is pure and total. Each scanned record either
  restores its stream or joins U, the unrestorable records by record
  name (Undecodable, NotAStream, InvalidWindow, Misplaced). Laws, in the
  module docs:
  - fail closed per stream:
    unavailable(table, key) <=> name(record_key(table, key)) in U;
  - pointwise: a record in U changes no other stream;
  - bounded slots: each record in U counts against both table bounds,
    so no new stream takes the store past its record bound.
  The whole-store capacity failure at restore is gone; a new stream is
  still refused at the bound.
- replay.rs: the restored ReplayStore (tables and U) is cached. An
  unavailable stream refuses reserve and admit with
  Error::TransactionReplayStreamUnavailable { key, record }, before any
  quota charge. ReplayCounters gains unrestorable_record (a gauge set at
  load) and unavailable_stream (refusals). Each unrestorable record is
  logged once at load. A scan that fails as a whole is not cached: it
  is counted, and the next call scans again. Restore is O(store) once;
  each later call reads nothing and writes one record.
- Recovery needs no new API: with the node stopped, remove the one
  record the refusal and the log name (the native file in the
  transaction-replay directory, or the browser row), then restart. That
  resets that stream's window alone.
- Tests:
  - pure restore: each failure class marks only its own slot;
  - an undecodable record fails closed exactly the stream it is filed
    as, and an undecodable shared snapshot is still recognised;
  - bounded slots;
  - runtime: one corrupt record refuses only its stream, and later
    calls never scan again;
  - clearing the record and restarting restores the stream;
  - end to end: a torn authoritative FileStorage record;
  - a browser (IndexedDB) test of the same refusal and recovery.
- SECURITY.md, the replay chapter (new "Bad records" section with the
  clearing procedure) and the CHANGELOG. The browser store's durability
  hint is tracked in #912.

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

* Address the #914 review round 1 (#909, #910)

H1: one unreadable record file no longer fails the whole scan.
- FileStore::read_records keeps each file's read result. A scan reports
  a file it cannot read (any error but absence: EACCES, EIO, a
  directory in a record's place) as an UndecodableRecord named by its
  file, so it joins U and fails only its stream.
- An authoritative open indexes an entry whose metadata fails, at zero
  bytes, instead of aborting.
- Tests: unreadable file and directory entries are scanned one by one;
  an authoritative open survives unreadable metadata; end to end, a
  directory in a native record's place refuses only its stream, with no
  persistence failure.

M1: the flushed store's cost is measured, and the I/O is off the async
workers.
- FileStorage is now a handle on an Arc<FileStore>, and every operation
  runs its file I/O on tokio's blocking pool.
- test_durable_throughput_of_replay_admissions (ignored) measures it.
  On an Apple M1 Max SSD (APFS, F_FULLFSYNC): 53-57 admissions/s (p50
  ~17 ms), against ~4,400 unflushed. Documented in the replay chapter,
  SECURITY.md and the CHANGELOG.
- Group commit is filed as #916. Linux is not measured yet.

M2: the laws say "torn, corrupt or unreadable", not "lost".
- An absent record restarts its stream from First; missing-record
  detection is #915.
- The durability law is stated for unix only.
- An authoritative open flushes the parent of every directory it
  created and of the root.

M3: a corrupt sender record refuses reserve on its stream. This test
fails when the sender-side refusal is removed.

M4: SECURITY.md no longer says a misplaced record invalidates the
store.

Lows:
- L1: an authoritative store evicts nothing. A put beyond the budget,
  or an open under a lowered one, fails with the new
  Error::StorageBudgetExhausted.
- L2: the pointwise law is restricted to streams in the store, with a
  capacity term for new streams.
- L3: scan and record_name move to a new KvStorageScan trait with both
  methods required, and its agreement law is stated. KvStorageInterface
  is unchanged, and ReplayStorage boxes a KvStorageScan.
- L4: the recovery steps name the IndexedDB database and object store,
  and cover closing the tabs and the DevTools path. They also state the
  Fork evidence and message loss when a sender stream is cleared.
- L5: FileStorage::record_name is documented.
- L6: the F_FULLFSYNC mapping of sync_all is documented as a
  precondition.

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

* Address the #914 review round 2 (#909, #910)

M1: replay transitions are whole. `reserve` and `admit_with_quota` now
take `&Arc<Self>` and hand the transition (lock, persist the record,
update the table, unlock) to `rings_runtime::run_detached`. Cancelling
a caller mid-persist therefore abandons only the wait. Before, a
cancelled caller released the mutex while its detached record write
still ran, so the next transition could compute from a table without
that write, and the stale write could land after it on disk.
- Tests: test_whole_transitions drives a gated store deterministically
  (Notify, no timing). A cancelled admission and a cancelled
  reservation complete before the next transition of the stream, in
  this run and after a restart. Both tests fail without the detach.
- TransactionReplay::new (test only) returns the Arc the transport
  holds.

M2: rings-runtime gains a fallible run_blocking. On native it runs
blocking work on the runtime's blocking pool, and ownership passes at
the call. Outside a runtime it returns Unavailable, and when the work
panics it returns Abandoned. FileStorage uses it, and
Error::StorageWorkUnscheduled replaces the panic path. It has three
tests.

Lows:
- L1: an authoritative open flushes the root and every ancestor. An
  empty relative ancestor is flushed as ".".
- L2: a directory listing that fails part-way fails an authoritative
  open. A disposable store still skips the unlisted entries. The index
  law states both.
- L3: count runs on the blocking pool.
- L4: blanket Arc impls of KvStorageInterface and KvStorageScan. The
  Shared* test newtypes are gone, and the remaining wrappers forward
  record_name.
- L5: read_file_if_present replaces three copies.
- L6: the MemStorage and IdbStorage KvStorageScan methods are
  documented.
- L7: a TempRoot drop guard removes each test's directory even when an
  assertion fails. The permission-based tests are split and say
  "skipped" when the process reads through permission bits; the
  directory case always ass…
* Bump rustls to 0.23.46 for RUSTSEC-2026-0346..0349 (#956)

rustls 0.23.45 is affected by four new advisories (session Debug leaks
TLS secrets, ECH-rejection data leak, unbounded Brotli certificate
decompression, ServerHello key-exchange downgrade), which fail the
Dependency policy gate on every PR. 0.23.46 fixes all four; the bump is
lock-only and semver-compatible.

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

* Admit rustls 0.23.46 through the cooldown gate (#956)

The release is younger than the 2880-minute cooldown, and every older
compatible rustls is affected by RUSTSEC-2026-0346..0349, so the
advisory and cooldown gates cannot both pass without an exception. The
exact-version exception replaces the one for 0.23.45, which is no longer
in the graph, following the precedent set for RUSTSEC-2026-0285.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
`admit_with_quota` read the quota instant before the detached
transition took the `state` lock. Two admissions on one quota key could
read `t1 < t2` and lock in the order `t2, t1`; the second then saw
`now < last_refill` and `OriginQuota::refilled` refused a legitimate
message with `MonotonicTimeRegressed`. The gateway onion-loop fixture
hit it while connecting edges in about 1% of local runs.

`admit_at` now takes the clock instead of an instant and reads it just
before `quota.reserve`, inside the critical section. Law (Clock under the
lock): the instants the quota table receives follow the order in which
transitions hold the lock, so a monotonic clock gives every record a
non-decreasing sequence and the regression is unreachable. Tests still
inject instants through the clock.

`test_quota_clock_is_read_under_the_admission_lock` creates two
admissions in the order A, B and lets B lock first; with the clock read
before the lock it fails, under the lock both commit and the clock is
read in lock order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on (#954) (#959)

…on (#954)

A crossing offer that meets this node's offer while it is still
`Creating` (or a sent offer losing the tie-break) makes it yield. The
yield was two lifecycle transitions: `withdraw_pending_in` released the
attempt, and `open_answer` reserved the answering generation later. In
between the peer's slot was vacant, so when the yielded offer's ICE
gathering failed ("Local connection closed"), `lost_slot` read no record
and reported `ConnectionAttemptCancelled` instead of
`ConnectionAttemptSuperseded`. Which error a caller saw depended on
scheduling; the gateway fixture failed this way in about 6% of runs.

The yield is now one transition. `replace_pending_in` applies
`Pending(attempt, observed) -> Pending(answering, Answering)` under the
lifecycle boundary iff the observed signaling state still holds, and
`yield_pending_in` then closes the yielded transport. Reconciliation
returns `IncomingOfferSlot::Yielded(answering)` and `open_answer` opens
the transport of that reserved generation instead of reserving again.
Law (Atomic yield): the slot is never vacant between the yield and the
answer, so a yielded offer's failing step is classified
`ConnectionAttemptSuperseded` by construction. One pending record
replaces another, so no bound is consulted. `Law (Decided state)` is
unchanged: a yield decided on a state the attempt has left is refused.

`test_yielded_offer_is_superseded_before_the_answer_is_created`
classifies the failure right after the yield, before any answer exists;
with the old two-step yield it reports `ConnectionAttemptCancelled`.
The registry test now checks that the answering generation takes the
slot in the same transition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#955)

The onion-loop gateway test failed with "gateway reset captured flow"
while the status read `Degraded`, `reason: None`, `active_flows: 0`,
and no log line said why. Two holes let that happen.

A degraded status had no reason: the exit availability and its reason
were separate `Option`s, and the initial `Unknown` carried none. Exit
service is now one `ExitObservation` in which `Unknown` and `Unavailable`
own their reason, and discovery starts at a pending observation that
names itself. Law: `reason` is `None` exactly when the exit is
available, and an active gateway is `Degraded` exactly when it is not,
so a degraded status always names its cause.

A reset had no reason, and one reset was not even seen as a reset.
`fail_flow` now takes a `FlowResetReason` (handshake expiry, idle
timeout, client reset, ungraceful close, Onion open or stream failure,
closed bridge, gateway stop), logs it at info, and records it as
`GatewayStatus::last_flow_reset`. A closed endpoint is classified from
facts the stack records: an RST that closed it, or the time since the
last client segment against the idle timeout.

The reason-less failure is the second hole. When the Onion stream ended
before any response, the gateway half-closed the client direction with a
FIN; the test client never answers, so smoltcp's idle timeout reset the
endpoint 30 s after the request. The runtime read "client finished" as
`!may_recv`, which also holds for a closed socket, so it forwarded a
client EOF of its own making and then took the timed-out endpoint for a
graceful close: no reset path ran. `client_finished` now holds only in
the states entered by the client's FIN, so that timeout is reported as
`IdleTimeout`. A runtime test drives exactly this sequence; it fails
with `last_flow_reset: None` on the old predicate.

The Onion EOF before any response is a reordering: traced on develop
before the master merge (b80604d), the exit sent backward sequence 1
`Data(381)` then 2 `Shutdown`, and the client received 2 before 1, so
the stream ended and the late data was dropped. There core ran the
application callback inline in concurrent inbound lane tasks. Since
#941 every hop preserves a circuit's order by construction (one channel
lane per category, #906; namespace dispatch in arrival order; FIFO link
outbox), and the mode did not occur in 1268 runs that reached the
gateway, 838 of them under three parallel loops. The fixture's
remaining edge-time failures are #953 and #954.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…965)

The application egress documents that its caller learns whether a
payload left, but `Egress::do_send_payload` discarded the outcome of
`send_payload_with_outcome` with `.map(|_| ())`. A transfer the waiting
discipline cancelled before acceptance (its generation superseded, its
readiness, route or scheduler revoked) therefore returned `Ok` from
`Swarm::send_message` and `Swarm::send_direct_message`, and from the
connection-offer send of `connect`. The caller could not tell a payload
that left from one that did not, and could not retry it.

`payload_left` now maps the outcome: `Succeeded` is `Ok(())`, `Cancelled`
is the new `Error::OutboundTransferCancelled { peer }`. By lemma (P) of
`error::send_class`, a detached `Cancelled` observed by a caller means no
frame was ever claimed, so the variant is `Deferrable(LinkChange)` and
the send class keeps its law `Deferrable ⇒ PreAcceptance`. Protocol
senders only ever see `Succeeded`, so their contract is unchanged. A
cancelled connection offer now abandons its pending generation and
reports the error, instead of reporting an offer that never left.

The onion circuit relies on this (#963): a relay can keep a cell only if
it learns the cell did not leave.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Onion circuits treated send_direct as a reliable FIFO, but the
application egress is reliable only within its admission budget: a
relay discarded a refused cell silently, the stream had no loss
detection, and the 30 s smoltcp idle timer fired first, ending the
captured gateway flow with an untyped IdleTimeout.

Law (Circuit fate): every cell of a circuit is delivered, or both
endpoints observe the circuit's destruction. The failure is made
symmetric at the link:

- the extension backend forwards PeerRetired to namespaces in order
  with their envelopes; the circuit reducer destroys every circuit on a
  retired link away from it and tears down local endpoints on it;
- a Destroy cell is carried hop by hop and is exempt from the outbox
  bounds, so a full lane cannot swallow it;
- a send refused before acceptance is retried while the peer stays
  admitted, so a refusal is a delay, not a loss;
- an outbox overflow destroys the circuit at that relay (CellRefused)
  toward both neighbours;
- an endpoint stream that aborts for a local cause sends Destroy to its
  adjacent hop; the pump reports a typed TcpStreamEnd;
- the gateway receives the abortive end through OnionStreamEnd and
  resets the flow with FlowResetReason::StreamReset naming the cause.

test_captured_flow_model checks the protocol exhaustively over 2..=5
links: without a failure every execution delivers (R1); the previous
protocol lets one link failure end in an untyped reset (R2); circuit
fate types every execution with one or two failures (R3); bounding
Destroy reintroduces an untyped reset, so the exemption is necessary
(R4). The model and its code correspondence live in the test's doc.

Namespace queue drops are tracked separately in #966.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…) (#961)

`test_processor_connect_with_did` hung in CI after both admissions
committed. The two crossing `connect_with_did` calls meet: p3's offer,
still gathering ICE candidates, yields to p1's and its connection is
closed. Native WebRTC then fails the gathering with "Local connection
closed", which core classifies as `ConnectionAttemptSuperseded` and
`connect` treats as satisfied. The browser backend instead waited for
`icegatheringstatechange` to report `Complete`, and
`RTCPeerConnection.close()` fires no event, so p3's connect waited the
full 60 s gather timeout and the 20 s hang guard fired first.

Law (Gathering ends): the wait ends when gathering completes, when the
connection is closed locally, or at the timeout, whichever comes first.
`close` now wakes a `local_close` latch, `webrtc_gather` waits on either
notifier (`Notifier::either_notified_within`), and a gathering of a
closed connection fails as `Local connection closed`, the native error.
The handler is also read after it is installed, so a gathering already
complete is never missed.

`test_local_close_ends_a_gathering_in_progress` closes a connection
whose gathering is held open by an unreachable STUN server; on the old
code it hits its 20 s hang guard, now it ends at once.

A yielded offer is reported as `Superseded`, not `Cancelled`, only when
the yield leaves no vacant slot, which #954 (PR #959) makes so.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…) (#968)

peers() and listPeers report a listed connection's raw WebRTC state.
Core readiness admits and routes a connection whose data channel is open
in Connecting or Connected, and a browser may fire the data channel's
open before connectionstatechange reports connected. An admitted,
listed peer can therefore still read Connecting, and the browser tests
that asserted the exact string Connected failed whenever the events
arrived in that order.

The tests now assert what admission guarantees: exactly one listed
entry, in a WebRTC state readiness counts as routable. The predicate
is_routable_webrtc_state states the law; peers() documents that its
status may read Connecting for a routable peer.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…cuits (#966) (#970)

A namespace queue that refused an envelope only counted the drop. For an
onion circuit that is a cell lost at the receiving hop after its send
was accepted: no sender learns of it, so it fell outside Law (Circuit
fate), and since the onion TCP sequence window only guards replay, a
lost Data cell silently removed bytes from the stream.

A refusal from an admitted peer now becomes a fact about that link:

- the extension's link facts are one path: LinkFact { Retired, Dropped }
  replaces the retirement-only hook (Protocol::link_fact,
  Route::observe_link, Queued::Fact); facts are queued in order with
  envelopes, outside the bounds, and never dropped;
- Dropped(p) is coalesced: at most one per peer waits in a queue, and
  only admitted peers make one, so facts stay bounded by admissions;
- the onion reducer destroys every circuit on that link toward both
  neighbours (destroy_link), since the link is up and its far side has
  not observed the loss, and tears down the local endpoints on it;
- an endpoint ended by a drop aborts as TcpStreamAbort::CellDropped, a
  cause that originates here, so it sends Destroy to its adjacent hop;
- a teardown's cause is recorded beside the stream's inbound lane
  (TcpStreamFate) instead of being queued in it, so a full lane can no
  longer lose it; the pump reads it once the lane closes, behind the
  frames already queued.

test_captured_flow_model gains the action Drop(i, d), of any cell
including Destroy. R3 now covers drops: up to two failures of any kind
never end the flow untyped. R5 (CircuitFateSilentDrops) shows that a
drop must be observed where it happens; R6
(CircuitFateDropsDestroyAway) shows that it must destroy the circuit
toward both neighbours. The core application inbox's drops are the
remaining excluded site, tracked in #969.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…972)

A full onion TCP inbound lane made send_client_inbound and
send_exit_inbound remove the stream. Its pump then saw the lane close
without a recorded cause and ended Aborted(RemoteClosed), a cause that
does not originate here, so no Destroy was sent: the far endpoint never
learned that its frames were being discarded, outside Law (Circuit
fate).

An overflow now records TcpStreamAbort::InboundOverflow as the stream's
fate before its record is removed (end_stream, shared by
end_client_stream and end_exit_stream). The cause originates here, so
the pump, once it has applied the frames already queued, ends with it
and sends Destroy to its adjacent hop. In the #963 model this is a Drop
at an endpoint, which R3 already covers. The model's correspondence
table now lists the lane under Drop, and two rows #970 left naming the
renamed peer_retired and destroy_adjacent are corrected.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
)

The core application inbox dropped a live message it had no room for
and only counted it. For an onion circuit that is a cell lost at the
receiving hop after its send was accepted, outside Law (Circuit fate),
the last such site the #963 model excluded.

- SwarmEvent::MessageDropped { peer }: deliver reports Delivered::Dropped,
  and its caller announces the fact under the lifecycle boundary iff the
  message's origin is admitted (announce_message_dropped), so the fact is
  read between the peer's admission and its retirement; the inbox, a
  leaf of the lock order, never takes that boundary itself.
- Inbox laws: a drop is told once while one waits after the peer's last
  admission or retirement, so a waiting drop of an earlier generation
  never absorbs a later one; a retirement that withdraws an unread
  admission withdraws the drops after it. Facts stay bounded:
  |facts| <= 4 * |peers|.
- The node forwards the fact to every namespace as LinkFact::Dropped
  (submit_link_fact, coalesced with the namespace's own drops through
  Held::mark_dropped); the onion protocol then destroys every circuit on
  that link toward both neighbours, as for a namespace-queue drop (#966).

The model's correspondence table now maps the core inbox to Drop and no
longer lists it as excluded; SECURITY.md and the transport flow-control
chapter describe the fact.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Order IndexedDB LRU eviction by a store-wide logical clock (#853) (#856)

Eviction ordered rows by wall-clock milliseconds made monotone only per
row, so rows touched within one timer tick could tie and the index broke
the tie by key, evicting a recently read row (seen in Firefox, whose
timers are clamped).

Each database now keeps one access clock in a companion object store.
Every put and every get hit takes the next clock value as the row's
`access_stamp` and advances the clock in the same read-write transaction.
Law: k accesses receive k distinct, strictly increasing stamps whatever
the timer resolution; no timer is read. The clock stops with an error at
Number.MAX_SAFE_INTEGER instead of repeating a stamp, and `clear` keeps it.

The schema moves to version 2. A version-1 database is migrated in place
on first open: the upgrade adds the clock store and the `access_stamp`
index and drops the wall-clock indexes, then one transaction restamps
every row 0..n in its former eviction order (last_visit_time, then key)
and sets the clock to n. The clock record marks a finished migration, so
an interrupted one reruns from the untouched rows.

Browser tests cover the tick law, the migration order and its rerun, and
bursts of back-to-back accesses with exact eviction; none waits on or
reads wall-clock time.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Deliver toward a node DID without overshooting it (#865, #873) (#876)

* Deliver toward a node DID without overshooting it (#865, #873)

Replies routed by DID cycled on an unconverged ring until MAX_RELAY_HOPS
dropped them (#865). The root cause (#873) is that delivery to a node
reused the owner lookup: find_successor answers Local(head) with head past
the key, which is right for "who owns position k" and wrong for "next hop
toward node T". A hop that did not know T handed the payload past it, and
greedy routing from the far side circled among the nodes whose views skip
T.

- dht::delivery: a dedicated delivery step over a carrier stage
  (aim, handed_off). Greedy hops forward to the known peer on (n, aim]
  nearest the aim, so dist(., aim) strictly decreases and no route can
  cycle. When no known peer lies on that arc, the hop hands off once to
  its successor head and marks the carrier; the receiver delivers over a
  direct link or fails fast with RelayDestinationUnreachable, and never
  routes greedily again. A route ends within 2(|V| + 1) + 1 hops in any
  views, so RelayHopBudgetExhausted is unreachable on the delivery path.
- find_successor keeps its owner-lookup semantics; its closest-preceding
  scan now covers successors as well as fingers.
- Transaction::reply_via (signed, in the transaction hash): while a node
  has no predecessor, i.e. no node is known to route to it, its requests
  name its successor head. Reports start in stage (via reply_via) and
  return through that peer, which hands them over its direct link. This
  keeps joins working under fail-fast: a joiner is known only to its
  bootstrap until its ring neighbours learn it.
- Checked model (dht::delivery::tests): exhaustive over every knowledge
  assignment of a 4-node ring crossed with off-view links and reply_via
  choices, plus a fixed-seed randomized pass; asserts linked hops, no
  overshoot, at most one crossing per aim, no repeated state, the hop
  bound, converged => delivered, the join-window law, and the #865
  topology regression.
- Docs: relay module, Transaction return-address law, SECURITY.md Layer
  Contracts, docs chord.md, MAX_RELAY_HOPS, Chord trait, find_successor.
- Version 0.32.0: the carrier stage and reply_via change the wire format.

Closes #865
Closes #873

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

* Address review round 1 and CI on #876

- One origination path: PayloadSender::originate is the only constructor
  of a locally originated request. It decides the first hop and the
  signed reply_via from one topology snapshot
  (dht::delivery::origination). SwarmTransport::signed_payload is removed
  and its callers (finger fixes, ProbeRequest, storage sync, manual
  offer/answer) use it, so a node without a predecessor names its
  successor head on every request.
- Bounded reflection: only FindSuccessorReport and ConnectNodeReport
  honour reply_via (Message::returns_through_reply_via); every other
  report routes straight to the origin.
- find_successor is master's version again (fingers only). The successor
  list may name peers a stabilization report introduced before any link
  exists, which made storage sync pick an unlinked next hop
  (SwarmMissDidInTable in the sync-storm replay). Delivery scans
  (succ ∪ fingers) ∩ Linked instead, and hands off to the first linked
  known node.
- Derived hop bound |V| + 2 (one greedy run, one handoff, two terminal
  deliveries); the "budget exhaustion is unreachable" claim is replaced
  by its premise (|V| + 2 > MAX_RELAY_HOPS only with a sparse finger
  table). reply_via is disclosed whenever a node has no predecessor, not
  only while joining. Docs, SECURITY.md and CHANGELOG follow.
- One form of each API: MessageRelay::{new, forward} take a NextHop,
  new_send_with_sequence takes the hop and reply_via; NextHop::toward
  names a fixed first hop; RouteStage no longer derives Default.
- Model: views may name unlinked peers; the always-true crossing counter
  and a duplicated assertion are removed; the bound |V| + 2 is asserted.
- rustdoc: fix intra-doc links in dht::delivery and a private link.

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

* Move dht::delivery to delivery/mod.rs

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

* Test converged and unconverged delivery separately

The delivery model now states what each regime guarantees instead of
one shared outcome assertion.

- tests/mod.rs: the shared overlay model, the safety laws every overlay
  obeys (Linked, NeverOvershoots, Acyclic, Bounded, Exact), and the
  per-stage unit laws; converged views now carry their predecessor.
- tests/converged.rs: on the Chord fixpoint every route is delivered by
  greedy hops alone (no handoff), every non-delivering hop at least
  halves the remaining distance (so a route takes at most
  ceil(log2 dist) + 1 hops), and no node names a reply_via.
- tests/unconverged.rs: one test per kind of stale view, each with its
  own expected outcome: a stale successor is routed around (#865 from D
  and C), an unknown destination fails fast after exactly one handoff
  (#865 from A), a joiner's answers arrive through reply_via while
  unhinted routes fail fast, unlinked view peers are never hopped to,
  and arbitrary views (exhaustive 4-node, fixed-seed random) satisfy the
  safety laws.

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

* Name the delivery test model and its files with the test_ prefix

delivery/tests/ becomes delivery/test_model/, and converged.rs and
unconverged.rs become test_converged.rs and test_unconverged.rs.

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

* Self-review of #876: linked reply_via, delivery placement, one constructor

- reply_via names the nearest *linked* successor. A successor head can
  be a candidate a stabilization report introduced before any link to
  it exists; naming it would route a joiner's answers to a peer that
  cannot hand them on (the same class as the sync-storm failure).
- The greedy delivery step (RouteStep, route_toward, reaches,
  linked_known_peers) and reply_via move from dht::topology into
  dht::delivery, where they belong; topology.rs differs from master
  only in the find_successor and closest_preceding_finger docs.
- Transaction::new takes reply_via; the parallel
  Transaction::replying_via is removed and its 22 callers migrated.
- Error::NoNextHop is removed: its only use was infer_next_hop.
- Docs: the handoff goes to the first linked known node, not the
  successor head (types.rs, chord.md, RouteStage); the fixpoint hop
  bound is stated as ceil(log2 dist) + 1; reply_via covers only the
  successor and connection answers; originate documents that a manual
  connection answer names the hint harmlessly.
- Test: test_reply_via_names_the_nearest_linked_successor_only_without_a_predecessor.

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

* Let the handoff receiver route on; forbid only a second crossing

A terminal handoff receiver broke every sender with a sparse view. A
leaf linked only to its guard, its ring predecessor, finds the guard past
almost every destination and hands off on its first hop; a terminal
guard then delivered only to its own neighbours although its full view
routes anywhere. A joiner's ConnectNodeSend to its successor failed the
same way unless the bootstrap was linked to that successor. What cycled
in #865 was the unbounded repetition of crossings, not one crossing.

- delivery_step: after a handoff the receiver forwards greedily again;
  only a second handoff is refused (RelayDestinationUnreachable). A route
  toward one aim is at most two greedy runs joined by one handoff, so it
  takes at most 2|V| hops and never cycles.
- Model: per-role liveness tests, each its own proposition, added before
  the rule changed and failing on the terminal rule:
  test_sparse_sender_reaches_every_member,
  test_leaf_behind_its_predecessor_is_reached_by_every_member,
  test_destination_without_links_fails_fast. The #865 route from A is now
  delivered as A -> C -> B -> T. A differential "no regression against
  the owner-lookup router" law was tried and dropped: on arbitrary views
  two greedy choice rules each win where the other dead-ends, so no
  greedy router dominates another (counterexample recorded in the model
  doc).
- Docs: the 2|V| bound and the one-crossing rule in dht::delivery,
  SECURITY.md, chord.md, MAX_RELAY_HOPS, the error doc, CHANGELOG.

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

* Correct two model doc claims after the one-crossing rule

- NeverOvershoots: a single crossing per aim is not a property of the
  type. It follows from the check itself: nothing clears the handoff
  flag, so hops of a flagged stage are held to the no-overshoot law, and
  a second crossing would be rejected as an overshoot.
- The joiner test: without reply_via an answer arrives only if one of
  the two greedy runs happens to pass the bootstrap; the two fixed
  topologies are ones where neither does, so the hint is what makes
  delivery certain.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Apply serif typography and update header tagline (#894)

* Use serif typography and refreshed feature illustrations

* Correct landing header tagline

* Keep landing PR limited to typography and header copy

* Fix flaky tests at the root: event-woken waits everywhere, deterministic fixtures, node test transport (#889)

* test(core): drive test_handle_connect_node on the controlled dummy queue (#850)

test_handle_connect_node ran three nodes over real webrtc-rs loopback and
polled each step against the 5 s TEST_WAIT_TIMEOUT. Under suite load, the
handshakes outran the deadline.

The test now runs on the dummy transport under ControlledDeliveryGuard:
- identities are fixed (fixed_secret_keys) and the dummy ids are seeded;
- deliver_until delivers FIFO until a named predicate holds, bounded by a
  step count and never by a clock;
- P0 (the n1-n2-n3 path joined, with no admitted or pending n1-n3 link)
  is reached first, then Swarm::connect(n3), then P1 (C(n1,n3), C(n3,n1)
  and exact successor lists);
- after P1 the run is driven to quiescence, decided by state (no queued
  event, and no handshake, inbound or outbound on any node), and P1 is
  re-asserted;
- the relay is witnessed: n3 received n1's ConnectNodeSend and n1
  received n3's ConnectNodeReport, each with n2 as the hop signer.

deliver_until takes a RetiredTarget policy, so the strict drain helper
keeps its live-connection check. has_connection_in_state is the shared
predicate behind wait_for_connection_state.

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

* test(core): await retirement events in the native storage-repair test (#858)

The test read the global outbound-transfer counter once right after
disconnect. The retiring worker releases its permits asynchronously, so
under load the read could see a permit about to be released. The repair
and the physical close were polled against deadlines.

- The repair is awaited on a write generation that node2's test storage
  publishes on every put; the generation is marked before the read.
- Maintenance and repair pressure keep running through the retirement.
- After disconnect, the test asserts that node2 is in none of node1's
  topology slots, then awaits deallocation of node2's outbound capacity.
  Deallocation is terminal, and each permit holds the capacity.
- The physical close is awaited through NativePhysicalCloseWitness.
- Repair-test nodes use host-only ICE.

Hooks: rings-transport's NativePhysicalCloseWitness is watch-backed and
gains completed(), documented as supported API. rings-core test builds
give TransferCapacity a never-sent watch whose closing witnesses
deallocation.

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

* test(node): await a complete E2E stream and use host-only processor fixtures (#857)

wait_for_e2e_stream_frames returned on the first final frame, although
the link does not guarantee order. It now waits on e2e_stream_complete,
a monotone set predicate (∃ final f. {0..=f.seq} ⊆ S), and returns the raw
frames in arrival order, so the test's shape assertions still check what
the sender emitted. A unit test covers all 24 arrival orders plus a
trailing duplicate and a stray frame.

Every processor fixture, including tests::native::prepare_processor,
uses host-only ICE.

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

* test(node): await admission and retirement events in the wasm tests (#870)

The browser provider and processor tests slept for fixed durations after
connecting, sending and disconnecting.

- A TransitionRecorder, installed at creation, logs (peer, Admitted |
  Retired) into an unbounded channel, and PeerTransitions consumes
  matching records, so no transition can be missed.
- create_connection awaits both admissions through the shared
  await_mutual_admission helper. disconnect is followed by awaiting
  Retired.
- test_handle_backend_message awaits a JS Promise that the handler
  resolves.
- The processor tests await exactly the expected texts.
- test_processor_connect_with_did reads the listing through
  settled_peer_states, which consumes a superseded generation's
  Admitted/Retired pair.
- Browser fixtures use host-only ICE.
- Each connection test runs under one 20 s with_hang_guard that names
  it, sized so that even five simultaneous hangs stay inside the 120 s
  runner budget.

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

* test(core): mint the cancellation test's admitted connection without a link (#875)

test_cancellation_after_scan_releases_successor_behind_waiting_head only
needs an AdmittedConnection token, but its wasm fixture set up a real
browser WebRTC link and polled admission. A stalled handshake then
consumed the wasm binary's whole 120 s budget.

- admit_detached_peer reserves a pending generation, creates its local
  transport object (no offer, no ICE), and activates it through
  activate_connection_for_test. That hook and its registry counterpart
  widen from cfg(all(test, not(wasm))) to cfg(test). The fixture is the
  same on native and wasm, and uses fixed identities.
- Core's browser fixtures use host-only ICE.
- tests::wasm::with_hang_guard fails a test by name. The real-transport
  handshake tests use 15 s guards, and the repair soak's 60 s is named a
  scenario budget.
- CI runs the soak as its own core wasm invocation, so that budget
  cannot starve the rest of the binary.

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

* test: share fixed test identities and the host-only ICE constant (#886)

Now that the flaky-test fixes land together, the facts that were stated
twice are stated once:
- fixed_secret_keys::<N>() moves into core's crate::tests, which both
  platforms compile, and the outbound cancellation test uses it;
- one node crate::tests::TEST_ICE_SERVERS serves the native and browser
  fixtures.

The core and node with_hang_guard helpers share one shape and reference
each other. They stay separate definitions because each lives in its
crate's cfg(test) module.

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

* test(node): run processor network tests on a controlled in-memory transport (#883, #857)

Processor network tests ran over real webrtc-rs handshakes bounded by
5 s deadlines, so under load the handshake latency decided the outcome
(#850's mechanism). This was #857's remaining root cause.

- rings-node gains a test feature, dummy = [node, rings-core/dummy,
  rings-transport/dummy], documented as not a production configuration.
  It adds no code path and only selects core's in-memory transport.
- Under dummy, network_test_guard(), which every connecting processor
  test already takes, also starts a ControlledNetwork. It enables the
  controlled dummy queue with a fixed seed and runs a FIFO pump on the
  test's thread. No message is delayed by a clock, and the handshake is
  in-memory, so the helpers' remaining bounds are hang guards only.
- The E2E stream test runs on the controlled network. The pump is paused
  while the stream is sent, and the frames are then delivered
  newest-first. Reordering is exercised deterministically, and the test
  asserts that it happened, along with completion, the raw shape and
  decryption. Its identities are fixed.
- The protocol-logic connecting tests (online registry, direct message,
  measurements, E2E handshake and stream, bootstrap probe) run in the
  dummy build. The default build keeps test_processor_create_offer and
  test_processor_handshake_msg as host-only real-WebRTC smoke tests,
  serialized by NETWORK_TEST_LOCK. The gateway tests, which need a real
  public network, run in the default build only.
- CI: the dummy_tests job compiles and runs rings-node --features dummy.

Browser tests are unchanged here: the dummy transport is native-only.

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

* test(core): wake every test wait on observed activity, never on a timer (#882, #890)

Core's shared test helpers polled state on timers and decided quiescence
by a 500 ms silence window, and core's browser soak polled every 25 ms.
Every wait now probes state and is woken by a state change.

- tests::activity is a runtime-neutral activity generation (mutex plus
  wakers, no tokio) used natively and in the browser. Waits follow
  mark, probe, then await generation != mark, so no wake-up is lost.
  Activity is recorded by each test node's observer (message
  sent/forwarded/received/stored, lookup started/finished) and callback
  (validated, handled, connection event).
- Node::build(builder) attaches the observer and callback to every test
  node. prepare_node_without_stun is folded into prepare_node, and every
  in-process fixture uses host-only ICE.
- wait_until_result (and its wait_for_* wrappers), wait_for_storage_state
  and connection.rs's wait helpers are activity-woken probes.
- wait_for_msgs drops the silence window. Quiescence is decided by
  message conservation: no handshake, inbound or outbound transfer, or
  queued dummy event on any node, and sum(delivered) = sum(received) over
  the test's nodes (wire messages only).
- Test builds record activity when a transfer permit is released, both
  outbound and inbound (one cfg(test) line in each Drop): the release is
  a state change the predicate reads, and it follows the delivery or
  handling notification. The in-flight test accessors widen to every
  test build.
- File-local polls became probes: the native-transport interleaving
  trace, the continuous-repair convergence, and the chunk-tail "no
  second chunk" checks, which now await the cancelled transfer's
  release.
- Browser: core's wasm fixtures carry the ledger and callback. The
  repair soak's mesh, convergence, repair and trace waits are
  activity-woken, and its stabilization rounds are separated by
  observed mesh quiescence instead of a 25 ms sleep.
- The pending-measure scheduler test waits for admission only: its
  measure never completes, so quiescence is unreachable by design, and
  the old silence window had masked that.

Hang guards remain only as failure bounds. TEST_HANG_GUARD is 5 s in
dummy builds and 60 s in native builds, where real webrtc-rs handshakes
under load exceeded 5 s (#850); no wait is paced by it.

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

* test(node): activity-woken processor waits and a shared test-support crate (#883, #890, #886)

Review follow-up on #889 (M1, L1–L5), plus the user's direction that no
test wait is paced by a timer:

- rings-test-support, a dev-only crate (publish = false), holds the
  runtime-neutral activity cell, the activity-woken probe and
  with_hang_guard. Core and node share one definition of each, which
  resolves the duplicated hang guard from #886.
- ProcessorBuilder gains a cfg(test) hook that chains one extra observer
  after Observability. Every test processor, native and browser,
  records activity through it; the test callback and the controlled
  pump record activity too.
- The node processor helpers are activity-woken probes: processor
  admission, inbound messages, the mutual DHT topology, the registry
  lookup and storage, and peer measurements. The Notify fields and the
  20 ms polls are gone. Hang guards are failure bounds only (5 s dummy,
  60 s real WebRTC). The two real-transport smoke tests document what
  only the real transport checks.
- The pre-stopped listener tests assert completion on the first poll
  instead of within 100 ms: returning before the first stabilization
  tick means waiting for nothing.
- The browser listener test decides "started is still pending" with a
  microtask flush plus Promise.race against a resolved sentinel,
  instead of a 10 ms sleep.
- The dummy seed is per test (base seed mixed with the test's name), and
  the controlled network is torn down before the serial lock is
  released. The rings binary refuses to build with the dummy feature,
  and CI's feature-check step lists its features instead of
  --all-features.

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

* test(browser): activity-woken waits in the dweb and frontend browser tests (#890)

The remaining browser tests outside rings-core and rings-node waited on
timers. dweb connected and retried a fetch every 500 ms. The frontend
onion flow slept 1 s after connecting and retried navigation every
250 ms, and its listener test slept 20 ms before asserting.

- ProcessorBuilder::observer(SharedSwarmObserver) is now public API. It
  chains one extra observer after the processor's own Observability, as
  SwarmBuilder::observer does for a bare swarm, so embedders, and these
  external tests, can observe swarm activity. It replaces the
  cfg(test)-only hook.
- dweb: a host-only test node (build_node_with) records activity. The
  test probes listPeers on activity until A lists B as connected, sends
  the request once, and probes for B's page on activity. The demo app
  keeps its STUN server.
- frontend: host-only ICE. Admission is probed on activity, and each
  failed gateway navigation is re-armed on the next activity after the
  failure rather than on a timer. The listener check flushes the
  microtask queue instead of sleeping 20 ms.
- Both use rings-test-support as a path dev-dependency.

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

* test: count arrivals, stabilise quiescence, relayed-connect smoke test (#889 review round 2)

- M1: conservation now balances deliveries against arrivals counted by
  the receiving transport before validation. A cfg(test) counter is
  bumped in process_event for logical-lane events, so a message the
  receiver drops or rejects still balances its delivery. The
  quiescence fold holds only if the activity generation did not move
  while the nodes were sampled, which is safe on multi-thread runtimes.
  Both preconditions are documented.
- M2: the frontend navigation retry marks activity before each
  attempt, so a change during or after the attempt wakes the next one,
  and it keeps the last error for the guard report.
- M3: test_relayed_connect_over_real_webrtc, a four-node real-webrtc-rs
  ring with one successor and no fingers. It always issues n1's connect
  to n3 from a state with no n1-n3 link (a join-time link is retired
  first) and witnesses the ConnectNodeSend relayed by n2.
- L2: rings-node refuses to build ffi together with dummy.
- L3: ProcessorBuilder::observer documents the non-blocking observer
  contract and that a later call replaces an earlier one.
- L4: the listener lifecycle tests decide "still waiting" with a direct
  futures::poll! of the listener future instead of 20-100 ms sleeps and
  timeouts.
- L5: an unnamed test thread fails instead of sharing a fallback seed.
- L6: rings-test-support gains `within` and, behind its swarm-observer
  feature, the one activity observer used by node, dweb and frontend.
  Its lint table is the workspace's minus clippy::panic, since failing
  tests is its purpose.

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

* docs(node): state the public ProcessorBuilder::observer contract (#889 L1)

User decision: keep ProcessorBuilder::observer public as an embedder hook.
The method now states its contract. The observer is synchronous and
bounded on the send and receive paths. It runs after the processor's own
Observability, which alone feeds /status?view=observability, so that
output is unchanged. Message observations carry no payload, key or
identifier. Lookup events carry a correlation key that exporters must
aggregate or redact. A later call replaces an earlier observer.

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

* test: frame-level conservation, independent progress signals (#889 review round 3)

- H1/M1: quiescence balances frames at the swarm's two chokepoints. A
  cfg(test) FrameLedger counts sending/sent around SwarmConnection::send_data
  and arrived plus an in-flight token at submit_inbound_message entry, released
  at the inbound actor's handoff. Replaces the observer delivered counter and
  the process_event arrival counter; MessageLedger and TestSwarm are removed.
- L1: the relayed-connect smoke test serializes its joins, asserts P0 on both
  sides and asserts both exact paths (send n1->n2->n3, report back via n2).
- L2: the frontend onion retry wakes only on DHT storage writes.
- L3: the stale-stop check polls the restarted listener directly.
- L4/L5/L6: feature-unification, ffi+dummy and soak hang-guard docs.
- zkjoie P2: the registry test looks up once after the decisive storage
  state; control rounds wait on the storage transfer's own progress.
- CI: rings-test-support workspace dependency carries a version for
  cargo-deny's wildcard ban.

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

* test: ordered frame sampling, commit-point send counting (#889 review round 4)

- M1: arrive() raises in_flight before arrived; the quiescence fold reads
  each swarm through sample_swarm, loading sending, sent, arrived, in_flight
  in the ledger's sampling-law order, then the actor and scheduler witnesses.
- M2: a send counts as sent once the transport committed to it (Ok or an
  irrevocable acceptance), so losses below the swarm leave sent >= arrived;
  on_invalid_inbound_frame counts its frame as arrived.
- M3: rings-test-support stays a version-less path dev-dependency, so it is
  stripped from packaged manifests; deny.toml allows wildcard paths.
- L1: CI runs the relayed-connect smoke test in its own invocation.
- L2: control rounds wait for a storage frame after the traced control.
- L3/L4: read-repair wakes documented; the stale-stop check asserts the
  restarted generation's token.
- Fix dead-code under dummy and wasm clippy for the new test helpers.

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

* test: outbound-first sampling, stated injection precondition (#889 review round 5)

- L1: sample_swarm reads the outbound permits before the ledger, since a
  transfer's permit is released only after its sends were counted.
- L2: the control-round predicate doc and the browser soak comment state
  that maintenance controls share the link there.
- L3: swarms_quiescent states that every received frame was sent through
  send_data; tests that inject frames do not wait on it.
- L4: the stale-stop comment claims only what the poll proves; the
  tautological token witness is removed.

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

* test(core): wait on the whole network while an offline peer's message is held

hold_message_for_offline_peer waited for quiescence over node1 and node3
only. In the rejoin test the departed peer still exists, and admission's
successor synchronization can reconnect it while the message is held, so
frames from a node outside the fold broke conservation (CI: sent=45,
arrived=46; locally 12/40 failures). The helper now takes every node of
the test for its waits.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Remove the paced direct-edge throughput bottlenecks (#887, #888) (#900)

* Confirm delivery on buffered-amount events instead of a 300 ms poll (#887)

Both WebRTC backends polled `buffered_amount` every 300 ms to resolve a
send's delivery future, so every awaited send cost about one poll period
and a paced link carried about 3.4 messages/s whatever its capacity.

Each data channel now owns a delivery tracker. A pure registry
multiplexes the channel's single `bufferedAmountLowThreshold` over every
pending end offset: it arms tau(E) = E - min(pending end offset), so
`bufferedamountlow` fires exactly when the earliest pending send has
left the buffer, and each settle round applies the existing flush
predicate to every pending send. A single-flight round arms the
threshold before it reads `bufferedAmount` (the lost-wakeup rule), and a
request that arrives mid-round forces one more arm-and-read step.
`close` and `error` settle every pending send as not delivered. No
timer takes part in a verdict.

The backends only supply the two channel effects (arm, observe) and wire
the three events: webrtc-rs `on_buffered_amount_low`/`on_close`/
`on_error`, and web-sys `bufferedamountlow`/`close`/`error`.

Tests check soundness and one-event liveness over every schedule of six
send/drain operations, and cover concurrent pending sends on one
channel, a flush before registration, an event during a round, closure,
dropped waiters and an abandoned round. They also run awaited,
concurrent and closed sends over a real native loopback link.

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

* Admit paced direct-edge traffic at the rate its protocol supplies (#888)

Core charged every Application message to one per-origin quota of 8
msg/s with a burst of 32. A protocol that paces its direct-edge traffic
and bounds each sending neighbour itself was refused far below its own
admission rate: the onion data plane emits about 98 cells/s under its
per-link budget B/V.

A final-destination transaction is now charged to an OriginQuotaLane.
This is its class lane, except that Application traffic from the
handshake-authenticated neighbour that originated it may use a paced
lane, if the protocol owning the payload registered one. Core takes only
a PacedRate (budget per period, burst = budget) and an opaque
PacedLaneId. It has no vocabulary of the owning protocol. The lane
replaces only the message bucket, whose refill is now rational; the byte
bucket and the record bound stay the Application lane's. All other
Application namespaces keep the configured 8 msg/s default.

- core: OriginQuotaLane::select states the law in one place and
  consults the application only where the lane may apply. EdgeRelation
  is Neighbour only for the authenticated connection of the origin
  itself: a relayed or foreign origin would multiply a per-link rate by
  its path count, and the unsigned relay carrier cannot witness
  "direct". Quota keys store only the lane identity
  (OriginQuotaLaneId). SwarmCallback::paced_lane is the application
  hook. Paced drops are counted in one bounded aggregate.
- node: Protocol::paced_direct_rate declares a protocol's rate. The
  registry installs it under the same write lock as the handler, and
  keeps the lane identity across a replace. Backend resolves an
  envelope's lane from its namespace prefix alone.
- Tests: the selection law over every class, edge and registration.
  On a simulated clock, a sender at 98 cells/s for 30 minutes is never
  refused in the paced lane, while the default Application lane admits
  at most burst + 8 msg/s. A flooding neighbour is bounded by
  B + B*T/V. Paced and class records are independent. End to end, the
  neighbour's own paced traffic is not refused, while a relayed origin
  and an unregistered namespace of the same neighbour are refused in
  the Application lane. Registration installs and replacement
  withdraws the rate.
- Docs: SECURITY.md origin-rate boundary, config.yaml origin_quota.

The TransactionSequenceStale drops seen with this traffic are genuine
reordering beyond the 32-sequence window, not a knock-on of the quota
refusals. The sequence stream is shared across classes, and the
outbound scheduler reorders classes. The window and the ordering are
unchanged here, and the ordering change is tracked in #898.

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

* Settle delivery against one E snapshot taken before b (#887 review)

H1: a settle round re-read E after reading bufferedAmount. A send that
landed between the two reads counted its bytes in E but not in the b
the round saw. So φ(E, b, e) could hold while earlier bytes were still
unacknowledged, a false flush on native. M1: E was also loaded before
begin_step took the registry lock, so a registration in that gap had its
Rerun absorbed against a stale E, which broke one-event liveness.

The round now snapshots E once, under the registry lock, in the step
that arms τ, and settles against that same E. The send path stores E
only after the write has counted the bytes in b, so the snapshot never
over-counts released bytes. A registration's request absorbed by the
step is visible to it through the lock.

- Tests: an enqueue between the read of b and the settlement fabricates
  no flush; a registration during a round re-arms τ against the new E.
  The dropped-waiter test is now driven by events.
- Docs: the registry states both arguments. The saturation comments
  now say saturation cannot occur (τ < b). The abandoned-round comments
  say only a later registration is sure to recover. The drop semantics
  are stated, as is the native meaning of "flushed" (acknowledged by
  the peer's SACK).

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

* Bound paced-lane identity, trust and exposure (#888 review)

- M2: core does not bound a declared rate. The PacedRate,
  SwarmCallback::paced_lane, Protocol::paced_direct_rate and
  SECURITY.md docs state that the rate is trusted local configuration,
  with the authority of raising the Application message quota for that
  namespace's neighbour-originated traffic.
- M3: a namespace receives its PacedLaneId the first time it declares a
  rate and keeps it for the registry's lifetime. Withdrawing and
  re-declaring therefore grants no fresh allowance and orphans no
  records. Exhausted identities are an error, not a saturated shared
  id, and a batch applies all-or-nothing.
- L4: core docs and tests are protocol-neutral (window admission of
  budget per period).
- L5: SECURITY.md states that no protocol on master declares a paced
  rate yet, gives the table bound (4 + k) * max_records, and repairs
  the broken sentence.
- L6: EdgeRelation and OriginQuotaLane::select are crate-private;
  PacedLaneId::get is removed.
- L7: the end-to-end tests assert the lane of the origin's quota
  record, with no clock. They cover the neighbour's own paced traffic,
  an unregistered namespace, a relayed origin, the origin arriving
  over another neighbour, an unauthenticated peer, and the reassembly
  path, and they check that the registry is consulted only for
  eligible traffic.
- L8: the new modules list their imports.

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

* Close the round-2 review lows of #900

- L1: the registry states the precondition of one-event liveness. A
  caller cancelled between committing E and registering leaves tau
  armed low by that send's bytes. Earlier sends are then delayed by at
  most one message's drain, and no verdict is lost.
- L2: the registration-during-a-round test documents that it covers
  the Rerun path, and that a registration between the load of E and
  begin_step is excluded by construction (E is loaded under the lock).
- L3: the unauthenticated-peer case asserts that the frame is admitted
  into the Application lane, not merely that it is not paced.
- L4: the dropped-waiter note says "at least as high", and the merged
  paragraph is split.
- L5: the SECURITY.md paragraphs are reflowed within 100 columns, and
  the two paced-rate entry points are named.
- L6: paced lane identities cover every u32, including u32::MAX, before
  exhaustion is reported. The exhaustion path is tested.

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

* Adopt the #889 test patterns in the #887 and #888 tests

- Transport native loopback tests: the hang guard is rings-test-support's
  with_hang_guard, which names the test that hung. rings-test-support is
  a transport dev-dependency, without the swarm-observer feature.
- Paced-lane end-to-end tests: every identity comes from
  fixed_secret_keys, so runs are reproducible.

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

* Replace the paced lane with delegated admission (#888 revised decision)

The paced lane enforced the rate B/V that the declaring protocol's own
admission already enforces. Without a core ceiling it bounded nothing at
the core layer. Per the revised decision on #888, it is replaced by a
flag with no rate.

- core: admission_delegation replaces paced_lane. An Application
  namespace may declare delegated admission. For a transaction that
  satisfies the unchanged EdgeRelation::Neighbour predicate in such a
  namespace, OriginQuotaCharge::select yields MessageLimit::Delegated.
  The admission then neither needs nor consumes a message token, and is
  still charged to the same Application record's byte bucket under the
  same record bound. Relayed, foreign-origin, local and unauthenticated
  traffic keeps the enforced limits. OriginQuotaKey, the per-lane limits
  and the counters are back to master's shape; OriginQuota::admit and
  the table take the MessageLimit.
- SwarmCallback::delegates_admission and Protocol::delegates_admission
  replace the rate-carrying paced_lane and paced_direct_rate. The node
  registry keeps a set of delegating namespaces, installed under the
  handler write lock. PacedRate, PacedLaneId, lane-id allocation and
  exhaustion are removed.
- Tests:
  - The selection law, and a simulated-clock acceptance test: one
    neighbour's record carries a delegated namespace at 98 msg/s and
    another at 20 msg/s for 30 minutes. The delegated one is never
    refused by the message limit; the other still is, beyond
    burst + 8 T.
  - Delegated traffic is still refused by the byte bucket.
  - Clock-free e2e cases (own traffic, unregistered namespace, relayed
    origin, origin N via neighbour M, unauthenticated peer, reassembly)
    assert the exact stored tokens: message limit skipped (bucket full)
    or charged (burst - 1), and bytes charged (byte_burst - cost).
- SECURITY.md and config.yaml: delegation is trusted local
  configuration, a declaring namespace must enforce its own
  per-neighbour admission, both entry points are named, and it is inert
  on master.

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

* Charge delegated admissions a 16 KiB byte floor (#888 addendum)

H1 (round 4): with delegation, core's own per-message admission work
(two signature checks and a replay-snapshot persist under the replay
lock) was bounded only by bytes. About 50-byte messages would allow
roughly 80k admissions per second, and no delegating protocol can bound
that work, because it runs before the protocol sees the message.

Per the maintainer's addendum on #888, a delegated admission is charged
max(len, DELEGATED_MIN_CHARGE) bytes against the per-origin byte
bucket. The floor is a 16 KiB core constant documented as a generic
bound on per-message work. Under the default 4 MiB/s and 64 MiB burst
it bounds a neighbour's delegated admissions to 256 per second with a
burst of 4096. Messages of 16 KiB or more pay exactly their length.

- Tests:
  - 50-byte delegated messages at 1000/s are bounded by the floor rate.
  - The acceptance test runs the delegated namespace at 98 msg/s of
    16 KiB and is still never refused.
  - Unit and e2e tests check the exact floor and length charges.
  - A small (and an undecodable) delegated payload is charged the
    floor end to end.
  - node pins that a malformed tail after a delegating namespace
    still resolves as delegated (L5).
- L1: the delegation verdict is documented as taken at admission time,
  with a replace-while-queued exposure bounded by the mailbox depth and
  the floor.
- L2: the protocol admits "before its own processing, after core's
  admission".
- L3: SECURITY.md and config.yaml state that delegated and plain
  traffic share the neighbour's Application byte bucket.
- L4: CHANGELOG entries for #887 and for the breaking #888 API.

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

* Close the round-5 review lows of #900

- L1: the docs no longer claim that the byte floor bounds the signature
  checks. It bounds how many delegated messages core admits, and so the
  per-message work that follows admission (the replay-snapshot persist,
  the logical lane, validation and dispatch). The decode and signature
  checks precede the quota and are not bounded by it. Reworded in the
  DELEGATED_MIN_CHARGE doc, the admission_delegation module and
  SECURITY.md.
- L2: the floor-rate test asserts the exact count on its simulated
  clock, floor((byte_burst + byte_rate * t_last) / floor) =
  4096 + 256 * 59.999, so an over-charge also fails it.
- L3: the e2e helpers use u128::from/try_from instead of `as` casts.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Pipeline outbound lanes and key replay by traffic class (#899, #898) (#906)

* Keep a bounded window of transfers in flight per outbound lane (#899)

Each outbound class lane admitted no second transfer until its active
transfer's delivery was confirmed. On native, "delivered" means the
peer's SCTP SACK, which the receiver delays by up to 200 ms when a
message ends on an unpaired packet. So every stop-and-wait frame could
pay that tail, and a lane carried about 14 messages/s on loopback.

A lane now keeps up to OUTBOUND_LANE_WINDOW (8) transfers in flight.
Consecutive messages reach the peer back to back, their SACKs pair, and
the delayed-ACK timer no longer paces the lane. A compile-time assertion
keeps the window strictly below TRANSACTION_REPLAY_WINDOW, so in-lane
pipelining never reorders a class beyond the receiver's replay window.

- queue: each lane is an ordered window of slots (Runnable, Sending,
  Waiting on its delivery) plus the FIFO backlog. Laws, stated on the
  lane:
  - bound: at most W in flight;
  - FIFO: the oldest runnable slot goes first, so transfers start in
    push order;
  - contiguity: a fresh transfer does not start while an earlier one
    still has frames to admit, so chunked transfers never interleave
    on the wire;
  - attribution: slots are addressed by their own token or delivery id.
  Each transfer keeps its capacity permit and delivery future, so the
  capacity bound is unchanged, and cancellation and shutdown settle
  every in-flight transfer.
- The outbound transfer reports whether frames remain after the one
  admitted, from the chunk header.
- Tests:
  - the 13^6-trace model now runs with a window of two and checks FIFO,
    contiguity, the in-flight bound, and that no transfer admits a
    frame while its previous one is undelivered;
  - a unit test pipelines a full window and then waits;
  - the scheduler tests that queue a transfer behind a blocked lane run
    their workers with a lane window of one (a test-only thread-local
    override). One paused delivery then blocks the lane, exactly as
    before, and none of them races the harness's delivery deadline
    under load; the window's own laws are tested in the queue module.
- SECURITY.md: the outbound scheduler section states the window laws.

Native loopback, 512 awaited 16 KiB sends, same test with the window
at 1 and at 8:

  W=1: 36.9 s, 13.9 msg/s, p90 204 ms, 179 sends over 150 ms
  W=8: 0.376 s, 1362 msg/s, p90 1.2 ms, none over 150 ms

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

* Key transaction replay by traffic class (#898)

One sequence stream per (network, origin, destination) was shared by
all four traffic classes, while the outbound scheduler preserves order
only within a class lane and deliberately reorders across lanes. When
DHT control traffic overtook an Application backlog longer than the
32-sequence replay window, the backlog's head arrived below the window
and honest transactions were rejected as TransactionSequenceStale.

StreamKey is now (network, origin, destination, class). The class is
the class of the message the transaction carries, derived from its
signed data on both sides (Transaction::class), so it adds no wire
field. Order is claimed only within a class. A lane keeps at most
OUTBOUND_LANE_WINDOW (8 < 32) transfers in flight (#899), so an honest
sender's per-class transactions stay within the window, however the
lanes are interleaved. This assumes a class's transactions reach the
scheduler in signing order, as they do from one sending task.

- Sender: PayloadSender's originate and send methods take a Message,
  and reserve_transaction_sequences takes the class, so a sequence is
  reserved in the stream of the message it signs. A probe completion,
  carried inside its offer and never admitted on its own, takes its
  sequence from the offer's stream.
- Receiver: admission keys by Transaction::stream_key, which now
  returns a Result because it decodes the class.
- Persistence cutover (maintainer's decision, option a): the snapshot
  moves to rings-core:transaction-replay:class-streams, and the first
  load deletes rings-core:transaction-replay unread. That resets every
  replay window once, as deleting the store does. A failed deletion
  fails closed and is counted.
- Rollout: the wire format is unchanged, but replay is not
  interoperable across the upgrade, so the release is a mandatory
  network-wide upgrade, seeds included. This is stated as breaking in
  the CHANGELOG. The version bump is left to the release.
- Tests:
  - The law over 200 seeded cross-class interleavings, with in-lane
    reordering inside the lane window: per-class streams never reject
    an honest transaction, while the same arrivals on one shared stream
    do.
  - End to end over the dummy transport: a 48-transfer Application
    backlog is overtaken by DhtControl probes with zero stale drops.
    With the class removed from the key, the same test fails.
  - The cutover: the old key is deleted unread, and a failed deletion
    fails closed.
- Docs: SECURITY.md transaction replay boundary and
  docs/transaction-replay.md.

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

* Pin class lanes to channels; make the delivery deadline progress-based (#906)

Review of #906, M1 and M2.

M1. A frame's delivery deadline measured the time since its own admission,
so the frames queued behind the head of a full window on a slow link
expired although the link kept delivering. The deadline now measures a
stall: a frame expires once `now - max(admitted, last_settled) >= D`, the
last confirmed delivery on the connection. The pure law is
`delivery_stalled`; the worker tracks `DeliveryProgress` per peer. The
test-only W=1 lane-window override is removed and the scheduler tests run
at W=8, filling the window through a paused worker and held deliveries.

M2. The transport rotated every send over the pool's data channels, so a
receive handler stalled on one channel let later frames of the same class
overtake it on the others. The pool now pins a lane to a channel,
`channel(lane) = pool[lane mod |pool|]`, and core sends DHT control (and
link control), storage, E2E and application on lanes 0..3.
`send_message_with_permit` takes a `ChannelLane`; `RoundRobin`/
`RoundRobinPool` become `LanePool`/`ChannelPool` and
`RoundRobinPoolEmpty` becomes `ChannelPoolEmpty`.

A native two-node test stalls the receiver's Application `on_validate`:
control proceeds on its own channel, and the held backlog (twice the
replay window) arrives afterwards in send order with no stale verdict.
Rotating Application frames over the channels fails it.

L3: the lane-window docs state that a chunked message still pays the
per-frame delayed-ACK tail and holds its lane's wire.

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

* Bound class-keyed replay by class count; retire the old key best effort (#906)

Review of #906, M3, L1, L2 and L5, with the M2 law in the replay docs.

M3. `TRANSACTION_REPLAY_STREAM_CAPACITY` is now `MessageCategory::COUNT *
4096`: every class stream of 4096 account-destination pairs. A full
snapshot encodes to at most `TRANSACTION_REPLAY_SNAPSHOT_MAX_BYTES`
(20,643,852 bytes, new export), derived from the per-record maxima that a
test pins. The native daemon's replay file store grows from 16 MiB to
32 MiB so a full snapshot fits it (a test asserts it); at 16 MiB the store
budget, not the stream bound, would have failed first.

L1. A failed deletion of the pre-#898 snapshot key no longer fails every
reserve and admit: the key is never read, so the failure is counted and
logged and admission continues. The load that finds no class-stream
snapshot persists the empty one, so no later start touches the former key.

L2. The module doc names the class in the stream identity, states the
in-class order law (lane FIFO, window below the replay window, one pinned
channel per class), the snapshot bound and the one-time cutover.

L5. The `arrivals` test uses `try_from` instead of `as` casts.

SECURITY.md, CHANGELOG and the replay docs cover lane pinning (a new
breaking entry for the transport API), the progress-based deadline, the
chunked delayed-ACK tail, the capacity and snapshot bound, and the
best-effort deletion.

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

* Qualify in-class order for held frames; fix stall-test doc and line widths (#906)

Review round 2 of #906, L1 to L3.

L1. A frame held on a delegation-reference miss is released in a task of
its own, independently of the later frames of its lane, because the session
link promises no order among held frames (delegation-references law: a
frame that resolves is never queued behind one that does not). Later frames
of its class may therefore reach replay admission first, and a held frame
released after a window's worth of them is rejected as stale. Forcing lane
order on release would contradict that law, so the in-class order law is
qualified instead, in the replay module doc, `StreamKey`, SECURITY.md, the
replay chapter and the delegation-references chapter: it covers the frames
that resolve on arrival, and a held frame can be lost as stale (counted),
never falsely admitted.

L2. The stall test's doc now says it discriminates through its control and
order checks; the stale count stays zero even under rotation, since the
single Application actor holds every channel's handler after one frame.
The test is renamed `..._keeps_its_order` accordingly.

L3. Lines over 100 characters added by this PR are rewrapped (SECURITY.md,
the replay module doc, the replay chapter, rings.rs), and the pool test
returns a `Result` instead of calling `unwrap`.

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

* Persist transaction replay per stream instead of per snapshot (#906)

Review of #906, M-a (performance). Every admission rewrote the whole
replay snapshot under the replay lock, so the cost of one admission grew
with the number of streams retained: about 1.2 MB per admission at 1000
streams and up to about 20 MB at the 4 x 4096 capacity, which would cancel
the throughput #899 gains and, in browsers, write a large IndexedDB value on
the UI thread.

The store now keeps one record per stream, under
`rings-core:transaction-replay:stream:{sender|receiver}:<hex key>`. A
transition writes only its own stream's record, at most
`TRANSACTION_REPLAY_RECORD_MAX_BYTES` (1161 bytes), and commits it to
memory only after the write succeeds.

- `replay::store` is the pure codec. `restore` rebuilds the tables from
  every record and fails closed on a record that does not decode, is not
  under its own key, holds an invalid window, or exceeds a table's
  capacity. The pre-#898 shared-stream snapshot is reported without being
  decoded.
- A load that finds that snapshot deletes it (best effort: counted, logged,
  retried by the next load until it succeeds).
- `ReplayStorage` stores the opaque `ReplayRecord`, which replaces
  `ReplaySnapshot`. New exports: `TRANSACTION_REPLAY_RECORD_MAX_BYTES`,
  `TRANSACTION_REPLAY_STORE_MAX_RECORDS` (32,768) and
  `TRANSACTION_REPLAY_STORE_MAX_BYTES` (28,295,168). A test pins all three
  exactly against maximal encodings.
- Both default stores evict beyond their limits, and an evicted record would
  reopen replay for its stream. They are sized never to reach them: the
  native file store's budget is 40 MiB (a full store plus the former
  snapshot, asserted by a test) and the browser IndexedDB row capacity is
  the record bound plus one.

The storage format already changes in this release (#898), so this adds no
further upgrade. Docs reference #908 where the in-class order law is
qualified. The exhaustion of the never-evicting table by free Sybil origins
is tracked separately as #907.

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

* Qualify the replay fail-closed law for native storage; note the replay lock (#906)

Review round 3 of #906: M1 (docs), L1 and L2.

M1. "Every record must decode or the store fails closed" holds only for the
records the storage returns. The native file store retires a file whose
`(key, record)` framing does not decode before `restore` sees it, and it
writes without fsync, so a torn file left by a crash is dropped and its
stream forgotten, which reopens replay for that stream. Only corruption of
a record's inner bytes reaches `restore` and fails closed. The law is
qualified in `replay::store`, SECURITY.md and the replay chapter, with
durability and surfacing tracked in #909.

L1. `TransactionReplay` and the replay chapter note that one mutex
serializes every replay transition and is held across the record write, so
store write latency bounds the node-wide transition rate; a per-stream lock
would let writes of different streams overlap.

L2. The replay chapter no longer names `ReplaySnapshot`, and the rings.rs
budget test doc speaks of a full store rather than a snapshot.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Keep bad replay records and fail closed on their stream only (#909, #910) (#914)

* Flush the replay store's writes and keep its undecodable records (#909)

Native FileStorage wrote each record to a temporary file and renamed it
without fsync, and deleted any record whose (key, value) framing did not
decode on the read that found it. A power loss after an admission could
tear a stream's record; the next load deleted it, the stream restarted
from First, and an unexpired transaction could be admitted again.

- FileStorage gains a RecordAuthority, fixed at open:
  - Disposable (new_with_cap_and_path, unchanged): no flush, and an
    undecodable record is retired and reported absent;
  - Authoritative (new_authoritative_with_cap_and_path): put flushes the
    temporary file before the rename and the directory after it, and
    removals flush the directory; an undecodable record is reported as
    Error::StorageRecordUndecodable and kept.
- UndecodableRecord names the record by its file and, when its key
  prefix decodes and hashes to that file, by its key. A record truncated
  to nothing is named by its file alone.
- The native daemon opens the replay store as authoritative. It is the
  only FileStorage holding replay state; the DHT, measurement, evidence
  and onion entry-guard stores stay disposable.
- Tests: a torn record and an empty record are reported (with and
  without the key) and stay on disk across a reopen; a key prefix that
  hashes to another file is not reported as the key; authoritative
  writes, rewrites and removals round-trip through a reopen.
- store.rs, SECURITY.md and the replay chapter replace the #906
  qualification "a torn native file is dropped and its stream's replay
  reopens" with the flush and report behaviour; the node-wide failed
  load that remains is #910, fixed next.

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

* Fail a bad replay record closed on its own stream, restoring once (#910)

A record that failed to restore left the replay tables unloaded. Every
later reserve and admit then re-read the whole store (up to 32,768
records) under the global replay lock and failed again, so one corrupt
record took the node offline, and the only recovery was deleting the
store, which resets every replay window.

- Storage: KvStorageInterface gains scan (every record, decoded or
  reported as an UndecodableRecord named by record_name of its key, and
  deleting nothing) and record_name (the key by default), both with
  defaults. FileStorage names records by file (hex SHA-1 of the key) and
  scans without retiring, whatever its authority; IdbStorage reports an
  undecodable row by its key.
- store.rs: restore is pure and total. Each scanned record either
  restores its stream or joins U, the unrestorable records by record
  name (Undecodable, NotAStream, InvalidWindow, Misplaced). Laws, in the
  module docs:
  - fail closed per stream:
    unavailable(table, key) <=> name(record_key(table, key)) in U;
  - pointwise: a record in U changes no other stream;
  - bounded slots: each record in U counts against both table bounds,
    so no new stream takes the store past its record bound.
  The whole-store capacity failure at restore is gone; a new stream is
  still refused at the bound.
- replay.rs: the restored ReplayStore (tables and U) is cached. An
  unavailable stream refuses reserve and admit with
  Error::TransactionReplayStreamUnavailable { key, record }, before any
  quota charge. ReplayCounters gains unrestorable_record (a gauge set at
  load) and unavailable_stream (refusals). Each unrestorable record is
  logged once at load. A scan that fails as a whole is not cached: it
  is counted, and the next call scans again. Restore is O(store) once;
  each later call reads nothing and writes one record.
- Recovery needs no new API: with the node stopped, remove the one
  record the refusal and the log name (the native file in the
  transaction-replay directory, or the browser row), then restart. That
  resets that stream's window alone.
- Tests:
  - pure restore: each failure class marks only its own slot;
  - an undecodable record fails closed exactly the stream it is filed
    as, and an undecodable shared snapshot is still recognised;
  - bounded slots;
  - runtime: one corrupt record refuses only its stream, and later
    calls never scan again;
  - clearing the record and restarting restores the stream;
  - end to end: a torn authoritative FileStorage record;
  - a browser (IndexedDB) test of the same refusal and recovery.
- SECURITY.md, the replay chapter (new "Bad records" section with the
  clearing procedure) and the CHANGELOG. The browser store's durability
  hint is tracked in #912.

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

* Address the #914 review round 1 (#909, #910)

H1: one unreadable record file no longer fails the whole scan.
- FileStore::read_records keeps each file's read result. A scan reports
  a file it cannot read (any error but absence: EACCES, EIO, a
  directory in a record's place) as an UndecodableRecord named by its
  file, so it joins U and fails only its stream.
- An authoritative open indexes an entry whose metadata fails, at zero
  bytes, instead of aborting.
- Tests: unreadable file and directory entries are scanned one by one;
  an authoritative open survives unreadable metadata; end to end, a
  directory in a native record's place refuses only its stream, with no
  persistence failure.

M1: the flushed store's cost is measured, and the I/O is off the async
workers.
- FileStorage is now a handle on an Arc<FileStore>, and every operation
  runs its file I/O on tokio's blocking pool.
- test_durable_throughput_of_replay_admissions (ignored) measures it.
  On an Apple M1 Max SSD (APFS, F_FULLFSYNC): 53-57 admissions/s (p50
  ~17 ms), against ~4,400 unflushed. Documented in the replay chapter,
  SECURITY.md and the CHANGELOG.
- Group commit is filed as #916. Linux is not measured yet.

M2: the laws say "torn, corrupt or unreadable", not "lost".
- An absent record restarts its stream from First; missing-record
  detection is #915.
- The durability law is stated for unix only.
- An authoritative open flushes the parent of every directory it
  created and of the root.

M3: a corrupt sender record refuses reserve on its stream. This test
fails when the sender-side refusal is removed.

M4: SECURITY.md no longer says a misplaced record invalidates the
store.

Lows:
- L1: an authoritative store evicts nothing. A put beyond the budget,
  or an open under a lowered one, fails with the new
  Error::StorageBudgetExhausted.
- L2: the pointwise law is restricted to streams in the store, with a
  capacity term for new streams.
- L3: scan and record_name move to a new KvStorageScan trait with both
  methods required, and its agreement law is stated. KvStorageInterface
  is unchanged, and ReplayStorage boxes a KvStorageScan.
- L4: the recovery steps name the IndexedDB database and object store,
  and cover closing the tabs and the DevTools path. They also state the
  Fork evidence and message loss when a sender stream is cleared.
- L5: FileStorage::record_name is documented.
- L6: the F_FULLFSYNC mapping of sync_all is documented as a
  precondition.

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

* Address the #914 review round 2 (#909, #910)

M1: replay transitions are whole. `reserve` and `admit_with_quota` now
take `&Arc<Self>` and hand the transition (lock, persist the record,
update the table, unlock) to `rings_runtime::run_detached`. Cancelling
a caller mid-persist therefore abandons only the wait. Before, a
cancelled caller released the mutex while its detached record write
still ran, so the next transition could compute from a table without
that write, and the stale write could land after it on disk.
- Tests: test_whole_transitions drives a gated store deterministically
  (Notify, no timing). A cancelled admission and a cancelled
  reservation complete before the next transition of the stream, in
  this run and after a restart. Both tests fail without the detach.
- TransactionReplay::new (test only) returns the Arc the transport
  holds.

M2: rings-runtime gains a fallible run_blocking. On native it runs
blocking work on the runtime's blocking pool, and ownership passes at
the call. Outside a runtime it returns Unavailable, and when the work
panics it returns Abandoned. FileStorage uses it, and
Error::StorageWorkUnscheduled replaces the panic path. It has three
tests.

Lows:
- L1: an authoritative open flushes the root and every ancestor. An
  empty relative ancestor is flushed as ".".
- L2: a directory listing that fails part-way fails an authoritative
  open. A disposable store still skips the unlisted entries. The index
  law states both.
- L3: count runs on the blocking pool.
- L4: blanket Arc impls of KvStorageInterface and KvStorageScan. The
  Shared* test newtypes are gone, and the remaining wrappers forward
  record_name.
- L5: read_file_if_present replaces three copies.
- L6: the MemStorage and IdbStorage KvStorageScan methods are
  documented.
- L7: a TempRoot drop guard removes each test's directory even when an
  assertion fails. The permission-based tests are split and say
  "skipped" when the process reads through permission bits; the
  directory case always asserts.

Docs: the whole-transition law is in the replay …
…880) (#895)

* Cut the onion data plane over to client-sealed loops (#843, #880)

Rebuilt on develop. The loop data plane of #843: Sphinx headers, the AEZ
carry, SURB pools, and the session frames data/fin/credit/abort of
papers/onion-kleisli-pipeline.tex; #880 constant-rate emission with real
cells substituted for cover, the idle floor r_idle = 1 unit/s and the
dwell D = 30 s; paid L9 admission, delegated to the onion namespace
(#888). The removed circuit plane goes with it (tcp/https over circuits,
the crypto layer, the circuit protocol and its tests).

Flow control is the transport's per-lane credit (#913/#924), never core
credit: the #904 core credit is dropped. The transport exposes a
read-only LaneCreditView (free(), credited()), which holds the
connection weakly and reserves nothing; core exposes it for the
Application lane as Swarm::application_credit. The emitter emits a
slot, real or cover, only while free >= 1, so a link's cell rate is
<= min(r/u(b), rho_rel/f(b)); the laws are restated in send_outbox.
A real cell refused before acceptance (a cancelled send included,
#965) goes back to the head of its lane while its link is up (R4-M1).
send_outbox is split into schedule, lanes and tests (<= 1000 lines).

Link facts: the extension dispatch now carries LinkFact::Admitted
beside Retired and Dropped, in core's order and never dropped. The data
plane numbers admissions with its own generations (OnionAdmissionLink),
seeds the links admitted before install with one Reconcile read at its
place in the queue, and the link feed, its tick and core's generation
API are gone. A full link table leaves a link not live instead of
disconnecting core's link. Core exposes only connection_registry_capacity.

Circuit fate (#963, #966, #969, #971): Retired(p) and Dropped(p) fail
closed every client session with a loop through p (the tag table closes
its reply queue) and every exit session whose loops arrive from p (it is
buried and its driver replies abort); a reply that finds its session's
queue full closes the session instead of leaving a gap. A captured
gateway flow whose session failed is reset through its OnionStreamEnd
(#955). The idle-session residual is #975.

Round-4 review: R4-M1, R4-M2, R4-M3 (5-node loops un-ignored), B4-M1,
B4-L1, B4-L2, B4-L3, R4-L1, R4-L2, R4-L3, D-M1, D-L2..L5, A-L1, A-L5
and the long lines. A-H1, A-H2, A-M1..M3, D-M2 and A-L2..L4 concerned
the dropped #904 design.

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

* Name laws, not review rounds; gate the emitter per frame (#843, #895 round 5)

- Replace every review-round tag in the sources with the law or issue it
  stands for; the CI gate rejects them.
- The emitter's credit gate is the pure is_credited(free) = free >= 1 and
  the Rate law says why it is per frame: f(b) reaches 192 frames at
  12 MiB, beyond the lane's whole window W = 16, so a per-cell gate would
  never hold. A real cell waits at most for the one cell in flight, never
  for a cover cell committed to a window with no room for its first
  frame. A pure test pins the gate at free in [1, f(b)).
- Volume hiding notes that a refused, requeued slot is empty.
- Core re-exports LaneCreditView beside Swarm::application_credit; the
  transport imports the type instead of naming its full path.
- The shell's Dropped(p) row names #966 and #969.

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

* Open a session at a fin with T; a failure replaces the queued effects (#843)

- The exit's phase diagram and the Bound law say a T-frame is a data or a
  fin carrying T, and label the Unbound mismatch edge: no reply, and the
  tombstone answers the next loop with abort(0).
- perform: a failed write, seal or reply send replaces the effects still
  queued with the failure's own, so no write runs against a failed world
  and nothing leaves after the abort. Tested on the pure step.

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

* Name the tcp world's failures as its own; read the target without zeroing (#843)

- client.rs: the fin row and the Target law say a fin before the first
  reply carries T, as OnionClientSession::fin does.
- OnionTcpReader reads into one reused BytesMut, limited to the reply
  capacity, instead of zero-filling a fresh buffer on every read.
- The tcp world's connect, read, write and shutdown failures are
  OnionRouteError::TcpTarget, not HttpRequestError.

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

* Build the lane credit view only where a backend can make one (#913)

Without rings_transport_backend no connection exists, so
InnerTransportCallback::send_credit_view, LinkCredit::view and
LaneCreditView::new are dead and fail the backend-free clippy. They are
gated on rings_transport_backend, as reserve_credit is; closed(), free()
and credited() stay ungated.

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

* Let the onion data plane live as long as its registration (#843)

The link sender shut down when the last OnionRuntime clone dropped
(OnionRuntimeLife::drop), which closes every lane and refuses every
later open. A node that installs the data plane and drops the returned
handle, as the gateway fixture's relays and exit and the browser
WebView test's relays do, then kept its registered shell, which still
peeled cells, but every relayed cell met LinkDown: the captured flow and
the browser fetch never got their exit's ack and timed out.

The lifetime now follows the registration: OnionCircuitShell's Drop shuts
the link sender down, so a relay whose handle was dropped still relays,
and no onion task outlives the protocol. OnionRuntimeLife is removed.

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

---------

Co-authored-by: Ryan Kung <662346+RyanKung@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#880: an idle link keeps a cover floor r_idle = 1 unit/s (<= r), and a link
that turns active stays at r for at least D = Q, extended by every real cell,
so activity is visible only at the granularity of D. The emission law, the
leakage table and the V&V row state it.

#843, as merged in #895, changes D2' in four places:
- fin(n, phi) carries phi, and the target while T is set, so a fin before the
  first reply opens the session with an empty upload;
- abort(n) = 03 || n fails the session, carries no reason, is applied on
  arrival and is never an end of stream;
- h answers a refused or failed open with abort(0);
- a loop of a session h closed within V, or refused, is answered with abort(0)
  under that loop's own SURB.

Order now counts data, fin and abort. The frames V&V row and Algorithm Reply
follow.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
RyanKung and others added 26 commits November 9, 2026 21:07
… CRDT, relay holder, and onion admission
…re compat, reset sync, relay recovery, and admission retention
…redecessors and passive gap-bridge hints (#1195) (#1197)

* feat(core,papers): non-transitive connectivity resilience via multi-predecessors and passive gap-bridge hints (#1195)

* fix(core,papers): resolve CI typos, clippy too_many_arguments, and wire goldens pin (#1195)

* fix(core,papers): resolve PR #1197 review findings on bridge fallback, handoff target guard, and hint capacity bounds (#1195)
…IdbStorage and FileStore (#1225) (#1233)

* fix(core): enforce batch capacity/order and transaction atomicity in IdbStorage and FileStore (#1225, #1209, #1210)

* fix(core): enforce oversized batch capacity law and single-turn IDB batch writes (#1225)
…rialize close witness (#1199) (#1234)

* fix(transport): prevent native WebRTC Failed callback deadlock and serialize close witness (#1199, #1204)

* chore(ci): resolve clippy and cfg(test) target lints

* chore(ci): use async fn for webrtc_like_close test helper
…sor ownership, and prune stale predecessors (#1221) (#1235)

* fix(core): fix passive bridge routing blackholes, isolate find_successor ownership, and prune stale predecessors (#1221)

* fix(core): gate wasm-unused test helper and update chain rejoin model bounds (#1221)

* fix(dht): preserve strict bridged target and reset local relay holder query

* test(core): avoid full-mesh auto-connect race in strict bridge lookup test (#1221)
…rce final admission on all locally terminated frames (#1219) (#1236)

* fix(core): bind relay.destination to transaction.destination and enforce final admission on all locally terminated frames (#1219)

* fix(swarm): refine local termination, handler forwarding, and post-lane final admission
… glare finger rebind, and session key renewal (#1222, #1223) (#1237)

* fix(swarm,node): resolve pending offer slot leak, backoff accounting, glare finger rebind, and session key renewal (#1222, #1223)

* chore(ci): resolve clippy and cfg(test) target lints
…d exit writes, and abort dropped HTTPS fetches (#1200) (#1238)

* fix(onion): decouple session I/O progress, bound exit writes, and abort dropped browser fetches (#1200)

- Decouple TCP pump_halves command enqueueing and partial-safe local writes (write_buf) from blocking opposite-direction reads and idle_timeout, and decouple OnionSessionDriver loop sends and reply event drainage so saturated bounded queues never deadlock (#1200, #1205)
- Drive exit world writes and shutdowns concurrently with inbound session frames, link loss, and periodic expiry ticks in OnionExitSessions so stalled upstream sockets never wedge abort/expiry teardown or leak OnionExitLease (#1211)
- Arm an RAII FetchCancellationGuard in browser HTTPS exit fetches so future drop, timeout, status/header/content-length rejection, or mid-stream byte budget overflow cancels the response body/reader and aborts the AbortController (#1216)

* chore(ci): resolve clippy and cfg(test) target lints

* chore(ci): resolve Rust 2021 let-chain, clamp, and wasm cfg(test) lints

* chore(ci): use TestTask::spawn in onion tcp and exit session tests (#1200)

* fix(node): bound exit socket writes with timeout, close on fin after eof, and abort on dropped receiver (#1200)
…rnation, and key inbox by full tx identity (#1202) (#1239)

* fix(core): retry lost relay custody declarations, bind handoff acks to custody incarnation, and key inbox consumption by full tx identity (#1202)

* chore(ci): resolve clippy and cfg(test) target lints

* fix(core): bound custody incarnation metadata across key eviction
…al/uncertain writes (#1203) (#1240)

* fix(core): stage CarrierStore recovery accounting and reconcile partial/uncertain writes (#1203, #1205)

* chore(ci): resolve clippy and cfg(test) target lints

* fix(core): fence in-flight removal writes across caller cancellation

* style(core): format carrier store and removal store tests
…redit starvation, and EntryGuard 1-in-K gate (#1224) (#1241)

* fix(core,node): harden onion admission, SURB credit window, outbox waiters, and entry guard (#1224, #1229)

- Align OnionAdmissionState slot_capacity and shares with SlotRoster::capacity(), prevent reconnecting peers from co-locating onto slots occupied by another live peer, and retain prior charges across reconnects.
- Cap OnionCreditWindow::credit_blocks to target.min(ONION_SURB_POOL_CAPACITY) and trigger refill on large-class deficits so large SURB classes neither starve nor over-mint, and roll back registered client tags on send/enqueue/build failures.
- Fix OrderedSendState::wake_one waiter double-decrement and forward armed wake-ups in OnionLaneWait::drop so endpoint waiters neither underflow nor lose wake-ups.
- Enforce the EntryGuard 1-in-K gate when persisted guards exist, track unreachability against first_hop_permitted eligibility, and require directly connected peers for onion proxy first-hop guards across native and browser readers.
- Replace hidden &self / bool::then mutations in SlotRoster and OnionAdmissionState with explicit &mut self APIs and control flow.
- Add direct unit tests for onion::runtime::install, release::stores_released, sync_lock::lock, and reconnect charge retention.

* chore(ci): resolve Rust 2021 let-chain, clamp, and wasm cfg(test) lints

* fix(node): rank unretained and lower-load slots before exhausted grace-period slots and preserve relocated peer charges (#1224)
…lesce namespace link facts (#1201, #1213, #1214) (#1242)

- Decouple half-duplex progress and bound stalled local TCP writes/shutdowns in pump_tcp_halves via OutboundWrites partial-write slices and top-level select! cancellation/write deadlines (#1201, #1205)
- Key UDP relay flow reservations, promotions, activations, and teardown by UdpFlowKey { listener, src } so multiple UDP listeners on one RelayHandle isolate flows from a shared client socket (#1213)
- Replace unbounded NamespaceQueue link-fact backlog with an indexed ordered queue that coalesces unread per-peer Admitted/Retired/Dropped churn while preserving causal link lifetimes around queued envelopes (#1214)
- Add unit and regression tests for TCP write stalls/half-duplex progress, multi-listener UDP flow isolation, and namespace link-fact coalescing
…s, and truncate torn journal tails (#1212) (#1243)

* dns-relay: retry failed withdrawal removes, stage binding commits, and truncate torn journal tails (#1212)

* fix(dns-relay): settle unconfirmed journal appends across cancelled bindings
…, and docs (#1206) (#1245)

* fix: harden pre-1.0 publish graph, FFI/RPC/delegation contracts, deps, and docs (#1206, #1215, #1217, #1228)

- Add rings-runtime, rings-aez, rings-network-policy, and rings-dns-relay to
  scripts/cargo-publish-crates.sh in topological order, scan target-specific and
  build dependencies, and validate completeness/order against cargo metadata.
- Update the C FFI signer callback to an explicit (proof, out, out_cap) -> size_t
  contract supporting Ed25519 (64B), EIP-191/secp256k1/BIP-137 (65B), secp256r1
  (64B), and BLS12-381 (96B), with Ed25519 construction and invalid-length tests.
- Enforce private-key-to-delegation DID binding and delegator signature validity
  when deserializing/parsing DelegateeKey and building/renewing Processor keys,
  while preserving restoration of bound historical keys for decryption.
- Enforce delegation ts_ms lower-bound validity with TS_OFFSET_TOLERANCE_MS under
  both Verify and Established authorization paths, and pass judged time through
  SignedDescriptor::descriptor_is_live_at.
- Fix rings_rpc::jsonrpc::Client::connect_with_did to return
  Result<ConnectWithDidResponse> with a compile-time DTO witness (#1215).
- Consolidate duplicate Provider::from_processor initialization and
  Extensions::prepare_runner preparation (#1217).
- Remove 18 unused direct dependencies, isolate rings-transport default Tokio
  features and native-only dotenvy from WASM builds, and set default-run on
  rings-mailbox-example.
- Reconcile 1.0 wire-versioning, delegation renewal, config.yaml required/optional
  fields and delegator_key, CLI --storage-path help, WASM/CORS/architecture/rustdoc
  guides, and add UPGRADING.md (#1206, #1228).

* fix(node): align descriptor clock checks, test delegations, and cli rustdoc scope

* chore(dweb): clarify browser provider connection flow in README and bump anyhow
…nvoke symbols (#834) (#1246)

* feat(onion): implement Phase 2b Kleisli pipelines with intermediate invoke symbols (#834)

- Extend the closed onion signature Sigma = {relay} + Sigma_I + Sigma_W with discrete OnionWidthClass, OnionInputType, OnionLatencyClass, OnionInvokeShape (invoke<In,Out,L>), OnionShape, and canonical open OnionBackendName (1..=32 bytes over [a-z0-9._-]) (D1', D6'').
- Add OnionInvokeArguments (canonical 64-byte layer argument codec sealing (omega, a')), OnionInvokeStage, OnionInvokePipeline, and OnionPipelineTerm with Free(G_Sigma) type/position checking, relay normal-form elimination, and L3 loop-class selection (D3, D4a, L1, L2, L3).
- Add signed OnionInvokeDescriptor / OnionInvokePolicy under ONION_INVOKES_TOPIC, OnionInvokeOffer rungs on OnionRole (preserving Reg_n != empty => relay in Reg_n), and per-(node, shape, backend) OnionInvokeAccounting quota enforcement (D2).
- Add multi-stage guard-closed OnionPipelineRoute selection and directory lookup across Processor and OnionDirectoryReader (D5, L7).
- Execute one-shot intermediate and terminal invoke hops in OnionInvokeRegistry / OnionCircuitShell with pure L3 output-width checking, L4 release-at-deadline timing and overrun dropping, and one-shot client reply tag delivery in OnionClientTags / OnionLoopClient / OnionPipelineHandle / NativeOnionCircuitHandle.
- Pin updated wire goldens and add unit, law, route-selection, and real-link 8-hop/11-hop Kleisli pipeline integration tests.

* fix(core,node): bump WIRE_VERSION to 4, pin core wire goldens, and fix typo (#834)

* fix(onion): enforce direct-peer guard on pipeline routes, keep WIRE_VERSION=3, and hold invoke lease through L(f)

* fix(onion): use cross-target async test attributes in invoke tests
…tones/floors, and unique-value water-filling (#1220) (#1247)

* fix(core): harden EntryKind::Data CRDT provenance, actor-scoped tombstones/floors, and unique-value water-filling (#1220)

* fix(core): resolve clippy lints and update wire golden pin (#1220)

* fix(core): harden CRDT wire proof decoding, unsigned floor defenses, and RemovalRecord proofs

* fix(core): stop retain_fair_data_elements at max_len cutoff to preserve join associativity
… mail/provider shutdown, and async lock hygiene (#1231) (#1248)

* fix(node,core,transport,dns-relay): harden transition_gate, mail shutdown, and async locks (#1231)

- Detect same-task re-entry into Runner::transition_gate via poll-scoped active-runner tracking (native + wasm32) so re-entrant Scope::inject fails fast instead of self-deadlocking
- Decouple JsShell outbound network sends from transition_gate via bounded per-peer FIFO OverlayOutbox spawned onto NodeTasks
- Race Recipient::poll and Recipient::run against StopToken and per-poll timeout, and bound Provider::close / Processor::close shutdown drain phases when storage or network stalls
- Add SlowLockGuard wait/hold observability and narrow DhtRegistrationPublisher::held and PeriodicMeasure evidence query lock scopes while preserving lock-order invariants
- Drain and invoke Notifier::wake wakers outside NotifierState mutex

* fix(node): preserve uncertain evidence admissions across rollback and report store-release timeout
@RyanKung RyanKung changed the title Release 1.0: merge develop into master Release 1.0.0-rc.0: merge develop into master Nov 10, 2026
let added = stamped_delta(EntryOperation::Extend, topic, &["stale"], now_ms, writer)?
.try_into_storage_entry()?;
let removed = added.tombstone(added.clone())?;
let mut record = seeded_removal_record(topic, 511, now_ms, "batch", Principal::Local)?;
last_key = key;
last_topic = topic;
} else {
let record = seeded_removal_record(topic, 512, now_ms, "batch", Principal::Local)?;
// Remaining Local capacity is 7,168 - 4,096 = 3,072; fill all 3,072 without refusal.
for extra in 0..6u32 {
let extra_topic = Did::from(500 + extra);
let extra_carrier = removes_only_carrier(extra_topic, 512, now_ms, "extra")?;
let now_ms = get_epoch_ms();
for idx in 0..4u32 {
let topic = Did::from(200 + idx);
let record = seeded_removal_record(topic, 512, now_ms, "cancel", Principal::Local)?;
}
}

let (record, collector) = provisional_evidence_fixture(9);
claim.epoch.slot.saturating_sub(1),
)
};
let record_a = make_record(11);
)
};
let record_a = make_record(11);
let record_b = make_record(21);
@RyanKung RyanKung changed the title Release 1.0.0-rc.0: merge develop into master Release 1.0.0-pre.1: merge develop into master Nov 10, 2026

This branch has not been deployed

No deployments
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.

3 participants