Skip to content

feat(transport): the A/V spine on the live node (#805 item 4, #720) - #813

Open
emooreatx wants to merge 10 commits into
mainfrom
feat/805-av-spine
Open

emooreatx wants to merge 10 commits into
mainfrom
feat/805-av-spine

Conversation

@emooreatx

@emooreatx emooreatx commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

CIRISEdge#805 item 4. The A/V spine now rides the same ReticulumTransport, node and event loop as replication. #720 lands here too.

Design

  • How an A/V link is recognised. It is a third link plane, LinkPlane::Av (FSD §3.6). A link dialled to a derived address whose group is in the av-stream: namespace is an A/V link for life. That is the namespace av_addressing::session_group_id already used, now one constant (AV_STREAM_GROUP_PREFIX). The plane is set at establishment by the same reverse-index probe that separates scoped links from identity links (Identity-plane replication rides the self-room's derived-address link — D2 drops every row UNATTRIBUTED (dest = D1's scoped address matches no rooted peer); one link pool serving two planes (#722's actual cause) #728). No frame content picks the route.
  • Misrouting cannot be expressed. The event loop matches on the plane once per frame (LinkPlane::route). attribute_and_deliver is the only road to the replication sink, and it now takes an EnvelopePlane, which has no A/V variant. A/V frames go to deliver_av_frame and then the transport-owned AvSink. Resources are refused on A/V links (AcceptNone). If one completes anyway, it is dropped by name.
  • Attribution. Every A/V frame passes the same E3: transport source_key_id attribution is classical Ed25519-only + advisory-spoofable — gates trace:* confidentiality; make it Rooted∧owns_key by type + hybrid announce #393 gate. gate_attribution was extracted unchanged from attribute_and_deliver, and both paths call it. On the responder side the candidate peer comes from LinkIdentified. On the initiator side it is the peer open_av_link dialled, using that peer's verified-route transport key and the link proof. There is no unattributed A/V path.
  • Backpressure. Each link has a bounded queue (AvLinkQueue, which LinkDataPump now uses too). A full queue drops the newest frame. Nothing on the event loop waits. The receiver is told how many frames were dropped (InboundWireFrame::dropped_before), so the subscriber's dense hop counter skips the gap instead of desyncing. Drops are counted in a new EdgeMetrics::av_plane ledger, which also appears in the snapshot and the pyo3 dict. Drops log at DEBUG with a throttle.
  • Bounded resync. A frame is tried at the counter past the reported gap, then the expected counter, then up to 8 counters past the gap. That is at most HOP_COUNTER_MAX_OPENS (10) AEAD opens per frame, whatever the gap. The dropped frames' own counters are never tried.
  • Arrivals-full drops. Frames from a peer-opened link that find the arrivals queue full are dropped as ArrivalsFull and counted under av_inbound_dropped_arrivals_full. The drop count is remembered per link (bounded at 1024 links) and reported on the link's first delivered frame, so the hop resyncs.
  • Check: A/V chunks aren't sized against the link Channel's limit (the #716 six-byte margin) — an oversized chunk would read as SendFailed, not Congested #720. LeviculumAvSender checks frame size against link_channel_message_limit before calling leviculum. That limit is the link MDU minus CHANNEL_ENVELOPE_HEADER_SIZE, capped at u16, and it shares one helper with the fragmenter. An oversized frame is refused as the typed ChunkTooLarge, and the spine reports it as LegOutcome::Oversized. A refused or congested send no longer burns the hop counter, because the bytes never left the process.
  • API. ReticulumTransport::open_av_link(peer, &MemberAddress) and take_av_arrivals().
  • edge_node. TransportMediaLink, which used the Resource path, is replaced by AvPlaneMediaLink, which uses the Channel path over A/V links. Each node installs the call's addresses before Ready, and members read media from the A/V arrivals. Observers keep the federation send because it is a refusal probe.

Witness

tests/av_spine_live_node_805.rs runs on real loopback Reticulum. A publishes through AvSpine while a replication round and an identity-plane Deliver run on the same transports. B receives all chunks in order and byte-identical. The rows land and A's round completes. B's replication listener holds no A/V-link frame. The av_plane counters match exactly. A replication frame forced onto the A/V link stays on the A/V side. An oversized chunk is refused as ChunkTooLarge, and the next chunk still opens.

With only the A/V route reverted to the envelope path (what main does), the test fails:

the A/V link never arrived at B's A/V consumer; 1 A/V chunk(s) reached B's REPLICATION listener instead; av_plane={}

Review follow-up: with the gap report disabled, the arrivals-full witness fails (the hop never resyncs). The old candidate set made 1009 opens for the junk-frame case, against the new bound of 10.

Against origin/main the test does not compile, because open_av_link, take_av_arrivals and av_plane do not exist there.

Not witnessed over real links: a 500-byte-MTU link. Loopback TCP negotiates a 16 KiB link MTU. The check reads the live link's MDU, and the 425-byte arithmetic is pinned by unit tests.

Dependencies and scope

  • Metrics. The branch is rebased on v40.1.0 (release(40.1.0): leviculum v0.29 — known-destinations backpressure, off-lock resource assembly, and transport counters that finally reach production #810, leviculum v0.29.0+ciris.1). The av_plane counters use the transport's attached metrics slot. The witness builds each Edge through EdgeBuilder with no with_metrics, and asserts the counters land in Edge::metrics().snapshot(). The av_plane ledger is in the Rust snapshot, the pyo3 dict, and the UniFFI counters as av_plane.<label>.
  • Public enums that gained variants. None of the three is #[non_exhaustive]:
    • transport::reticulum::LinkPlane gains Av.
    • transport::av_spine::LegOutcome gains Oversized.
    • transport::realtime_av_dispatcher::AvDispatcherError gains ChunkTooLarge.
  • Other public additions. observability::EdgeMetricsSnapshot gains the av_plane field (the struct is not #[non_exhaustive]). AvLinkReceiver gains a defaulted recv_frame method. AvDelivery is pub(crate). The version bump is the lead's call.
  • Out of scope: the seal, nonce or DEK; the host call API; replication semantics.

🤖 Generated with Claude Code

https://claude.ai/code/session_011ksniaWCvWoUwFQHRG3HQL

emooreatx added a commit that referenced this pull request Oct 6, 2026
…ops (#813 review)

- hop_counter_candidates is {next+gap, next, next+gap+1 ..= next+gap+SLACK}:
  at most HOP_COUNTER_MAX_OPENS (SLACK+2) AEAD opens per frame whatever the
  reported gap. The dropped frames' own counters are never tried. Both open
  loops pick the counter through open_at_first_counter.
- A peer-opened link whose frames find the arrivals queue full is
  AvDelivery::ArrivalsFull, counted as av_inbound_dropped_arrivals_full,
  remembered per link (bounded at 1024 links), and reported as
  dropped_before on the link's first delivered frame.
- Witnesses: a junk frame after 1000 drops costs exactly SLACK+2 opens and
  the post-gap frame opens first try; an arrivals-full link with more drops
  than the slack reaches resyncs a real subscriber (fails with the gap
  report disabled).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ksniaWCvWoUwFQHRG3HQL
emooreatx added a commit that referenced this pull request Oct 6, 2026
…ops (#813 review)

- hop_counter_candidates is {next+gap, next, next+gap+1 ..= next+gap+SLACK}:
  at most HOP_COUNTER_MAX_OPENS (SLACK+2) AEAD opens per frame whatever the
  reported gap. The dropped frames' own counters are never tried. Both open
  loops pick the counter through open_at_first_counter.
- A peer-opened link whose frames find the arrivals queue full is
  AvDelivery::ArrivalsFull, counted as av_inbound_dropped_arrivals_full,
  remembered per link (bounded at 1024 links), and reported as
  dropped_before on the link's first delivered frame.
- Witnesses: a junk frame after 1000 drops costs exactly SLACK+2 opens and
  the post-gap frame opens first try; an arrivals-full link with more drops
  than the slack reaches resyncs a real subscriber (fails with the gap
  report disabled).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ksniaWCvWoUwFQHRG3HQL
@emooreatx
emooreatx marked this pull request as ready for review October 6, 2026 03:57
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@emooreatx

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-08T22:25:41.110782Z 337c5cd New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e8d2a1db35

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/transport/reticulum.rs Outdated
crate::av_addressing::AV_STREAM_GROUP_PREFIX,
)));
}
self.check_blackhole(&dest_hash.into_bytes()).await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce peer blackholes before opening A/V links

When an operator blackholes a peer using one of the federation destination hashes exposed by the path table, open_av_link checks only the unrelated, session-derived address. Unlike the regular send path, which checks every resolved candidate to prevent alternate-address bypass, this allows the blackholed peer to remain reachable through any A/V address. Resolve the peer first and apply the deny-list to all of its federation candidates before dialing the derived address.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 43ed890. open_av_link now checks the deny-list on the derived address and on every federation candidate the peer resolves to, as the regular send path does (av_dial_target, src/transport/reticulum.rs).

Witness: tests/av_spine_live_node_805.rs blackholes B by its federation destination, then asserts open_av_link to B's call address fails with PeerBlackholed naming that hash. With the candidate loop removed, the dial succeeds: Ok(AvLink { .. }).

🤖 Generated with Claude Code

Comment on lines 615 to 617
// Malformed wire — skip this frame, keep pulling.
let Ok(sealed) = SealedAvChunk::from_bytes(&bytes) else {
let Ok(sealed) = SealedAvChunk::from_bytes(&frame.bytes) else {
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve drop reports when rejecting malformed frames

If queue overflow drops more than the eight-counter resync slack and the first subsequently queued frame is malformed, that frame carries the sole nonzero dropped_before report, but this branch discards it. AvLinkQueue::offer has already reset its drop accumulator, so the next valid frame reports zero while its sender counter remains far ahead; it and every later frame then fail authentication permanently. The subscriber and equivalent relay path need to retain the reported gap across parse/open failures until a frame authenticates.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 43ed890. A new HopGap (src/transport/realtime_av_dispatcher.rs) adds up each frame's dropped_before and clears it only when a frame authenticates. Both the subscriber loop and the relay pump use it, so an unparseable or unopenable frame can no longer discard the report.

Witnesses:

  • a_junk_frame_does_not_swallow_the_drop_report: both junk shapes, each carrying a gap larger than the slack.
  • the_relay_pump_keeps_the_drop_report_across_junk.

With the gap carry reverted, they fail with "chunk 1: the hop lost its drop report" and "the relay lost the drop report: nothing forwarded".

🤖 Generated with Claude Code

Comment thread src/transport/av_sink.rs Outdated
Comment on lines +240 to +242
QueueOffer::ConsumerGone => {
links.queues.remove(&link_id);
AvDelivery::ConsumerGone

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not re-admit links after their consumer is gone

When a link's receiver is dropped, this removes its only ownership record, so the next frame takes the unclaimed-link branch and emits a fresh AvArrival. This makes a rejected peer-opened link repeatedly reappear, and for an open_av_link caller that intentionally drops the unused inbound half (as edge_node does), two reverse frames cause an initiator-opened link to be surfaced through the API that promises only peer-opened links, with the peer's destination reported as though it were an inbound address. Retain whether the link was claimed/previously arrived, or close/tombstone it when its consumer disappears.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 43ed890. On ConsumerGone the link's queue entry now stays in the sink, as a tombstone, until LinkClosed calls forget(). Later frames on it stay ConsumerGone and are never routed down the unclaimed-link branch. Live links bound the map.

Witness: av_sink::tests::a_link_whose_consumer_is_gone_never_arrives_again covers a link this node dialled whose inbound half was dropped, and a peer-opened link that arrived and was then rejected. Neither is ever surfaced again. With the old remove, it fails: left: Arrived.

🤖 Generated with Claude Code

@emooreatx

Copy link
Copy Markdown
Contributor Author

Conflict to carry, found while porting #853 (the responder-side inbound reap): this PR routes A/V frames to deliver_av_frame, bypassing attribute_and_deliver, the only writer of link_last_inbound_at. Once both land, a peer-opened A/V link carrying media reads as idle and is reaped 120 s into a call. Whichever merges second must stamp link_last_inbound_at in deliver_av_frame (preferred) or treat LinkPlane::Av as busy, with a witness that a live call's link survives the bound.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3471dcddb6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/bin/edge_node.rs
Comment on lines +4468 to +4470
if let Err(e) = opened {
tracing::warn!(peer = %m, error = %e, "A/V link did not open (CIRISEdge#805)");
av_open_failures.push(e);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail fan-out measurements when A/V links fail

When any member's A/V dial fails, this records the error but deliberately continues; every later delivery to that member fails, yet perf.publish_fanout still sets ok solely from frames_sent == cfg.frames, and frames_sent increments once per source frame regardless of successful delivery. Consequently even a run with zero opened A/V links can report this required benchmark leg as successful and calculate fanout_chunks_per_s as though every subscriber received every chunk. Include av_open_failures and delivery_failures in the verdict and base delivered throughput on successful member sends.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 43ed890. perf.publish_fanout now uses fanout_verdict(). It requires every source frame sealed, zero A/V link-open failures, and zero member delivery failures.

  • fanout_chunks_per_s now counts member sends the link accepted (member_sends_delivered), not frames × roster.
  • A late joiner's frames before its Ready opens its link are counted separately (frames_awaiting_link). They are neither a failure nor a delivery.

Witness: edge_node tests::the_fanout_verdict_fails_when_members_did_not_get_the_stream. With the old frames_sent == frames_requested, it fails: "no A/V link opened to two members: not a fan-out".

🤖 Generated with Claude Code

Comment thread src/transport/reticulum.rs Outdated
Comment on lines +4221 to +4222
let plane = classify_link_plane(self.scope_addresses.get().map(Arc::as_ref), &dest_hash);
if plane != LinkPlane::Av {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify that the A/V address belongs to the requested peer

When two federation key IDs resolve to the same transport identity, passing peer B's A/V address together with peer C's destination_key_id passes this plane-only check and the link proof can still succeed. The initiator then records C in av_dialed_peer, so reverse frames on a link actually opened to B's member address are attributed through C's #393 binding. Resolve the InboundAddress here and reject it unless member_key_id() equals destination_key_id.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 43ed890. Before any dial, open_av_link resolves the address's table entry and refuses with Config("… belongs to member <B> …") unless member_key_id() equals destination_key_id (av_dial_target).

Witness: av_spine_live_node_805 dials B's call address under another directory key id and gets the refusal by name. Without the check, that call reached dial resolution and failed differently (Unreachable).

The fixture cannot construct two key ids that share one transport identity. The check runs before identity resolution, so the shared-identity case is refused the same way.

🤖 Generated with Claude Code

Comment on lines +4273 to +4277
Ok(id) => id,
Err(e) => {
// The dial registered nothing that outlives it: a link that
// never established closes, and `LinkClosed` forgets both.
return Err(e);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Tear down partially opened A/V links on dial errors

If the derived link establishes but identification subsequently fails, before_establish has already claimed an AvSink queue and inserted av_dialed_peer, but this error arm returns without closing the link or removing either record. Because no AvLink is returned, the caller has no link ID with which to invoke link_teardown; repeated identification failures can therefore leave live links and sink queues consuming the node's bounded link capacity until an independent close event occurs. Explicitly clean up the registered state and close the partially opened link before returning the error.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 43ed890. open_av_link records the link id the dial dispatched. On any dial error after registration, undo_partial_av_dial removes the sink queue and the av_dialed_peer record and closes the link.

Witness: av_spine_live_node_805 forces two identify failures on established links through a new seam, fail_next_derived_identifies_for_test. Then the sink count and the dialed-peer count are back to baseline, and no established A/V-plane link remains. With the cleanup disabled, it fails: left: 2 against a baseline of 0.

🤖 Generated with Claude Code

Comment on lines +8494 to +8495
drop_inbound(Some(link_id), DROP_AV_FRAME_UNATTRIBUTED, &detail);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Report attribution drops to the hop-counter consumer

When the #393 gate is temporarily false, every successfully sent A/V frame is dropped here before reaching AvSink, so none contributes to InboundWireFrame::dropped_before. This is readily longer than the eight-counter recovery slack because the binding cache deliberately retains a stale false verdict for up to three seconds; at video cadence the first frame admitted after recovery can therefore be dozens of counters ahead, will not authenticate, and all subsequent frames remain permanently ahead as well. Track these per-link pre-sink drops and report the gap on the next admitted frame, or close the link so a redial resets its counter.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 43ed890, the preferred way. In deliver_av_frame, the #393 refusal arm now calls AvSink::note_dropped_before_sink. It counts the frame into the link's next dropped_before, or into the pre-arrival count for a link with no consumer yet. HopGap then skips the counter past the window once the binding recovers.

Witness: av_sink::tests::frames_refused_by_the_gate_still_resync_the_hop. It sends frame 0, then 45 gated frames (about 3 s at 15 fps, five times the slack), then recovery, and a real subscriber opens the first admitted frame. With the count disabled, it fails: "chunk 46: the hop never re-authenticated".

This is witnessed at the sink and subscriber seam, not over real links. Holding a real binding stale-false for 3 s is not practical in the loopback fixture. The production refusal arm calls the same method.

🤖 Generated with Claude Code

emooreatx added a commit that referenced this pull request Oct 8, 2026
…ery departure (#853, main port of v40.0.10) (#854)

* fix(transport): the responder closes idle inbound links, and every link departure is counted (#853)

Main port of v40.0.10 (971a605 + dc5b296), with two design changes.

- Link direction from LinkEstablished { is_initiator }; inbound_links /
  outbound_links gauges; EdgeLinkInfo.direction in the UDL and the
  regenerated bindings.
- Responder-side idle reap on the pool tick (inbound_link_idle_bound,
  default 120 s, 0 disables), node-authoritative teardown; busy:
  link_in_flight, inbound transfers, outbound ships, sender-side
  resource progress, LXMF serve activity, the attribution handshake.
- {inbound,outbound}_link_closed_by_reason with one token per way a
  link leaves, and reconcile_vanished_links pruning links the node no
  longer holds as `vanished`.

Changed from the release line:
- The LXMF serve exemption is BOUNDED: a link is busy for one inbound
  idle bound after its last LXMF activity (a request, its response
  handed to the link), then the normal rule applies. A sender-side
  resource still making progress (e.g. a large LXMF response) is busy
  too. Witness (f): fails with the exemption made for-life and with it
  removed.
- hold_link_open / release_link_hold removed: no A/V caller on main,
  and #813 tracks link ownership through LinkPlane::Av + AvSink.
- Main has no P0 telemetry (#820) yet, so the new metrics are in
  EdgeMetrics / EdgeMetricsBundle only; their PyO3 / UniFFI flattening
  arrives with #820's port.

Gates: fmt; clippy --all-targets -D warnings on CI's three sets; lib
filtered 819/853/link/reap/metrics/observability 84 passed (84 under
ffi-uniffi); 6 integration files 57 passed; links_ffi +
peer_mutation_ffi + witness under ffi-uniffi 30 passed; bindings regen
matches; 0 failed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5FDihqVKTSXyNYs2CLnrK

* fix(transport): mark the swallow seam's early return for the #425 scanner (#853)

inbound_exits_are_instrumented_or_marked failed the v40.0.10 tag run on
the bare `return;` after the swallow_link_closed test seam in the
LinkClosed arm. The seam is empty in production and the swallowed close
is still counted as `vanished` by reconciliation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5FDihqVKTSXyNYs2CLnrK

* fix(transport): the four reaper review fixes (#853, Codex on #854)

1. Outbound use restarts the idle clock. A successful outbound send on a
   link (a Channel-first frame fully sent on the scoped lease and both
   reverse-path arms; a Resource whose completion future resolved) stamps
   link_last_outbound_at, kept apart from link_last_inbound_at so the #353
   reverse-path selector still reads only inbound liveness. The idle clock
   is max(last inbound, last outbound, establishment). An initiator-only
   peer's link this node keeps answering on is no longer reaped under it.
2. Idle selection skips links the node no longer holds, so a queued
   LinkClosed is never pre-empted and misfiled as idle_expired; those wait
   for their event or reconciliation's `vanished`.
3. A local teardown counts the close under its cause, closes at the node,
   then runs the full LinkBook::forget eagerly (plus LXMF, pool stamp,
   scoped pool): a lost LinkClosed leaks no attribution, stamps or
   transfer marks.
4. Two consecutive idle observations. leviculum 0.27's std handle does
   not surface a link's last-inbound (link_stats has none; core's
   last_inbound_secs counts keepalives), so a link is closed only by the
   second pass that finds it idle with the same (last inbound, last
   outbound) snapshot; activity queued behind the timer lands between.

Witnesses, each failing before its fix (mutation runs, file restored):
- (g) a_link_this_node_keeps_sending_on_is_not_reaped_854: without the
  outbound stamp, A's reply path is gone under its own sends.
- the_idle_reap_skips_links_the_node_no_longer_holds_854: without the
  node filter, idle_expired is counted for a link the node had closed.
- a_local_teardown_forgets_the_link_everywhere_854: without the eager
  forget, attribution/stamps/transfer marks remain.
- (h) a_link_is_reaped_only_on_the_second_idle_observation_854: with one
  observation, the first pass closes the link.

Gates: fmt; clippy --all-targets -D warnings on CI's three sets; lib
filtered 819/853/854/link/reap/metrics/observability/inbound_exits 87
passed (87 under ffi-uniffi); 6 integration files 59 passed; links_ffi +
peer_mutation_ffi + witness under ffi-uniffi 32 passed; 0 failed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5FDihqVKTSXyNYs2CLnrK

* fix(transport,replication): end the pool churn, and let the responder speak (#853)

From the Datum link-storm trace (storm-v4006): ~890 `link identified`
and ~825 PeerClosed per 10 min from one v40.0.6 initiator, and no
responder line at the canonical's RUST_LOG=info.

- No PoolFull close on release: release_link_after_transfer no longer
  trims the pool to the idle cap, and the reaper applies no cap either;
  pooled lanes die only by the idle bound. pool_full is retired (always
  0, kept for its series); idle_cap is accepted but not enforced.
- LinkClaim: a pool lane's transfer claim is an RAII guard released on
  Drop, so a send cancelled mid-ship (the responder's 60 s reply
  timeout) no longer retires its lane.
- A pooled lane closing ChannelExhausted or Stale logs a throttled WARN
  with running counts (the starved-responder signature).
- Responder round lifecycle at INFO (started; both completion paths
  with duration_ms; reply failures stay WARN), with peer, kind and the
  arrival link; counter responder_rounds_total{kind, outcome}.
- responder_link_up_seconds{stage: bundle_gate, announce_intake,
  owner_binding} from LinkEstablished on a peer-dialled link, and
  responder_link_up_total{ok, bundle_refused, timeout, link_gone}.
- EdgeMetrics only on main (accessors responder_rounds_total() /
  responder_link_up_seconds() / responder_link_up_total()); the bundle
  and both bindings carry them when #848 lands. FixedHistogram /
  HistogramSnapshot come over verbatim from the release line.

Witnesses: a_burst_closes_nothing_on_release_and_the_bound_retires_it_853
(14 concurrent sends, cap 1), a_cancelled_send_gives_its_lane_back_853,
each_responder_round_outcome_increments_its_token_once_853,
a_responder_link_up_lands_in_a_bucket_and_counts_its_outcome_853.
Fail-before: with the release trim + reaper cap restored the burst
witness fails (12 lanes closed pool_full); with LinkClaim's Drop a no-op
the cancel witness fails (the lane stays claimed).

Gates: fmt; clippy --all-targets -D warnings on CI's three sets; lib
filtered 819/853/854/link/reap/metrics/observability/inbound_exits 89
passed (89 under ffi-uniffi); 6 integration files 60 passed
(link_pool_819 3 runs green); links_ffi + peer_mutation_ffi + witness
under ffi-uniffi 32 passed; 0 failed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5FDihqVKTSXyNYs2CLnrK

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
emooreatx added a commit that referenced this pull request Oct 8, 2026
…ops (#813 review)

- hop_counter_candidates is {next+gap, next, next+gap+1 ..= next+gap+SLACK}:
  at most HOP_COUNTER_MAX_OPENS (SLACK+2) AEAD opens per frame whatever the
  reported gap. The dropped frames' own counters are never tried. Both open
  loops pick the counter through open_at_first_counter.
- A peer-opened link whose frames find the arrivals queue full is
  AvDelivery::ArrivalsFull, counted as av_inbound_dropped_arrivals_full,
  remembered per link (bounded at 1024 links), and reported as
  dropped_before on the link's first delivered frame.
- Witnesses: a junk frame after 1000 drops costs exactly SLACK+2 opens and
  the post-gap frame opens first try; an arrivals-full link with more drops
  than the slack reaches resyncs a real subscriber (fails with the gap
  report disabled).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ksniaWCvWoUwFQHRG3HQL
emooreatx added a commit that referenced this pull request Oct 8, 2026
- open_av_link applies the operator deny-list to every federation
  candidate of the peer, not only the derived address.
- open_av_link refuses an A/V address whose table entry names a different
  member than destination_key_id, before dialling, so a link to one member
  is never attributed to another.
- A derived dial that fails after registering its consumer (identify
  failure on an established link) undoes the sink queue and the dial
  record and closes the link.
- HopGap: a reported drop gap survives junk frames until a frame
  authenticates, in the subscriber loop and the relay pump.
- A link whose consumer dropped its receiver stays recorded in the sink
  until LinkClosed and is never re-surfaced as a fresh arrival.
- Frames refused at the #393 gate are counted into the link's next
  dropped_before, so the hop resyncs after a stale-binding window.
- edge_node: perf.publish_fanout fails on link-open or member-send
  failures; throughput counts accepted member sends; a late joiner's
  pre-link frames are reported separately.

Each fix has a witness that fails with the fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5FDihqVKTSXyNYs2CLnrK
emooreatx added a commit that referenced this pull request Oct 8, 2026
…undo

clippy::too_many_lines (122/100) after the #813 review fixes. The checks
(plane, member ownership, deny-list, transport key) move to av_dial_target
and the failed-dial cleanup to undo_partial_av_dial. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5FDihqVKTSXyNYs2CLnrK
emooreatx and others added 9 commits October 8, 2026 16:55
A link dialled to a derived address in an A/V session's group (the
av-stream: namespace) is LinkPlane::Av for life, classified at
establishment by the same reverse-index probe as Scoped. The event loop
matches on the plane once per frame: Identity/Scoped take the envelope
path (attribute_and_deliver now takes an EnvelopePlane with no A/V
variant), Av goes to the transport-owned AvSink. Misrouting is
unrepresentable rather than checked.

- Attribution: A/V frames pass the same #393 gate (gate_attribution,
  extracted unchanged from attribute_and_deliver), responder side by
  LinkIdentified, initiator side by the peer open_av_link dialled.
- Backpressure: per-link bounded queue (AvLinkQueue, shared with
  LinkDataPump), drop-newest, never awaits; drops counted in the new
  EdgeMetrics::av_plane ledger and reported to the consumer so its dense
  hop counter skips the gap (InboundWireFrame::dropped_before).
- #720: LeviculumAvSender sizes the frame against the link Channel
  limit (link MDU minus CHANNEL_ENVELOPE_HEADER_SIZE, capped at u16) and
  refuses with the typed ChunkTooLarge; the spine reports
  LegOutcome::Oversized. A refused or congested send no longer burns the
  hop counter.
- API: ReticulumTransport::open_av_link / take_av_arrivals.
- FSD §3.6 + invariant 10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ksniaWCvWoUwFQHRG3HQL
Two loopback transports with field-shaped identities; A publishes
through AvSpine over an A/V link while a replication round and an
identity-plane Deliver run between the same transports. B receives
every chunk in order and byte-identical, replication completes, no
A/V-link frame reaches B's replication listener, a replication frame
forced onto the A/V link stays on the A/V side, and an oversized chunk
is refused as ChunkTooLarge without burning the hop counter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ksniaWCvWoUwFQHRG3HQL
TransportMediaLink (Transport::send, Resource path) is replaced by
AvPlaneMediaLink: one open_av_link per member to its address in the
call's av-stream: group, each chunk one link-Channel message. Members
install the call's addresses before Ready and read media from
take_av_arrivals into the same mailbox. Observers keep the federation
send as a refusal probe. perf.publish_fanout reports av links opened,
open failures, chunks refused too large and congested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ksniaWCvWoUwFQHRG3HQL
…ops (#813 review)

- hop_counter_candidates is {next+gap, next, next+gap+1 ..= next+gap+SLACK}:
  at most HOP_COUNTER_MAX_OPENS (SLACK+2) AEAD opens per frame whatever the
  reported gap. The dropped frames' own counters are never tried. Both open
  loops pick the counter through open_at_first_counter.
- A peer-opened link whose frames find the arrivals queue full is
  AvDelivery::ArrivalsFull, counted as av_inbound_dropped_arrivals_full,
  remembered per link (bounded at 1024 links), and reported as
  dropped_before on the link's first delivered frame.
- Witnesses: a junk frame after 1000 drops costs exactly SLACK+2 opens and
  the post-gap frame opens first try; an arrivals-full link with more drops
  than the slack reaches resyncs a real subscriber (fails with the gap
  report disabled).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ksniaWCvWoUwFQHRG3HQL
…Edge::metrics()

open_av_link reads the transport's attached bag (OnceLock, #810) instead
of the pre-#810 Option. The live-node witness now builds its transports
with no with_metrics and an Edge over each through EdgeBuilder, and
asserts the av_plane counters land in Edge::metrics().snapshot() on both
sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ksniaWCvWoUwFQHRG3HQL
metrics_snapshot carries every EdgeMetrics::av_plane entry as
av_plane.<label>, beside #810's transport.* counters, so the A/V plane is
visible to mobile operators as it is in the Rust snapshot and the pyo3
dict. Contract test the_uniffi_snapshot_carries_the_av_plane_ledger_805
(fails with the projection removed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ksniaWCvWoUwFQHRG3HQL
A/V-plane frames route to deliver_av_frame and never entered
attribute_and_deliver, the only place link_last_inbound_at was stamped.
CIRISEdge#853's responder-side idle reap reads that stamp, so a peer-opened
A/V link carrying media would read as idle and be reaped mid-call. The
stamp is set after the #393 gate passes, so an unattributed flood cannot
keep a link alive, and a quiet A/V link still ages out. The reply
selector also reads the map but never selects an Av link.

Witness: av_spine_live_node_805 asserts B holds a last-inbound stamp for
the A/V link; it fails with the stamp removed. The quiet-link reap test
waits on #853's seams reaching main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5FDihqVKTSXyNYs2CLnrK
- open_av_link applies the operator deny-list to every federation
  candidate of the peer, not only the derived address.
- open_av_link refuses an A/V address whose table entry names a different
  member than destination_key_id, before dialling, so a link to one member
  is never attributed to another.
- A derived dial that fails after registering its consumer (identify
  failure on an established link) undoes the sink queue and the dial
  record and closes the link.
- HopGap: a reported drop gap survives junk frames until a frame
  authenticates, in the subscriber loop and the relay pump.
- A link whose consumer dropped its receiver stays recorded in the sink
  until LinkClosed and is never re-surfaced as a fresh arrival.
- Frames refused at the #393 gate are counted into the link's next
  dropped_before, so the hop resyncs after a stale-binding window.
- edge_node: perf.publish_fanout fails on link-open or member-send
  failures; throughput counts accepted member sends; a late joiner's
  pre-link frames are reported separately.

Each fix has a witness that fails with the fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5FDihqVKTSXyNYs2CLnrK
…undo

clippy::too_many_lines (122/100) after the #813 review fixes. The checks
(plane, member ownership, deny-list, transport key) move to av_dial_target
and the failed-dial cleanup to undo_partial_av_dial. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5FDihqVKTSXyNYs2CLnrK

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 43ed890136

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/bin/edge_node.rs
Comment on lines 1689 to +1690
let bytes = encode_frame(KIND_MEDIA, header, chunk);
self.transport
.send(to, &bytes)
.await
.map(|_| ())
.map_err(|e| e.to_string())
match sender.send(&bytes).await {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fragment encoded units before sending them over the A/V Channel

With the mesh harness's default 500-byte MTU, the new sender accepts only a 425-byte Channel message, but this wraps an entire H.264 NAL from annexb_access_units—plus the sealed-chunk and JSON framing overhead—into one message. Normal 320×240 keyframe/slice NALs are substantially larger, so LeviculumAvSender returns ChunkTooLarge and the benchmark drops those video frames instead of exercising live A/V delivery. Split/reassemble media at the Channel limit, or otherwise size the generated units to the actual per-link budget.

Useful? React with 👍 / 👎.

Comment thread src/bin/edge_node.rs
Comment on lines +4737 to +4742
fanout_verdict(
frames_sent,
cfg.frames,
av_open_failures.len(),
delivery_failures,
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Count frames skipped while waiting for a late member's link

When a mid-stream joiner's Ready is delayed, its sends take the frames_awaiting_link branch and are skipped without incrementing either av_open_failures or delivery_failures. Although the new verdict checks those two failure counts, it can therefore report perf.publish_fanout as successful after omitting one or more frames for the late member—or even if that member never becomes ready. Include frames_awaiting_link in the verdict or validate the expected number of accepted member sends.

Useful? React with 👍 / 👎.

Comment thread src/bin/edge_node.rs
Comment on lines +4485 to +4487
for m in &admitted {
let opened = match occ.call_address(m) {
Some(addr) => link.open(m, &addr).await,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route A/V links through the configured relay topology

In the checked bench-mesh/compose.yaml topology, the publisher is directly attached only to relay-1, while subscribers bootstrap through that relay; nevertheless this dials each subscriber's announce-suppressed derived address directly. The transport explicitly treats such addresses as broadcast-only and only a directly attached neighbor can answer, while the relay is not admitted to the cohort and has no call address of its own. Consequently the actual relay-backed mesh reports NoRouteToPeer for every subscriber A/V link instead of carrying media; construct publisher→relay→subscriber A/V hops or make the benchmark topology directly attach the endpoints.

Useful? React with 👍 / 👎.

Comment on lines 8241 to 8244
match table.and_then(|t| t.accepts_inbound(dest.as_bytes())) {
Some(a) if crate::av_addressing::is_av_stream_group(a.group().group_id()) => LinkPlane::Av,
Some(_) => LinkPlane::Scoped,
None => LinkPlane::Identity,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve A/V classification across address retirement

If an inbound link establishes against a superseded A/V address just before seal_rotation retracts that address, but its queued LinkEstablished event is handled just after the seal, this live-table lookup returns None and permanently records the link as Identity. Retirement intentionally leaves already-established links running, so subsequent media on this link is routed into the envelope/replication path rather than the A/V sink. Preserve the destination's plane at acceptance or retain enough retired-address metadata to classify links whose establishment races the seal.

Useful? React with 👍 / 👎.

Comment thread src/bin/edge_node.rs
Comment on lines +1696 to +1698
Err(AvDispatcherError::Congested) => {
self.congested.fetch_add(1, Relaxed);
Err(AvDispatcherError::Congested.to_string())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pace the nonblocking A/V sender after congestion

Under normal Channel pacing, LeviculumAvSender::send returns Congested when try_send sees Busy or PacingDelay, but this new media-link implementation converts that signal directly into a dropped delivery. The publisher's frame loop has no frame-rate sleep or backoff and immediately attempts the next frame, so after the first accepted packet it can run through the 15-fps source in milliseconds while repeatedly hitting the same pacing window and dropping most of the stream. Defer/retry according to PeerBackoff, or pace the source before continuing to the next encoded frame.

Useful? React with 👍 / 👎.

…ction)

Two A/V links A dials to B's call address: one carries a frame every
250 ms, the other goes quiet after its first. With B's inbound idle bound
at 2 s, B's #853 reap passes close the quiet link (its consumer's
receiver ends) and leave the live one, whose frames deliver_av_frame
stamps as last-inbound liveness, delivering. LinkBook::forget now clears
the A/V sink and dial records, so every way a link leaves (event,
teardown, reap, vanished) ends its consumer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5FDihqVKTSXyNYs2CLnrK
@emooreatx

Copy link
Copy Markdown
Contributor Author

Rebased onto main b4d4cd4 (#854, the #853 inbound reap). The fixes referenced as 43ed890 in the seven review-thread replies are unchanged; their commits are now 2148bf8 (the seven fixes) and 82ad73e (the open_av_link split). The head is 337c5cd.

New in this round:

🤖 Generated with Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 337c5cd2de

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +10101 to +10102
sender: crate::transport::realtime_av_runtime::LeviculumAvSender::new(node, link_id)
.with_metrics(metrics),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refresh liveness when sending on arrived A/V links

When a peer-opened link is used in the documented reverse direction (a subscriber dials a relay and the relay streams through AvArrival.sender), this plain LeviculumAvSender never updates link_last_outbound_at; successful sends only increment AV_SENT. The responder-side idle reaper therefore continues measuring from the initial inbound frame or establishment and closes the actively streaming link after the default 120-second bound. Wire successful sends from this sender into note_outbound_use (or otherwise exempt/mark the link busy) so outbound-only A/V traffic keeps an inbound link alive.

Useful? React with 👍 / 👎.

self.check_blackhole(&candidate.dest_hash.into_bytes())
.await?;
}
let Some(transport_ed25519) = candidates.first().map(|c| c.transport_ed25519) else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Try all viable transport keys for derived A/V dials

When the rooted announce cache still contains a peer's old transport identity while the resolver has its rotated identity, resolve_dial_candidates deliberately returns candidates paired with different signing keys, but this unconditionally selects the first cached key. The derived A/V address itself is independent of those federation destination hashes, so the link proof is attempted with the stale key and open_av_link fails even though a valid current key is present later in the candidate list. Try the distinct candidate keys, or select the authoritative current binding, rather than always borrowing the first candidate's key.

Useful? React with 👍 / 👎.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant