Skip to content

feat(delegate): unsubscribe, subscription introspection, and pin every wire tag - #98

Open
sanity wants to merge 13 commits into
mainfrom
feat/delegate-unsubscribe-introspection
Open

feat(delegate): unsubscribe, subscription introspection, and pin every wire tag#98
sanity wants to merge 13 commits into
mainfrom
feat/delegate-unsubscribe-introspection

Conversation

@sanity

@sanity sanity commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Problem

Three things, all in the delegate API surface, all of the same shape: something that looks like it is guaranteed and isn't.

1. The wire-format pin covered one variant out of sixteen. inbound_delegate_msg_wire_format_is_stable asserted that InboundDelegateMsg's variant 0 is ApplicationMessage. Nothing covered OutboundDelegateMsg at all, and nothing covered any variant after the first. So any reorder that happened to leave ApplicationMessage in front was undetected — swapping UserResponse and GetContractResponse, for instance, reassigns two bincode tags and makes already-deployed delegate WASM decode each as the other. Silently: the bytes still parse, into the wrong variant.

That is not a hypothetical. Exactly that swap was written and staged during the work that led to this PR, in a change whose stated purpose was to protect the wire format.

2. InboundDelegateMsg's doc comment asserted a fact about the code that was false. It said OutboundDelegateMsg was already #[non_exhaustive]. It never has been. Anyone reasoning about whether a variant addition was source-breaking got the wrong answer from the documentation.

3. A delegate cannot ask what it is subscribed to. A delegate's subscription set lives in the node, not in the delegate: the WASM is instantiated per invocation and dropped immediately after, and the node replays subscriptions across a restart without running the delegate at all. So after a restart a delegate has no way to learn its own state. It can keep a parallel record in its secrets — which drifts from the node's exactly in the cases that matter — or re-subscribe to everything on every wake. freenet/freenet-core#5467 names this as blocking restart-replay.

Approach

Pin every tag, and fail closed in both directions. delegate_msg_variant_tags_are_pinned asserts the bincode tag of every variant of both enums. Two mechanisms keep it honest, because a pin that can rot is not a pin:

  • The tag map is an exhaustive match. #[non_exhaustive] has no effect inside the defining crate, so adding a variant without pinning it is a compile error.
  • A probe asserts that the tag one past the last known variant does not decode. Add a variant and update nothing else, and that tag becomes decodable and the test fails. Without this, the variant-count constants would be checked only against a list written by the same hand in the same commit, which is a restatement rather than a check.

PutContractRequest is covered too. The obvious shortcut is to skip it because building a ContractContainer is awkward; it is four lines, and a pin with a hole in it reads as coverage while providing none.

Assert the compatibility rules instead of stating them. delegate_wire_compat and struct_field_wire_compat establish what bincode actually does, because appending an enum variant and appending a struct field break in opposite directions and it is easy to carry the wrong intuition from one to the other:

change old sender → new receiver new sender → old receiver
append an enum variant fine, old tags unchanged hard error, unknown tag
append a struct field hard error, unexpected end of input silently ignored if the struct is terminal in its message; silent corruption if it is not

A struct field is the more dangerous of the two: bincode is positional and carries no field tags, so there is nothing for a decoder to skip. #[serde(default)] does not make a field optional on this path — it is a self-describing-format feature and protects serde_json only, which is easy to misread given ContractState::size_bytes carries it. The practical rule that follows is to prefer a new enum variant over a new field on an existing wire struct, since a variant is only ever seen by a peer that asked for it.

NodeDiagnosticsResponse is terminal in its message, which is the only reason a field can be appended to it without corrupting what follows. That property is invisible at the definition site, so it is now pinned rather than assumed.

Introspection as a host function, not a message variant. DelegateCtx::list_subscriptions is backed by two new V2 host functions in the existing freenet_delegate_contracts namespace. Host functions resolve by name at module instantiation, so this is additive for every existing delegate — one that does not import it is unaffected, and one that does fails to load on a node too old to provide it, with a named missing-import error. Compare a new enum variant, which fails mid-protocol at bincode decode with no way for the delegate to have checked first. Where a capability can be expressed either way, the host function has the better failure mode.

It returns Result<Vec<[u8; 32]>, i64>, not a bare Vec. An empty list and a failed enumeration mean opposite things to a caller deciding whether to re-subscribe, and collapsing them is how a delegate concludes its user's content is unpinned because a host call failed. For the same reason the off-WASM stub returns Err, so a host-side test cannot read "no subscriptions" out of a stub that never had any.

OutboundDelegateMsg stays not #[non_exhaustive], deliberately. The false doc comment is fixed by correcting it, not by making it true. freenet-core dispatches this enum in exhaustive matches with no wildcard (contract.rs, in the request loop and again in the app-message filter). Marking it would force those to grow _ => arms, and a newly added variant would then compile against the host with no handler — the delegate's request silently swallowed while the call reports success. That is the failure mode a delegate SubscribeContractRequest has today (freenet/freenet-core#4669), and the compile error is what stops the next one. InboundDelegateMsg keeps the attribute because its consumers are third-party delegate WASM, which can reasonably ignore an unknown variant. The asymmetry is now documented on both enums, and matches the position already taken in #82.

Also corrects subscribe_contract's doc comment, which said notification delivery was "a follow-up" (it works) while saying nothing about the fact that a delegate subscription registers no demand in the network — it does not pin the contract, enter the renewal set, or exempt it from eviction, so a delegate sees remote updates only while some other route keeps the node subscribed. The call succeeds either way and nothing distinguishes the two, which is why it belongs at the call site rather than in an issue.

Compatibility

Wire change: two variants appended, nothing moved. UnsubscribeContractRequest joins OutboundDelegateMsg and UnsubscribeContractResponse joins InboundDelegateMsg, both at tag 8. Every pre-existing tag is exactly where it was, so deployed delegate WASM built against an older stdlib is unaffected — the append-safe direction of the table above. A hand-built pre-0.9.0 ContractNotification payload is asserted to still decode unchanged.

Tag 8 is a deliberate allocation, coordinated with #82: that PR appends ScheduleWakeup / WakeupFired and moves to tag 9. Recorded as a comment on #82 so it does not live only in a working session.

The pin was made to fail before it was made to pass. Adding the two variants with nothing else changed produced five E0004 non-exhaustive-pattern errors — three in production code, including the FlatBuffers encoder that the OutboundDelegateMsg doc cites as the reason that enum is deliberately not #[non_exhaustive], and two in the tag map itself:

error[E0004]: non-exhaustive patterns: `OutboundDelegateMsg::UnsubscribeContractRequest(_)` not covered
    --> rust/src/client_api/client_events.rs:1543:52   <- production dispatch
    --> rust/src/delegate_interface.rs:1481:15          <- pinned_inbound_tag
    --> rust/src/delegate_interface.rs:1496:15          <- pinned_outbound_tag

So the guard is demonstrated rather than asserted: a variant cannot be appended without both the host-dispatch site and the wire pin refusing to compile.

The two new host imports require a node whose freenet-core registers them. That is a load-time failure by design, not a silent one. The host half is not in this PR — for host functions the usual stdlib-first order is inverted, since core's linker registration references no stdlib type and can land first. Coordinated with the agent owning crates/core/src/contract.rs.

Testing

New: delegate_msg_variant_tags_are_pinned, every_variant_is_covered_by_the_pin, an_unpinned_variant_fails_this_test, an_old_payload_still_decodes_after_appending_a_variant, a_new_variant_does_not_decode_on_an_old_receiver, the five struct_field_wire_compat cases including node_diagnostics_response_is_terminal_in_its_message, and three round-trip cases for the contract-id codec.

The old-payload tests use hand-built byte strings rather than values produced by this crate's own encoder. An encoder-produced payload would only prove the code agrees with itself, which is not the property under test.

[AI-assisted - Claude]

Also in this PR: two fixes in memory/buf.rs

Flagged here because it is a file otherwise unrelated to this change, so a reviewer should not meet it by surprise.

StreamingBuffer::from_ptr is a pub unsafe fn that had no documentation, because its entire doc comment — including the # Safety contract — was attached to total_remaining, a safe getter two lines above that takes no pointer. So the getter was documented as "Create a streaming reader from a buffer pointer" with a safety contract about a ptr it does not have, while the constructor whose callers must uphold that invariant said nothing at all. A method had been inserted into the middle of another method's doc block. Splitting them back apart also clears clippy::missing_safety_doc.

The second is a false positive: the non-WASM stub must keep the mangled __frnt__fill_buffer name to match the WASM import it stands in for, so it takes a narrow #[allow(non_snake_case)] with the reason recorded.

Neither is reachable by the lint gate. ci.yml:99 passes no --features. The clippy matrix does cover both wasm32-unknown-unknown and x86_64-unknown-linux-gnu, so target coverage is fine — but default = [], so feature = "contract" is off in both legs, and both of these live behind #[cfg(feature = "contract")]. The contract-side API has therefore never been linted. Filed as #100 rather than fixed here, because CI is a shared Full-tier surface and widening the lint surfaces a backlog of unknown size.

Verification

Run locally, both the gate CI actually applies and a stricter one, because those are different questions — the first is what merges, the second is what stops the next person inheriting warnings:

  • cargo clippy --target wasm32-unknown-unknown -- -D warnings — clean (CI's exact invocation)
  • cargo clippy --all-targets --features contract,net,testing,trace -- -D warnings — clean
  • cargo test --features contract,net,testing,trace — 111 passed, 0 failed; doc-tests 1 passed
  • cargo build --target wasm32-unknown-unknown --features contract — succeeds, which is the first time anything has compiled the cfg(target_family = "wasm") branch of list_subscriptions

Note for whoever rebases #82

It appends ScheduleWakeup / WakeupFired to these same two enums, at the tag this PR leaves free. Two things to know:

  • The new pin will catch a colliding append, which is the point — it fails as a compile error, not as a silent renumbering.
  • feat: scheduled wakeup primitive for delegates (ScheduleWakeup / WakeupFired) #82 currently reverts four fixes merged to main since July, including the fixed_size_field decode-panic hardening and the unknown_union_discriminant change. That is branch staleness rather than intent, but it needs handling in the rebase regardless of which order the two land in.

The pins were verified to run, and to fail

A filtered cargo test that matches nothing prints 0 passed and exits 0. That is indistinguishable from a clean run by exit code, and a reviewer cannot tell the two apart by reading the output either. So every check below asserts a test count, never rc:

wire-compat tests executed:            8   (each named in the output)
struct-field tests executed:           6
list_subscriptions guard tests:       10
rc for a filter matching NOTHING:      0   <- why rc is not evidence

Counting is necessary and not sufficient — a test that runs can still be one that cannot fail. That is exactly what review found in two of these guards, so both were mutated to confirm they now fail when the thing they guard is broken:

Mutation Result
INBOUND_VARIANT_COUNT left stale at 8 after appending a 9th variant probe fails
terminality fixture reverted to the all-default (all-zero) shape non-zero-tail assertion fires

Restored tree: 125 passed, 0 failed.

The first mutation is the drift scenario the count constants exist for; the second is the precise regression three reviewers found independently. Neither guard could previously fail for its stated reason.

A note on the external review

codex review converged on the unknown-tag probe defect independently — the strongest signal in the review, since three Claude lenses and a non-Claude model found the same hole from different directions.

Its proposed remedy would never have worked. It recommended asserting ErrorKind::InvalidTagEncoding. bincode never produces that for an enum tag: there is exactly one construction site, in deserialize_option (bincode-1.3.3/src/de/mod.rs:340). An out-of-range variant index goes through idx.into_deserializer() into serde's derived visitor and comes back as ErrorKind::Custom("invalid value: integer ..., expected variant index 0 <= i < N").

The trap is that bincode's description string for that variant reads "tag for enum is not valid" — so the external model was misled by the description exactly as this PR's original code was misled by the name. A test written to its suggestion would have failed permanently while looking correct.

Worth stating as a general point about external review: treat its diagnosis as signal and its remedy as a hypothesis. The diagnosis here was right and valuable; the fix was wrong in a way that would have been easy to apply without checking.

The host half: owner, and why it cannot be linked yet

The unsubscribe variants are useless without a freenet-core handler, and shipping a variant no host handles is the exact defect this workstream exists to remove. So, plainly:

  • Owner: the agent holding crates/core/src/contract.rs, by the file-ownership rule — contract.rs:915's TODO(#2830) and the V1 outbound dispatch at contract.rs:660-679 are both in that file. Assignment confirmed by the team lead and accepted.
  • Tracked as: freenet-core#2830, which specified subscribe and unsubscribe together; only subscribe was built.
  • There is deliberately no PR number here, because one cannot exist yet. freenet-core cannot name UnsubscribeContractRequest until 0.9.0 is published, so the handler is necessarily downstream of this merge and release. Requiring the number as a merge condition would be a deadlock.

The handler will route both teardowns — the ring demand registration and the DELEGATE_SUBSCRIPTIONS notification hook — through a single helper rather than removing them in two places, so the two records cannot drift apart. That is the same desync class as freenet-core#5487, arriving from the opposite direction.

A correction worth recording, because it is the kind of reassurance that quietly replaces a check. An earlier version of this PR said the exhaustive match in freenet-core means a new variant "cannot compile against a host with no handler". That is too strong. It cannot compile without an arm; an arm returning Ok(()) compiles perfectly and is precisely the silent stub in question. This crate proves it — the FlatBuffers encoder has five arms that log the message and drop it. The compile error is a backstop against forgetting, and no backstop at all against stubbing.

Two findings from review that are the same lesson from opposite directions

Recorded because both are cheap to repeat and neither is visible in a passing test run.

A cosmetic edit inverted a guard. Replacing expect_err with unwrap_or_else to make a panic message interpolate turned the assertion into its opposite — unwrap_or_else unwraps Ok and runs the closure on Err, so the test would have demanded the probe succeed. The compiler caught it, but nothing about the change looked like a behavioural one.

Twelve tests existed and had never executed. The test count went 113 → 125 not because tests were added, but because the list_subscriptions guards were extracted out of #[cfg(target_family = "wasm")]. CI runs cargo test on the host only; the wasm32 matrix entries build and lint and execute nothing. The guards were type-checked and unrun — which reads as coverage in the config and provides none. See #100 for the related lint-gate blindness and #101 for four sibling sites with the same unvalidated-length shape.

sanity added 8 commits August 30, 2026 13:08
Adds `DelegateCtx::list_subscriptions` so a delegate can ask the node what it
is subscribed to. Its subscription set lives in the node, not in the delegate:
the WASM is instantiated per invocation and dropped afterwards, and the node
replays subscriptions across a restart without running the delegate at all, so
a delegate had no way to learn its own state (freenet-core#5467). It returns a
`Result`, not a bare `Vec` — an empty list and a failed enumeration mean
opposite things to a caller deciding whether to re-subscribe.

Delivered as a V2 host function rather than a message variant. Host functions
resolve by name at instantiation, so this is additive for every existing
delegate and fails at load time with a named missing-import error on a node too
old to provide it, instead of mid-protocol on a decode.

Pins the bincode tag of EVERY variant of both delegate message enums. The
previous pin covered `InboundDelegateMsg`'s variant 0 alone, so any reorder
that left `ApplicationMessage` first went undetected — including swapping
`UserResponse` and `GetContractResponse`, which reassigns two tags and makes
deployed delegate WASM read each as the other, silently. That exact swap was
written during the work that produced this pin. The guard fails closed both
ways: the tag map is an exhaustive match, so a new variant is a compile error
until pinned, and a probe asserts the next tag along does not decode.

Also asserts the compatibility rules rather than only stating them. Appending
an enum variant and appending a struct field break in opposite directions, and
a struct field is the more dangerous: bincode is positional with no field tags,
so an old payload fails outright on a new receiver, and a new field is skipped
cleanly only when the struct is terminal in its message. `#[serde(default)]`
does not make a bincode field optional; it protects the serde_json path only.

Corrects two false doc comments: `InboundDelegateMsg` claimed
`OutboundDelegateMsg` was `#[non_exhaustive]` (it never has been, and it is
deliberately staying un-marked so the host cannot gain a variant without a
handler), and `subscribe_contract` claimed notification delivery was a
follow-up while saying nothing about the fact that a delegate subscription
registers no demand in the network (freenet-core#4669).

No wire change: no variant is added, removed or reordered.

Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
…test enums

The old-tag-space and envelope types in the compat tests exist to occupy wire
space and to be deserialized into, never to be constructed, which trips
dead_code under CI's -D warnings.

Also drops a doc comment's reference to what a previous version of that same
comment said, which is of no use to a reader of the published API.

Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
…he rule

ContractState::size_bytes was appended in #52 (2026-02-18, crate 0.1.36) and
ships in every tag from rust-v0.8.0. ContractState is a HashMap VALUE inside
NodeDiagnosticsResponse with two more fields after the map, so the appended u64
is not trailing — it shifts what follows, and an older reader does not get a
response missing one field, it gets no response at all.

Isolates the single variable by using today's String map key, so it measures
the appended field rather than the later key change in #70.

Scope, stated so the finding is not read as larger than it is: the only
external consumer of this query is fdev diagnostics, which ships from core's
own tree and is version-matched in practice; River touches NodeDiagnostics only
in tests and pins stdlib 0.8.5. The exposure is an fdev built before
2026-02-18 pointed at a newer node. The value here is the rule with a real
instance attached, not the instance.

Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
Its fields are decoded into and never read, which is the test: the decode
either fails or produces something wrong. CI denies warnings.

Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
HostResponse defaults its type parameter to WrappedState, which is what goes
over the wire. The terminality pin was instantiating it at Vec<u8>, so it was
pinning the layout of a type nobody sends.

Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
…operty

net-wiring's freenet-core#4669 work changes whether a delegate subscribe
registers demand, so stating 'registers no demand' as a fact about this API
would be wrong the moment their PR merges — the same doc rot in the other
direction.

Reframed as node behaviour with a tracking reference: pre-#4669 nodes register
no demand at all; post-#4669 nodes register it when hosting the contract, and
still do not when they can resolve but are not hosting, since a pin on an
unheld contract could be neither renewed nor reclaimed. The delegate cannot
detect which node it has, and the call reports success in every case.

Also documents list_subscriptions' real cost: the node keys delegate
subscriptions contract -> delegates, so this is a scan across every contract
with any delegate subscription, not O(this delegate's).

Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
After freenet-core#4669 a delegate subscription is two records — the
notification hook and the demand registration — and they can separate. An
eviction that sheds a still-in-use contract clears the demand and leaves the
hook, so a list read from the hook alone reports a contract the delegate is no
longer pinning.

That 'looks subscribed, is not pinned' state is what #5467 exists to make
visible, and reproducing it inside the introspection API meant to reveal it
would be the same defect one layer up. A delegate replaying this list after a
restart would also re-subscribe to things it holds no demand for and believe it
had recovered.

Promises the narrower meaning deliberately, so tightening the host's answer to
the cross-checked set later is a bug fix rather than a breaking change.

Reported by net-wiring from the core side, where the divergence is visible and
from stdlib it is not.

Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
Filed by net-wiring, covering both subscription desyncs and why the two
obvious fixes are wrong. Points the reader at the mechanism instead of my
summary of it, and records that introspection built against the two-record
shape wants rewriting when #4669 part 3's single store lands.

Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
… getter

StreamingBuffer::from_ptr is a pub unsafe fn whose entire doc comment,
including its '# Safety' section, was attached to total_remaining — a safe
getter two lines above that takes no pointer. So the getter was documented as
'Create a streaming reader from a buffer pointer' with a safety contract about
a ptr it does not have, and the unsafe constructor, whose callers must uphold
that invariant, had no documentation at all. A method had been inserted into
the middle of another method's doc block.

Splitting them back apart also clears clippy::missing_safety_doc.

The other error in this file is a false positive: the non-WASM stub must keep
the mangled __frnt__fill_buffer name to match the WASM import it stands in
for, so it gets a narrow allow with the reason.

Neither is reachable by CI's lint gate, because ci.yml:99 passes no
--features: the clippy matrix covers both wasm32 and x86_64, but 'contract' is
off in both legs, and both of these live behind #[cfg(feature = "contract")].
Filed as issue #100; not fixed here, because CI is a shared Full-tier surface.

Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
@sanity
sanity force-pushed the feat/delegate-unsubscribe-introspection branch from fecdf1c to eea3c0a Compare August 30, 2026 20:18
…fers files

A build regenerates rust/src/generated/ with whatever local flatc is present,
which is not the one that produced the checked-in files, so any build leaves
thousands of lines of unrelated churn in the working tree. Staging explicit
paths is what keeps it out; a single 'git add -A' puts a toolchain downgrade
into a PR where nobody is looking for one.

Found while preparing this branch: the diff against main showed ~4000 lines of
generated churn that the committed diff did not contain.

Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
Multi-lens review found the terminality pin vacuous and three doc claims wrong.
Each was verified against source before being changed.

Vacuous test, found independently by three reviewers: the terminality pin built
NodeDiagnosticsResponse from all defaults, which encodes as 27 ZERO bytes, so
ends_with(inner) asserted only that the message ends in zeros. That stays true
after appending any field that encodes as zeros, which is exactly the mutation
it claims to catch. Now uses a distinctive non-zero fixture, asserts the fixture
is not all-zero, and adds a length equality so an appended sibling cannot hide
even if its bytes coincide.

The unknown-tag probe asserted only is_err(), so a future variant whose payload
rejects zeros (a DateTime, a NonZero, a validating deserialize_with) would fail
for the wrong reason and leave the count constants drifting undetected. It now
asserts the error names an invalid variant index, plus a control that the last
known tag still decodes from the same payload, so it cannot go vacuous either
way.

Wrong claim 1: bincode does NOT reject an unknown enum tag with
InvalidTagEncoding. That is produced only for a bad Option discriminant
(de/mod.rs:340); deserialize_enum hands the index to serde's derived visitor,
producing ErrorKind::Custom naming an invalid variant index. Stated in two doc
comments; corrected, and now pinned by the probe above.

Wrong claim 2: the node replays subscriptions across a restart is false, and it
was the motivating premise for list_subscriptions. DELEGATE_SUBSCRIPTIONS is an
in-memory LazyLock DashMap with no persistence, so a restart LOSES them. The API
is still right to add, but it is the read side of a capability that needs #4669
part 3's durable store; scoped accordingly.

Wrong claim 3: the non_exhaustive rationale cited SubscribeContractRequest as a
variant compiled with no handler. It IS handled (contract.rs:916-940); its
defect is that it registers no demand. Different bug. The argument stands on its
own and now states two honest limits: the compile error forces an arm to exist,
not a working handler, and this crate's own encoder has arms that log and drop.

Wrong claim 4: the version floor named a stdlib version. Host functions are
registered by name and reference no stdlib type, so the stdlib a node was built
against guarantees nothing. Says so, and that no released node provides these
imports yet.

Also hardens the FFI, which reviewers found could silently under-report.
Validate that the host length is a multiple of 32 and within a new
MAX_SUBSCRIPTION_LIST_BYTES before allocating: usize is 32-bit on wasm32, so an
unchecked i64 cast truncates to 0 and surfaces as an empty list, the exact
conflation the Result return type exists to prevent. Reject written > len, which
truncate would otherwise ignore, leaving zero-filled tail bytes to decode as
valid-looking all-zero ids. Re-check on an exactly-full buffer so a set that
GREW between the two calls returns ERR_BUFFER_TOO_SMALL rather than a short list
that looks complete. The import contract now specifies that requirement, since
the host half is written against it.

Adds the off-WASM test that list_subscriptions returns Err rather than an empty
list; documents that notification delivery is best-effort and lossy; qualifies
the old-delegate-to-new-host claim, which holds for appended variants but not
for fields appended to their payload structs; and renames a test to what it
actually pins.

The additive claim is now evidence: River's shipped chat_delegate.wasm builds
against stdlib 0.8.5, which declares the five freenet_delegate_contracts
externs, and imports none of them. A first attempt to show this with a synthetic
delegate was itself vacuous, since that harness exported no entry point, so
nothing could import and an empty module reports zero the same way.

Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw

@sanity sanity left a comment

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.

Comprehensive PR Review: #98

Summary

  • PR Title: feat(delegate): subscription introspection, and pin every wire tag
  • Type: feat (wire-format guards + a new V2 host function)
  • CI Status: green on the reviewed HEAD; re-running on the fix commit
  • Linked Issues: freenet-core#5467, #4669, #5487; freenet-stdlib #99, #100
  • Review tier: Full — touches wire format / protocol serialization on the delegate enums, an always-Full surface
  • Reviewers run: code-first, testing, skeptical, big-picture (four parallel Claude lenses, each blind to the others), plus codex review as the external non-Claude pass. All five read the checked-out code in an isolated worktree.
  • Disclosure: I am the author. Every finding below was raised by a reviewer that did not write the code, and I verified each against source before acting on it.

Code-First Analysis

Independent Understanding: four separable things — a new DelegateCtx::list_subscriptions plus two WASM imports and a shared codec; corrections to three doc comments; two test modules pinning bincode variant tags and demonstrating struct-field compat; two unrelated fixes in memory/buf.rs.

Stated Intent: matches, with one exception found by the big-picture lens (below).

Alignment: good on the wire-guard half. The list_subscriptions half was justified by a false premise — now corrected.


Testing Assessment

Coverage Level: adequate after fixes; two guards were vacuous before them.

Test Type Status Notes
Unit 112 pass (was 111; off-WASM contract test added during review)
Integration N/A library crate; the host half lands in freenet-core
Simulation N/A no routing/topology surface
E2E ⚠️ the two new WASM imports cannot be exercised until the core half exists — stated in the PR rather than papered over

Regression Test: present — the shipped ContractState::size_bytes append is pinned as a concrete instance of the rule.


Skeptical Findings

Risk Level: medium before fixes, low after.

The wire-safety claim itself held under adversarial check: both delegate enums are byte-identical to origin/main, no variant or payload field added, removed or reordered, and no generated-file churn rode in. The problems were in the guards and the prose, not the wire.

Concern Severity Location Status
Terminality pin vacuous — all-default fixture encodes as 27 zero bytes, so ends_with asserted only "ends in zeros", which survives appending any zero-encoding field High client_events.rs:4126 Fixed — non-zero fixture, an assertion that the fixture is not all-zero, and a length equality
Unknown-tag probe asserted only is_err(), so a variant whose payload rejects zeros fails for the wrong reason and drift goes undetected High delegate_interface.rs:1591 Fixed — asserts the error names an invalid variant index, plus a control that the last known tag still decodes
list_subscriptions could silently under-report: unchecked i64 as usize (32-bit on wasm32), written > len ignored by truncate, and a set that grew between the two calls returning a short list that looks complete Medium delegate_host.rs:743 Fixed — length validated against a new MAX_SUBSCRIPTION_LIST_BYTES, written > len rejected, exactly-full buffer re-checked, and the import contract now specifies ERR_BUFFER_TOO_SMALL
#[allow(non_snake_case)] claimed to suppress nothing Low buf.rs:369 Rejected — false positive. rustc rejects consecutive interior underscores; I have the clippy output showing it fires

Big Picture Assessment

Goal Alignment: yes for the wire guards. The list_subscriptions justification had drifted and is corrected.

The most valuable finding in the review, and the one I would not have caught: the doc claimed "the node replays subscriptions across a restart without running the delegate at all" — the entire motivation for the API. It is false. DELEGATE_SUBSCRIPTIONS is an in-memory LazyLock<DashMap> with no persistence, so a restart loses delegate subscriptions rather than replaying them. Verified in freenet-core. The API is still worth adding — it is the read side of that capability — but it does not deliver restart-replay until #4669 part 3's durable store lands, and it now says so.

Two further false claims, both verified and corrected:

  • The #[non_exhaustive] rationale cited SubscribeContractRequest as a variant that compiles with no handler. It is handled (contract.rs:916-940); its defect is that it registers no demand. A different bug with a different fix. The argument stands on its own and now states two honest limits: the compile error forces an arm to exist, not a working handler — this crate's own encoder has arms that log and drop.
  • The version floor named a stdlib version. Host functions register by name and reference no stdlib type, so that guarantees nothing; corrected to name the core requirement, and to say no released node provides these imports yet.

Anti-Patterns Detected: none of the CI-chasing kind. No test removed, skipped, or loosened; inbound_delegate_msg_wire_format_is_stable is present and byte-identical to main.

Scope Assessment: some creep, acknowledged. Five arguably-separable things. The buf.rs fixes and the CONTRIBUTING line were flagged in the PR body deliberately; the big-picture lens correctly notes that flagging is transparency, not focus. Splitting list_subscriptions out would be defensible.


External Model (codex)

Converged independently on the probe defect (P2, same location), which is the strongest signal in the review — three Claude lenses and the external model found the same hole from different directions.

Its suggested remedy was wrong, instructively. It recommended asserting ErrorKind::InvalidTagEncoding. bincode never produces that for an enum tag: it has exactly one construction site, in deserialize_option (de/mod.rs:340). The trap is that its description string reads "tag for enum is not valid" — codex was misled by the description exactly as the original code was misled by the name. A test written to codex's suggestion would never have passed. The fix asserts the error names an invalid variant index instead, which the passing test empirically confirms.


Documentation

  • Code docs: complete, and now accurate — four claims corrected against source.
  • CHANGELOG: corrected. The "additive for every existing delegate" claim is now evidence rather than prose: River's shipped chat_delegate.wasm builds against stdlib 0.8.5, which declares the five freenet_delegate_contracts externs, and wasm-objdump -x shows it imports none of them. (A first attempt to demonstrate this with a synthetic delegate was itself vacuous — the harness exported no entry point, so nothing could import, and an empty module reports zero the same way.)
  • Noted, not fixed: the repo-root examples/delegate.rs no longer compiles against DelegateInterface — it uses a 5-parameter signature with a SecretsStore argument the trait has not had for some time. It is not wired into any Cargo target, so nothing catches it. Worth its own issue.

Recommendations

Must Fix (Blocking)

All resolved in be638f3:

  1. Terminality pin vacuous.
  2. Unknown-tag probe asserted only is_err().
  3. Restart-replay premise false.
  4. SubscribeContractRequest cited as unhandled.
  5. Version floor named a stdlib version.

Should Fix (Important)

  1. FFI hardening for the truncation and cast paths — done.
  2. Best-effort/lossy notification delivery now documented — done.
  3. "Old delegate → new host always fine" qualified: true for appended variants, not for fields appended to their payload structs — done.

Consider (Suggestions)

  1. Split list_subscriptions into its own PR. Not done — it is the piece the host half is written against, and the ABI is what chokepoint needs.
  2. Pin the request/response enums (HostResponse, QueryResponse, ContractRequest) the same way. Genuinely worth doing and out of scope here; the response direction is entirely unpinned today.
  3. A HIGHEST_TAG_EVER_USED watermark, since removing the last variant is invisible to the whole scheme.
  4. File the stale examples/delegate.rs.

10–12 are follow-ups, not merge blockers.


Verdict

State: Needs Changes — Re-review Required After Fix (fixes are pushed; a re-review pass on the new HEAD is required before merge, per the per-code-content rule — five blocking findings were addressed, which is well past the threshold)

HEAD SHA reviewed: 42ecc979ac9bce4e1ba80e4b27f94baaf55e570d
Fixes pushed as: be638f3

I am not merging this. The review found real defects — including two guards that would have passed while measuring nothing, in a PR whose entire purpose is to stop exactly that — and the code has changed materially since the reviewed SHA.

[AI-assisted - Claude]

freenet-core#2830 specified subscribe and unsubscribe together; only subscribe
was built, and core has carried the TODO(#2830) since. Until now the only way a
delegate's subscription was released was the implicit cleanup when the delegate
itself was unregistered, so a delegate that had finished with a contract kept
holding interest for as long as it existed.

Appends UnsubscribeContractRequest to OutboundDelegateMsg and
UnsubscribeContractResponse to InboundDelegateMsg, both at tag 8. Every existing
tag is unchanged, so deployed delegate WASM is unaffected. Tag 8 is Ian's call,
coordinated with freenet-stdlib#82, which appends ScheduleWakeup/WakeupFired and
now takes tag 9; recorded as a comment on that PR so the decision does not live
only in a working session.

Unsubscribing a contract the delegate is not subscribed to reports Ok(()). Not a
convenience: the host's teardown already treats an absent client id as a no-op,
so an error return would have it inventing a failure it did not have. That
reasoning is net-wiring's, from the side that implements it, and it survives
someone later deciding convenience was not a good enough justification.

The pin was made to fail before it was made to pass. Adding the two variants
with nothing else changed produced five compile errors, all E0004
non-exhaustive-pattern: three in production code, including the FlatBuffers
encoder in client_api/client_events.rs that the OutboundDelegateMsg doc cites as
the reason the enum is deliberately NOT non_exhaustive, and two in the tag pin
itself (pinned_inbound_tag, pinned_outbound_tag). So the guard is demonstrated
rather than asserted: a variant cannot be appended without both the host
dispatch site and the wire pin refusing to compile.

Adds a round-trip test for the pair that also asserts a hand-built pre-0.9.0
ContractNotification still decodes unchanged, so the append is shown not to
disturb anything older.

The host half is freenet-core's and is owned by net-wiring, who has the exact
field layout. This does not ship until they confirm they are landing it —
shipping a variant no host handles would be the same defect this workstream
exists to remove.

Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
@sanity sanity changed the title feat(delegate): subscription introspection, and pin every wire tag feat(delegate): unsubscribe, subscription introspection, and pin every wire tag Aug 30, 2026
Re-review of the unsubscribe commit found one real design mistake, the oracle
problem in my own new test, and a false claim I had just reintroduced.

The exactly-full re-check was wrong twice over. It re-called the length function
whenever the read came back exactly filling the buffer, meaning to catch a set
that had grown. But an exactly-full buffer is the NORMAL result, not an edge
case: len is derived from the same set the read serialises. So it doubled a scan
the docs describe as O(all contracts with any delegate subscription) on every
non-empty call, and it could fail a correct read by reporting
ERR_BUFFER_TOO_SMALL when a subscription happened to arrive in between. It was
not even sound: grow-then-shrink passes it. Removed. The import contract already
requires the host to return ERR_BUFFER_TOO_SMALL rather than truncate, so a
short write means the set shrank, and completeness rests on that contract, which
is why the contract is stated on the import rather than implied.

The decisions now live in validate_list_len and resolve_written, which are pure
and compiled on every target. That matters because CI runs cargo test on the
host only; the wasm32 matrix entries build and lint but execute nothing, so
everything previously inside cfg(target_family = "wasm") was type-checked and
never run. Ten table-driven tests now cover the branches, including the wasm32
truncation case (1 << 32 as usize is 0 there, which would have surfaced as an
empty list).

My round-trip test for the new unsubscribe pair proved only that the code agrees
with itself. Both structs' docs say the field ORDER is the wire format, and a
round-trip through this crate's own encoder cannot establish that — swapping
contract_id and result would round-trip just as happily. Both layouts are now
frozen as hand-written bytes, the inbound half asserts the VALUES rather than
just the variant, and the Err(String) path is exercised since it has a different
bincode shape from Ok.

Reintroduced false claim, the same defect class as this PR's headline fix: the
doc said "several of them are #[non_exhaustive]" of the payload structs. Exactly
one is, ApplicationMessage. Corrected and named.

Also: the #82 note asserted that PR takes tag 9, which it does not yet — it
still declares 8, so the text now says it must move and that the pin will catch
whichever lands second. The unknown-tag probe gained an outbound control to
match the inbound one. The terminality fixture's non-zero guard asserted some
byte was non-zero when what ends_with relies on is a non-zero TAIL.

Fixes a pre-existing bug found in review: get_context and get_mut_context
returned None for UserResponse, which carries a context, because a `_ => None`
wildcard swallowed the missing arm and nothing in the crate called either
accessor. Arm added, both accessors are now exhaustive with no wildcard, and a
table-driven test drives them off every_inbound/every_outbound so the next
omission is a compile error rather than a silent None. Filed #101 for four
sibling sites with the same unvalidated-length shape.

One of these fixes was itself wrong first: replacing expect_err with
unwrap_or_else to make a panic message interpolate inverted the test, since
unwrap_or_else unwraps Ok and runs the closure on Err. The compiler caught it.

Claude-Session: https://claude.ai/code/session_014tq59dRUCNsHR1GuUkguHw
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