feat(delegate): unsubscribe, subscription introspection, and pin every wire tag - #98
feat(delegate): unsubscribe, subscription introspection, and pin every wire tag#98sanity wants to merge 13 commits into
Conversation
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
fecdf1c to
eea3c0a
Compare
…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
left a comment
There was a problem hiding this comment.
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), pluscodex reviewas 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 citedSubscribeContractRequestas 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.wasmbuilds against stdlib 0.8.5, which declares the fivefreenet_delegate_contractsexterns, andwasm-objdump -xshows 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.rsno longer compiles againstDelegateInterface— it uses a 5-parameter signature with aSecretsStoreargument 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:
- Terminality pin vacuous.
- Unknown-tag probe asserted only
is_err(). - Restart-replay premise false.
SubscribeContractRequestcited as unhandled.- Version floor named a stdlib version.
Should Fix (Important)
- FFI hardening for the truncation and cast paths — done.
- Best-effort/lossy notification delivery now documented — done.
- "Old delegate → new host always fine" qualified: true for appended variants, not for fields appended to their payload structs — done.
Consider (Suggestions)
- Split
list_subscriptionsinto its own PR. Not done — it is the piece the host half is written against, and the ABI is whatchokepointneeds. - 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. - A
HIGHEST_TAG_EVER_USEDwatermark, since removing the last variant is invisible to the whole scheme. - 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
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
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_stableasserted thatInboundDelegateMsg's variant 0 isApplicationMessage. Nothing coveredOutboundDelegateMsgat all, and nothing covered any variant after the first. So any reorder that happened to leaveApplicationMessagein front was undetected — swappingUserResponseandGetContractResponse, 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 saidOutboundDelegateMsgwas 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_pinnedasserts the bincode tag of every variant of both enums. Two mechanisms keep it honest, because a pin that can rot is not a pin:match.#[non_exhaustive]has no effect inside the defining crate, so adding a variant without pinning it is a compile error.PutContractRequestis covered too. The obvious shortcut is to skip it because building aContractContaineris 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_compatandstruct_field_wire_compatestablish 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: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 protectsserde_jsononly, which is easy to misread givenContractState::size_bytescarries 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.NodeDiagnosticsResponseis 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_subscriptionsis backed by two new V2 host functions in the existingfreenet_delegate_contractsnamespace. 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 bareVec. 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 returnsErr, so a host-side test cannot read "no subscriptions" out of a stub that never had any.OutboundDelegateMsgstays 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 delegateSubscribeContractRequesthas today (freenet/freenet-core#4669), and the compile error is what stops the next one.InboundDelegateMsgkeeps 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.
UnsubscribeContractRequestjoinsOutboundDelegateMsgandUnsubscribeContractResponsejoinsInboundDelegateMsg, 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.0ContractNotificationpayload is asserted to still decode unchanged.Tag 8 is a deliberate allocation, coordinated with #82: that PR appends
ScheduleWakeup/WakeupFiredand 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
E0004non-exhaustive-pattern errors — three in production code, including the FlatBuffers encoder that theOutboundDelegateMsgdoc cites as the reason that enum is deliberately not#[non_exhaustive], and two in the tag map itself: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 fivestruct_field_wire_compatcases includingnode_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.rsFlagged here because it is a file otherwise unrelated to this change, so a reviewer should not meet it by surprise.
StreamingBuffer::from_ptris apub unsafe fnthat had no documentation, because its entire doc comment — including the# Safetycontract — was attached tototal_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 aptrit 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 clearsclippy::missing_safety_doc.The second is a false positive: the non-WASM stub must keep the mangled
__frnt__fill_buffername 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:99passes no--features. The clippy matrix does cover bothwasm32-unknown-unknownandx86_64-unknown-linux-gnu, so target coverage is fine — butdefault = [], sofeature = "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— cleancargo test --features contract,net,testing,trace— 111 passed, 0 failed; doc-tests 1 passedcargo build --target wasm32-unknown-unknown --features contract— succeeds, which is the first time anything has compiled thecfg(target_family = "wasm")branch oflist_subscriptionsNote for whoever rebases #82
It appends
ScheduleWakeup/WakeupFiredto these same two enums, at the tag this PR leaves free. Two things to know:mainsince July, including thefixed_size_fielddecode-panic hardening and theunknown_union_discriminantchange. 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 testthat matches nothing prints0 passedand 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, neverrc: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:
INBOUND_VARIANT_COUNTleft stale at 8 after appending a 9th variantRestored 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 reviewconverged 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, indeserialize_option(bincode-1.3.3/src/de/mod.rs:340). An out-of-range variant index goes throughidx.into_deserializer()into serde's derived visitor and comes back asErrorKind::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:
crates/core/src/contract.rs, by the file-ownership rule —contract.rs:915'sTODO(#2830)and the V1 outbound dispatch atcontract.rs:660-679are both in that file. Assignment confirmed by the team lead and accepted.UnsubscribeContractRequestuntil 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_SUBSCRIPTIONSnotification 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_errwithunwrap_or_elseto make a panic message interpolate turned the assertion into its opposite —unwrap_or_elseunwrapsOkand runs the closure onErr, 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_subscriptionsguards were extracted out of#[cfg(target_family = "wasm")]. CI runscargo teston 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.