Repository navigation
Conversation
) 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>
… CRDT, relay holder, and onion admission
…re compat, reset sync, relay recovery, and admission retention
…invalidation to preceding arc
…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)
…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)
…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
…, 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
| 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); |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Bump workspace version to
1.0.0-pre.1and synchronizemasterwithdevelopcovering all 191.0Milestone PRs (#1233–#1250) and the upstreamwebrtc0.17.3upgrade (#1251):#1199,#1090— fix(core): enforce batch capacity/order and transaction atomicity in IdbStorage and FileStore (#1225) #1233, chore(transport): upgrade webrtc to 0.17.3 and drop vendored webrtc-sctp #1251):Failedcallback deadlock in native WebRTC close and serializeNativePhysicalCloseWitnesscompletion (fix(core): enforce batch capacity/order and transaction atomicity in IdbStorage and FileStore (#1225) #1233).webrtcandwebrtc-sctpto0.17.3(incorporatingwebrtc-rs/webrtc#941batched SCTP packet dispatch) and remove the vendoredcrates/transport/vendor/webrtc-sctppatch (chore(transport): upgrade webrtc to 0.17.3 and drop vendored webrtc-sctp #1251).#1206— fix(transport): prevent native WebRTC Failed callback deadlock and serialize close witness (#1199) #1234):rings-test-support), FFI/RPC/delegation contracts (DelegateeKeydeserialization verification), dependency cleanup, and 1.0 documentation (fix(transport): prevent native WebRTC Failed callback deadlock and serialize close witness (#1199) #1234).#1203,#1212,#1220,#1225— fix(core): fix passive bridge routing blackholes, isolate find_successor ownership, and prune stale predecessors (#1221) #1235, fix(core): bind relay.destination to transaction.destination and enforce final admission on all locally terminated frames (#1219) #1236, fix(node,gateway,mailbox,webview): close nodes cleanly, drain TUN & bridge duplex, and harden SMTP/WebView bounds (#1226) #1244, fix(core): harden EntryKind::Data CRDT provenance, actor-scoped tombstones/floors, and unique-value water-filling (#1220) #1247):CarrierStorerecovery accounting and reconcile uncertain/partial commits (fix(core): fix passive bridge routing blackholes, isolate find_successor ownership, and prune stale predecessors (#1221) #1235).IdbStorage::put_batchcapacity/order,FileStore::store_batchnet budget, and transactionalaccess_clockpersistence (fix(node,gateway,mailbox,webview): close nodes cleanly, drain TUN & bridge duplex, and harden SMTP/WebView bounds (#1226) #1244).EntryKind::DataCRDT provenance (EntryOperationProof), scope tombstones/floors by actor, and water-fill unique values across actors (fix(core): harden EntryKind::Data CRDT provenance, actor-scoped tombstones/floors, and unique-value water-filling (#1220) #1247).#1202,#1219,#1221,#1222,#1223— fix(swarm,node): resolve pending offer slot leak, backoff accounting, glare finger rebind, and session key renewal (#1222, #1223) #1237, fix(node): prevent onion session TCP pump/driver queue deadlock, bound exit writes, and abort dropped HTTPS fetches (#1200) #1238, fix(core): retry lost custody declarations, bind handoff acks to incarnation, and key inbox by full tx identity (#1202) #1239, fix(core): stage CarrierStore recovery accounting and reconcile partial/uncertain writes (#1203) #1240):DelegationLessee::within_marginto hop delegations (fix(swarm,node): resolve pending offer slot leak, backoff accounting, glare finger rebind, and session key renewal (#1222, #1223) #1237).relay.destinationtotransaction.destination, enforce final admission on all locally terminated/redirected frames, fix passive bridge routing blackholes, isolatetopology::find_successorownership from bridged hints, and prune stale predecessors (fix(core): retry lost custody declarations, bind handoff acks to incarnation, and key inbox by full tx identity (#1202) #1239).send_connection_offerpending slots on error, fix candidate dial backoff accounting, and cancel superseded finger leases on glare rebind (fix(core): stage CarrierStore recovery accounting and reconcile partial/uncertain writes (#1203) #1240).#834,#1200,#1224— fix(core,node): fix OnionAdmissionState live-slot co-location, SURB credit starvation, and EntryGuard 1-in-K gate (#1224) #1241, fix(dns-relay): retry failed withdrawal removes, stage binding commits, and truncate torn journal tails (#1212) #1243, feat(onion): implement Phase 2b Kleisli pipelines with intermediate invoke symbols (#834) #1246):OnionAdmissionStatelive-slot co-location eviction,SlotRostercapacity bounds, andOnionEntryGuards1-in-K gate (fix(dns-relay): retry failed withdrawal removes, stage binding commits, and truncate torn journal tails (#1212) #1243).OnionInvokePipeline, directory route selection, and multi-hop pipeline session drivers) (feat(onion): implement Phase 2b Kleisli pipelines with intermediate invoke symbols (#834) #1246).#1201,#1226,#1231— fix(node): bound TCP relay writes, key UDP flows by listener, and coalesce namespace link facts (#1201) #1242, fix: harden pre-1.0 publish graph, FFI/RPC/delegation contracts, deps, and docs (#1206) #1245, fix(transport,core,node): harden transition_gate re-entrancy, bounded mail/provider shutdown, and async lock hygiene (#1231) #1248):Processor/Providerclose, drain TUN gateway & bridge backpressure, and enforce SMTP/WebView bounds (fix: harden pre-1.0 publish graph, FFI/RPC/delegation contracts, deps, and docs (#1206) #1245).transition_gatere-entrancy, bounded mail/provider shutdown, and async lock hygiene acrossrings-core,rings-node, andrings-dns-relay(fix(transport,core,node): harden transition_gate re-entrancy, bounded mail/provider shutdown, and async lock hygiene (#1231) #1248).#1227,#1230— fix(ci,models): fix dead Miri filter, remove vacuous laws, and add exhaustive onion/repair/service Stateright models (#1227) #1249, feat(core,node): inject Clock and SharedRng capabilities into protocol handlers and onion session drivers (#1230) #1250):ClockandSharedRngcapabilities across protocol handlers and onion session drivers (feat(core,node): inject Clock and SharedRng capabilities into protocol handlers and onion session drivers (#1230) #1250).1.0.0-pre.1:Cargo.toml, example manifests,Cargo.lockfiles (Cargo.lock,examples/dweb/Cargo.lock,frontend/Cargo.lock),package.json,package-lock.json,README.md, anddocs/src/install-a-native-node.mdto1.0.0-pre.1.