Repository navigation
Releases: CIRISAI/leviculum
Release list
v0.30.0+ciris.1 — direct links through NAT (#70), link telemetry (#77)
Added
- Direct-link upgrade (+ciris, leviculum#70): NAT traversal for two nodes
whose link runs through a transport node. Each learns its public UDP address
from a facilitator, the two swap addresses over the link, and both punch.
On success the link moves onto a new point-to-point UDP interface, and
that interface announces the node's destinations, so paths drop to one hop.
On failure (symmetric NAT) nothing changes. If the direct interface later
dies, the link goes back to its previous interface.- Wire-compatible with rns-rs's DirectLink protocol: the same channel
signals (MSGTYPE0xFE00..=0xFE04), RNSP/STUN probes, punch frames and
punch token. This is an independent implementation, not a port; rns-rs is
under the Reticulum License, which AGPL code cannot absorb. Interop was
verified in all three combinations: rns-rs as relay and facilitator,
rns-rs accepting a leviculum proposal, and rns-rs proposing to leviculum. - Off by default. Config keys
probe_port,probe_addr,probe_protocol
anddirect_connect_policy(defaultreject), or
ReticulumNodeBuilder::direct_link. API:
ReticulumNode::propose_direct_link,direct_link_interface, and in
coreNodeCore::{propose_direct_link, take_direct_link_jobs, direct_link_probed, direct_link_punched}. Events:NodeEvent:: {DirectLinkEstablished, DirectLinkFailed, DirectLinkLost}. - Only propose to a leviculum or rns-rs peer: a Python RNS peer's channel
stalls behind the unknown signal. - Hardened through five rounds of Codex review on PR #74. Highlights:
- The link MTU drops to 1400 when the link moves onto the direct
interface. - A one-way failure falls back to the relay, and both ends converge on
it after an authenticated nudge. - Only decrypted traffic over the relay can trigger fallback.
- Signals pass a full application sink.
- A link with a transfer under way moves only once the transfer is
done.
- The link MTU drops to 1400 when the link moves onto the direct
- Wire-compatible with rns-rs's DirectLink protocol: the same channel
- Link telemetry (leviculum#77, CIRISServer FSD UNIFIED_TELEMETRY).
Everything below is onReticulumNode, and most of it onNodeCoretoo:link_list(): every link with its caller-visible id, destination,
role, state, age, idle seconds, measured RTT (Noneuntil sampled) and
interface.link_census(): live links by role, overall and per destination
(CIRISEdge#819). The initiator keeps a link alive with its keepalives,
so a rising initiator count names a node that does not close what it
dialled.link_lifecycle(): established by role, closed byLinkCloseReason,
handshake failures by reason, and requests the application refused.
established - closed_totalequals the live census.link_count_check(), plus an event-loop alarm. If the completion
mirror and the core disagree on the established-link count for more
than 30 s, aLINK_MIRROR_DIVERGEDevent is raised, once per episode.heap_census(): the core's accounted heap by component, on std hosts.publish_metrics()records 24 catalogued metrics
(leviculum_std::telemetry::METRIC_CATALOG) through themetricscrate
facade. There is no exporter dependency; the host installs the recorder.
Counters are process totals, so several nodes sum and a late recorder
still reads full totals. Every label value is a bounded enumeration.
event_catalog_completenessnow also checks metric names and label
keys against the catalogue.
- A test pinning the invariant CIRISEdge relies on: a resource whose link
closes during off-lock assembly fails exactly once, asLinkClosed,
before the link'sLinkClosed, and its late result completes nothing.
Changed
reject_linkrefuses only a pending inbound request. Any other link is
closed the wayclose_linkcloses it, with a close packet and
LinkClosed. Before, it dropped the link silently, so the completion
mirror and the counters went on treating it as alive.
Consumer notes
- Additions only; no existing public signature changed.
NodeEvent(non-
exhaustive) gainsDirectLinkEstablished,DirectLinkFailedand
DirectLinkLost.leviculum_std::ErrorgainsDirectLink. - New dependency in leviculum-std:
metrics0.24, with default features
off. THIRD-PARTY-NOTICES is regenerated. - Direct links are inert unless configured. With the default policy
(reject) and no facilitator, the node never proposes, refuses every
request, and the event loop never looks for direct-link work.
v0.29.0+ciris.1 — off-lock resource assembly (#71) and the eviction counter (#49)
Changed
- Incoming resources are assembled off the node lock (leviculum#71). The
decrypt, bz2-decompress and hash-verify that follow a transfer's last part
ran under the node lock, so concurrent transfers on different links
serialized on it (CIRISEdge#739: 8 concurrent ~940 KB chunks). The std
driver now runs that work on blocking threads, at most 1 to 4 at a time by
CPU count, and concludes each result under a brief lock. Events and proofs
are identical to in-lock assembly.ReticulumNodeBuilder:: assemble_resources_off_lock(false)restores in-lock assembly for A/B
measurement or as an escape hatch. no_std and FFI callers are unchanged:
NodeCoreassembles inline unlessset_defer_resource_assembly(true).- A ready resource waits in its link until a worker is free, so only the
in-flight work is outside the node. A cancel, a duplicate advertisement,
the timeout poll and a link close treat it exactly as before. - A resource cancelled or closed while assembling fails as
Cancelledor
LinkClosed, and its late result is dropped. On shutdown, in-flight
assemblies are applied within 2 s and the rest fail asCancelled. - The completion proof may go out on a Stale link, so an assembly that
finishes after the link went quiet still completes the sender. - Hardened through four rounds of Codex review on PR #73.
- A ready resource waits in its link until a worker is free, so only the
Added
ReticulumNode::known_destination_evictions()(leviculum#49): a
cumulative count of identity-cap evictions, for a transport-stats counter.- The phased assembly API on
NodeCore(set_defer_resource_assembly,
take_resource_assembly_jobs,complete_resource_assembly,
abandon_resource_assemblies,pending_resource_assemblies) and the
opaqueresource::{AssemblyJob, AssemblyResult}.
Consumer notes
- No public function signature changed between v0.27.0+ciris.1 and this
release; there are additions only. ResourceError(not#[non_exhaustive]) gainedPartRequestTimeoutand
ProofTimeoutin v0.28.0 via upstream. Sender-side timeouts that read
Timeoutnow read one of those.NodeEvent(non_exhaustive) gainedPacketDroppedin v0.28.0 via upstream.- A receiver's
ResourceCompletednow arrives when the off-lock assembly
finishes, normally milliseconds after the last part. If a consumer abandons
a link without closing it, a completion on that link can still arrive.
Closing the link rules this out.
v0.28.0+ciris.1 — catch up to upstream master (+164) and the #49 fix
Catch-up to upstream master (+164 since the v0.27.0 base), plus the
known-destinations backpressure fix. The rebase applied cleanly: no carry
commit conflicted and none was absorbed.
Fixed (CIRIS fork)
- Known destinations under capacity pressure keep their pins and stay pruned
(leviculum#49). The identity cap evicts unretained destinations first. Each
flush holds the on-disk file to the cap instead of resurrecting what memory
evicted. Runtime retain and last-use state reach the file, disk pins are
seeded at load, and pruning is reported by the catalogued
KNOWN_DESTINATIONS_PRUNED event andMemoryStorage::identity_evictions().
Fixed on the fork's CI lanes
- Upstream's diagnostic-dump test asserted the "Census gap" line, which
follows an RSS read from/proc/self/statm. Only Linux has that file, so
the assertion is now Linux-only (the macOS and Windows lanes).
From upstream, relevant to library consumers
- Links over asymmetric paths to Python RNS 1.5 peers now form. A
link-request proof whose hop count disagrees with the frozen tables is
re-balanced as RNS 1.5 does (upstream Codeberg #330), without deleting the
route it corrects. - A local link routes its resource proof to the destination, not back to the
initiator. - A resource receiver paces its part timeout by the parts it actually sees.
- A sender waiting on a peer that stopped answering no longer outlives its
caller's deadline. - A path to a peer used five minutes ago no longer expires early, and a node
no longer holds back the answer to its own path request. - A stale echo installs no path but still reaches local clients, and a daemon
routes to its own returned client rather than through the mesh.
v0.27.0+ciris.1 — catch up to upstream master (+465)
Catch-up to upstream master (+465), including upstream's 0.9.0 release
(2026-09-18) and 44 entries in its current Unreleased section.
Absorbed upstream (dropped from the carry)
unregister_destinationonReticulumNode(leviculum#54).- NTFS-safe
lnflashsysfs fixtures (no:in committed paths). - The Linux-only gating of the BLE dependencies (leviculum#64).
- The Unix-only parent-directory fsync in the file identity store (leviculum#65).
- The self-spawned pipe-bridge child in the pipe interface tests.
Carried features ported onto upstream's changes
- Scoped transit (leviculum#51): the per-interface
transitflag now applies
inside upstream'sregister_interface_confighelper, and upstream's new BLE
interface declarestransit: truelike every other interface. - Control-plane drop ladder and watermark (leviculum#60): upstream moved the
drop counter into anArc<Counter64>shared with theEventReceiver
(Codeberg #419), which mints the overflow marker and resets the count. The
doubling warn ladder now resets when a new drop episode starts at a count of
one.plane_stats()keeps its own cumulative counters, because upstream's
counter is reset on every marker. - Per-peer outbound circuit breaker (leviculum#66): the shed counter uses
upstream'sCounter64, and the breaker ignores upstream's newpeerfield
onAction::SendPacket. A multi-peer interface shares one transmit queue,
so it keeps one breaker. - Upstream's new shared-instance disconnect path now passes the segment
assembler (leviculum#62), the plane counters (leviculum#60) and the breakers
(leviculum#66) todispatch_output, like every other call site.
Fixed on the fork's CI lanes (upstream's CI is Linux-only)
- Security: h2 0.4.19 (RUSTSEC-2026-0258) and rustls 0.23.45
(RUSTSEC-2026-0285). Taking upstream's lockfile had put the vulnerable h2
back. Both are reached only through lblogd and async-web-client. - Windows build: upstream's
announce-stall-probe(Codeberg #418) uses raw
FIFO descriptors. It is now Unix-only, with a stubmainelsewhere, and its
test is Unix-only. - macOS: APFS refuses non-UTF-8 file names (EILSEQ), which broke the two
foreign-filename store tests. They now accept that refusal and are Unix-only. - Windows: the UDP device-form test binds a broadcast address, as
UDPInterface.pydoes, and Windows refuses that bind. It is ignored on
Windows with that reason.
Consumer notes (CIRISEdge)
CooperativeStamper::generatenow takes a fourth argument,
&StampCancel. Pass&StampCancel::new()where no cancellation is needed.ControlPlaneOverflowis minted by theEventReceiver. It arrives ahead of
the backlog, at most once per second.- A peer's rejection surfaces as
ResourceError::RejectedByRemote, not
Cancelled. A failed middle segment emitsResourceFailed, and
request-carrying resources no longer emitResourceCompleted.
v0.26.0+ciris.1 — shed a peer that will not drain, before paying to mask it
Answers the deferred #63 question with the mechanism, now that the canonical
measured it: one peer of 130 taking every one of 34,390 dropped packets in
24 h — ~24/s sustained, no idle hour — while the other 129 dropped none, from
a producer that is legitimate and cannot be upgraded yet.
That is the case a deep absorbing queue handles worst. There is no catching
up, so the queue sits at its cap discarding continuously: the depth buys
nothing and the discard is silent to whatever is producing.
Where this sits
Reticulum puts flow control at Link/Channel and leaves the interface layer
best-effort — BufferFull → drop is correct there, not a bug. So this does
not try to make the interface reliable. It makes the shedding cheap and
visible, and leaves reliability to the layer that owns it.
What it does
- Sheds before masking.
dispatch_actionsmasks a packet — an Ed25519
signature plus an HKDF mask stream — and only then discovers the interface
will not take it, so a pinned queue paid full IFAC crypto for every packet
it was about to discard. The breaker runs before that; a shed packet costs a
state check instead of a signature. - Opens after three refused ticks. Tick granularity, not packet, so
"three tries" means three rounds of being refused rather than three frames
inside one congested burst. The cooldown starts at 500 ms and doubles per
failed probe to a 5 minute ceiling — deliberately the cadence a backed-off
producer should trickle at, so probe and trickle meet instead of beating
against each other. Exactly one packet is spent per half-open probe, and a
success both closes the circuit and resets the cooldown, so a peer that
recovers is not punished for having been unreachable. - Never sheds a proof, in any state. A proof is the delivery confirmation
the producer is waiting on. Drop it and the producer retransmits, raising
the offered load exactly when it is already too high — that is how a
congested link becomes a collapsing one. New data yields; confirmations of
data already accepted do not. - Pushes the signal.
NodeEvent::PeerCongestedis Control class, so the
notice that a peer is being shed cannot itself be dropped under load.
interface_stats()gains per-interfacecircuit_openandshed_packets;
plane_stats()gainsshed_packets_total. Per interface on purpose: #66
had 129 healthy peers beside the one that dropped, and a node-wide total
cannot name which.
Following the reference rather than inventing
Threshold and AIMD shape mirror Channel.py — window +1 per delivery, −1 per
timeout, pow(1.5, tries-1) backoff scaled by RTT and queue depth, teardown
after _max_tries.
The half this is not
The producer side — honouring try_send's Busy rather than the absorbing
send(), and backing off exponentially to a trickle — belongs to the
consumer, and is raised separately on CIRISEdge.
Tests: 11 state-machine cases plus 3 driving dispatch_output end to end
(shed-before-mask, proofs survive an open circuit, recovery reaches the event
stream). std lib 654 passed, core 1864 passed, all CI lanes green.
v0.25.0+ciris.1 — upstream +92, and three fixes found gating it
Catches the CIRIS fork up to upstream master 28de8362 (+92) and fixes three
things found while gating that catch-up.
Fixed — the retry queue is diagnosable and quiet (#63)
Reported from the live canonical, where the retry queue climbed past its
warning threshold and began discarding traffic while the log read as quiet.
Warnings now name their peer (a drop line said iface=7, and the accept line
that would have identified it had been suppressed by log dedup). The depth
watermark logs on a doubling ladder instead of per increment — ~10 lines an
episode instead of hundreds, with the last line before drops always emitted.
The drop warning states that any max-depth line below the cap understates the
peak, because a drop only fires at the cap. And plane_stats() gains
retry_queued, retry_queue_cap and retry_dropped_total, so the queue is
watchable as it climbs rather than announced by its first discard.
Whether backpressure should propagate to the producer instead of being
absorbed by the queue is a design change and stays open, now with the
instrumentation to decide it on evidence.
Fixed — BLE is Linux-only, so its dependencies must be too (#64)
Upstream's new BLE interface declared bluer and dbus unconditionally; both
pull libdbus-sys, which does not build on macOS or Windows. That reached a
consumer rather than only a CI lane — CIRISEdge's darwin and win_amd64 wheels
resolve this crate as a git dependency. The deps are now target-gated and the
modules cfg-gated; configuring a BLEInterface off Linux returns a typed
error naming the reason instead of failing to compile.
Fixed — a directory fsync is a Unix move, not a portable one (#65)
With the build fixed, the Windows lane ran tests for the first time this sync
and found a second, independent break. Upstream's cf0e24cc added a
parent-directory fsync after the identity save's rename. Correct on Unix; on
Windows File::open cannot open a directory at all, so the line did not skip
the flush — it failed the save with Access is denied. (os error 5) and took
every test that builds a node with it (20+ in driver::builder::tests, plus
api::tests::node_lifecycle_without_interfaces). leviculum-core was
unaffected. The fsync is now #[cfg(unix)].
Both Windows breaks are upstream's code and invisible to them — their CI is
Linux-only. This is what the fork's Windows lane is kept for.
Fixed — a live link survives a rotation, and sealing states its price (#52)
s6_live_links_survive_rotation was failing ~50% of runs. Measuring it found
a real defect and a wrong assumption in the test.
The driver masked with a key the peer had already retired: the event loop
refreshed its IFAC map at the top of each iteration and then parked in
select!, so a send that woke it was dispatched against the pre-rotation map.
The map is now refreshed immediately before every dispatch.
The rest is not a bug. install and activate are make-before-break, so
neither can lose a packet. seal retires the old key for inbound too, so
anything still masked with it is rejected on arrival — bytes in a socket, in a
retry queue, or in the peer's receive buffer. Sealing with no dwell after
activating strands that in-flight traffic. The obligation to leave a dwell is
documented on the rotation API, and the test now rotates the way an operator
must. Nothing imposes a dwell in the library: each phase is an explicit
operator call so the cutover moment stays the operator's to choose.
Evidence: rotation removed, 6/6 pass; install alone, 5/5; install+activate,
5/5; only sealing failed, with exactly one drops_ifac at the peer on every
failing run and none on any passing one. Stress after both fixes: 0/12
failures, was 4/8.
Upstream
The catch-up rebase was clean and every carried feature was symbol-audited
after. Two places where upstream's new code met the fork's carry, both
mechanical: the BLE interface's InterfaceInfo takes the declared-transit
flag (#51), and upstream's new dispatch_output call site for BLE peer-loss
threads the multi-segment assembler (#62).
The BLE target-gating is worth offering upstream — it makes their crate
buildable off Linux, with BLE fully available wherever BlueZ exists.
v0.24.0+ciris.1 — transfers of any size arrive as one delivery
A transfer of any size is one delivery (leviculum#62)
A payload past RESOURCE_MAX_EFFICIENT_SIZE (1 MiB − 1) is split into segments, and core delivers one ResourceCompleted per segment carrying that slice. That is right for leviculum-core — no_std, running on boards that cannot hold a multi-megabyte transfer in RAM — and wrong for a consumer: the reference fires its resource callback once per transfer, so code written to reference semantics decoded our first event as the whole message and got a body cut at the ceiling minus the metadata size.
leviculum-std now reassembles. Intermediate segments are absorbed; the final segment becomes one ResourceCompleted carrying the whole payload and segment 1's metadata. Measured end to end: a 2,109,497-byte payload that previously arrived as (1,3,1048575) (2,3,1048575) (3,3,12347) now arrives as (3,3,2109497).
- No new wire or event field. A link carries at most one incoming resource at a time, so
LinkIdis an exact correlation key. - Free when unused. Single-segment transfers and every sender-side event pass through untouched; a batch with nothing segmented does no extra work.
- Bounded, because the segment count is peer-supplied — per transfer (default 64 MiB,
ReticulumNodeBuilder::max_assembled_resource_size) and in aggregate across links at four times that. A transfer past the ceilings is neither dropped nor truncated: it degrades to per-segment delivery with an error naming the ceiling and the knob. A link that dies mid-transfer releases its partial. - Assembly runs before observation and emission, so the completion registry, the event tap and the consumer all see the same whole-transfer event.
The efficient-size ceiling was reported as a typo; the comment was (leviculum#61)
RESOURCE_MAX_EFFICIENT_SIZE = 1_048_575 is correct and must not change: reference RNS is 1 * 1024 * 1024 - 1 (Resource.py:116). Raising it to 16 MiB as reported would emit segments a reference receiver cannot accept — trading a consumer-side bug for a wire-compatibility break. What was wrong is the comment beside it, claiming a "3-byte length encoding (0xFFFFFF)", which made a correct value look like an off-by-one-hex-digit typo. It is now an expression with the reference cited, and NodeEvent::ResourceCompleted documents the per-segment contract where a consumer actually reads it.
Catch-up to upstream master @ 50388fc6 (+57)
The largest catch-up since the 0.8.1 line, almost all LNode firmware and radio work: boosted receiver gain rather than an unchosen default, a reception no longer abandoned for a transmission, the radio listening again before handing a packet upward, a settable inter-packet gap, register read-back at bring-up, airtime and listen-window reporting, and a plain reader for the LNode debug port. leviculum-std movement is mostly rnsd-interop coverage. Nothing touched the fork's carry.
Also fixed: a latent race in this fork's own conformance suite, which read the responder's live-link gauge immediately after the initiator's await returned. The two sides establish at different moments by design, so it was a race the test happened to keep winning until upstream timing shifted.
Gate: core 1,743 / std 605 / multi-segment 2/2 / destination-lifecycle 4/4 / scoped-transit 6/6 / request-response 3/3; clippy -D warnings; fmt; thumbv6m; full CI green.
v0.23.0+ciris.1 — readable saturation, and a signal before the cliff
Completes leviculum#60 from the live-canonical incident (CIRISEdge#508). Property 1 — the per-drop WARN flood — shipped in v0.22.0; this is the rest.
Three bounded structures shared one failure shape: invisible until exceeded, so "healthy" and "one event from lossy" looked identical.
Pre-threshold signal
The control plane now warns at 80% occupancy, once per saturation episode, re-arming only after it drains back below half. Before this, the first signal an operator got was the first drop — by which point the loss had already happened.
Readable at runtime
ReticulumNode::plane_stats() returns PlaneStats: depth, capacity and cumulative drops for both planes, plus live-link and recent-outcome occupancy against their limits. A dashboard can plot headroom instead of waiting for the cliff.
Cheap enough to poll on an interval — two atomic loads, two channel-permit reads, one brief registry lock. The node already held the channel senders, so occupancy needed no new plumbing; only the cumulative drop counters are new (one relaxed fetch_add on a path that was already dropping).
Declared policy, in one place
PlaneStats' doc carries the table of what each limit does at saturation, previously scattered across four comments in three files:
| structure | at saturation |
|---|---|
| control plane | drop newest, count it, deliver a ControlPlaneOverflow marker carrying the total |
| data plane | drop newest, silently — backpressure is the point |
| live-link mirror | nothing is dropped; the envelope is an alarm threshold only (leviculum#56) |
| recent-outcomes ring | wrap oldest-first; a late await_* for an aged-out outcome parks rather than resolving, bounded by the caller's timeout |
The end-to-end test asserts the gauges track the real lifecycle — one link established shows on both sides, occupancy returns to zero on close — which is leviculum#56's mirror invariant observed from outside the crate.
Gate: core 1,649 / std 587 / destination-lifecycle 4/4 / scoped-transit 6/6 / request-response 3/3; clippy -D warnings; fmt; cargo doc clean; full CI green.
v0.22.0+ciris.1 — live-link mirror fix, event reclassification, drop-log ladder
First fixes from the live-canonical incident (CIRISEdge#508 → leviculum#56–#60). Two defects fixed, one flood cut off at the source, one diagnosis answered, one refactor argued against with measurements.
The completion mirror no longer evicts a live link (#56)
ESTABLISHED_MIRROR_CAP evicted FIFO over live entries, so the oldest link lost its completion first — and the oldest links are the long-lived canonical peers whose completions matter most. 130 live evictions were observed in the field, each silently degrading an await_link_established to "resolves only at LinkClosed or node stop".
The comment justifying that cap ("well above the realistic concurrent-link envelope") was falsified by production, and the trade was bad regardless: the mirror self-cleans on LinkClosed, so its occupancy is the live set, and a LinkId is 16 bytes. The cap is now an alarm, not an evictor — 80% warns, the envelope logs an error naming the lifecycle question, growth past it reports on a doubling ladder, and nothing is ever dropped.
Removing the FIFO VecDeque also deleted an O(n) scan taken under the registry lock on every link close.
Resource events classified by what they carry (#59)
ResourceTransferStarted + ResourceCompleted were 86% of everything dropped from the lossless plane (556 + 630), crowding out link and path liveness. The split now follows content, not variant name:
- receiver-side
ResourceCompletedstays lossless at every segment — itsdatafield is the delivered payload, so dropping one would be silent data loss, strictly worse than the flood it relieves; - sender-side final-segment stays lossless — the transfer outcome, and what a completion await resolves on;
- sender-side intermediate segments and
ResourceTransferStartedbecome droppable — progress on our own upload, already covered byResourceProgressand superseded by the final completion.
Completion futures are unaffected: the registry observes at dispatch, before the sink.
Control-plane drops log on a ladder (#60, property 1)
One WARN per dropped event produced 1014 near-identical lines in 2000, burying the CONTROL_PLANE_OVERFLOW marker 30:1. Drops now log at 1, 2, 4, 8 … each carrying the running total and naming the next report point; the ladder resets when the marker flushes. No drop goes uncounted.
Not changed, deliberately (#58)
The ask was to split or shard the registry lock. Measured first: a stand-in with the registry's exact hot-path shape sustains 9.6M events/sec/core, against an offered 0.76 events/sec — seven orders of magnitude of headroom. futex_wait_queue_me on tokio workers is the idle state, and the reported 6.1M nonvoluntary vs 993k voluntary context switches is the signature of CPU-bound work against a container ceiling, not of blocking (blocking yields voluntarily). The issue stays open because the symptom is real and unexplained; a profile would settle it, and the split gets built the moment evidence points there.
Also
Catch-up to upstream 3b52f66a (+2: nRF/LoRa CRC counters, tier-3 CI list gate). h2 0.4.16 re-applied for RUSTSEC-2026-0258 (the rebase takes upstream's lockfile, which reverts it).
Gate: core 1,649 / std 587 / scoped-transit 6/6 / destination-lifecycle 3/3 / request-response 3/3; clippy -D warnings; fmt; thumbv6m; full CI green.
v0.21.0+ciris.1 — the request/response contract (#55), IFAC size defaults, h2
The request/response contract (leviculum#55)
#55 asked for send_response to be forwarded onto the std driver. It already is — ReticulumNode::send_response, beside send_response_resource and send_file_response, public on the same impl as register_request_handler, present at the tag the issue cites; an external crate holding an Arc<ReticulumNode> compiles a call to it. Nothing was blocked.
What the issue got right is that a careful consumer searched for the verb, missed it, and had no stated contract to build a serve path against. Both are fixed here.
Findability. NodeEvent::RequestReceived said "call send_response()" without saying where it lives. It now names NodeCore::send_response and the async ReticulumNode::send_response, plus send_response_resource for bodies past the link MDU.
Contract, measured rather than asserted, on the method's own doc and pinned by leviculum-std/tests/request_response_contract.rs:
Ok(())means handed to the link, not delivered. If the peer has vanished but this node has not yet processed the link's death, the reply is accepted and goes nowhere. A responder must not readOkas proof the requester got the bytes.- Once the link is known dead (closed here, or a
LinkClosedprocessed) a reply is refused withRequestError::LinkNotFound— a typed signal as soon as one is available. - Replying twice for one
request_idis accepted, so a retrying responder needs no bookkeeping; the requester may then see the response more than once.
Catch-up to upstream master @ 352c6a62 (+3)
Per-interface IFAC size defaults (upstream Codeberg #293). Two peers that disagree on the access-code length reject each other's frames in both directions, with nothing in either log naming the cause — an IFAC-protected link that looks like it never came up. Upstream replaced a medium-inferred grouping with per-interface-type defaults checked against the reference. The fork's scoped-transit paths pass an explicit ifac_size, so the conformance harness is unaffected (6/6); deployments relying on the default should re-read it after this bump. Also: the 32-bit usize gate now covers the core suite and no longer compiles out the compression module.
Security
h2 0.4.15 → 0.4.16 for RUSTSEC-2026-0258, re-applied — a rebase takes upstream's lockfile, which reverts it. Lockfile only; h2 enters solely via lblogd → axum → hyper, not leviculum-core/leviculum-std. Now a standing per-sync check until upstream's own tree carries the bump.
Gate: core 1,644 / std 585 / request-response contract 3/3 (10× stress) / destination-lifecycle 3/3 / scoped-transit 6/6; clippy -D warnings; fmt; thumbv6m; full CI green on all platforms.