Skip to content

feat: scheduled wakeup primitive for delegates (ScheduleWakeup / WakeupFired) - #82

Draft
sanity wants to merge 1 commit into
mainfrom
feat/delegate-scheduled-wakeup
Draft

feat: scheduled wakeup primitive for delegates (ScheduleWakeup / WakeupFired)#82
sanity wants to merge 1 commit into
mainfrom
feat/delegate-scheduled-wakeup

Conversation

@sanity

@sanity sanity commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Problem

Delegates have no way to schedule future execution. The OutboundDelegateMsg
enum has no ScheduleWakeup-equivalent variant and InboundDelegateMsg has no
WakeupFired, so delegate execution is purely message-driven. dApps that need
periodic background work (key rotation, TTL pruning, scheduled publication) are
forced to push it into a UI/client sync loop, which stops working when the UI is
closed. Driving use case: River private-rooms weekly secret rotation
(freenet/river#228).

Approach

Add the two wire variants the host↔delegate protocol needs:

  • OutboundDelegateMsg::ScheduleWakeup { at: SystemTime, tag: Vec<u8> }
  • InboundDelegateMsg::WakeupFired { tag: Vec<u8> }

Both are appended at the end of their enums (bincode variant tag 8), so the
change is wire-compatible for every existing variant — delegate WASM compiled
against an older stdlib keeps deserializing everything it already understood.

  • OutboundDelegateMsg is deliberately left not #[non_exhaustive]: the
    host must consciously handle every outbound variant, so the compiler should
    force an arm for each new one. The inaccurate doc comment on
    InboundDelegateMsg that claimed OutboundDelegateMsg was already
    #[non_exhaustive] is corrected.
  • InboundDelegateMsg stays #[non_exhaustive]; unknown inbound variants are
    forwarded to the delegate WASM unchanged, so WakeupFired flows through.
  • These variants travel only on the bincode host↔delegate FFI path, not the
    FlatBuffers WS path, so no .fbs schema / generated-code / TypeScript changes
    are needed. ScheduleWakeup gets a "reached client serialization - this is a
    bug" arm in the WS encoder mirroring SendDelegateMessage.

Bumps 0.8.20.8.3.

Testing

New wire-format pin tests freeze the variant tags so a future reorder is caught
loudly:

  • inbound_wakeup_fired_wire_format_is_stable — full byte layout at tag 8.
  • outbound_schedule_wakeup_wire_format_is_stable — tag 8 + round-trip.

Downstream

Host-side implementation is in freenet-core#3972 (opens after this publishes,
per the stdlib-first release policy).

Refs: freenet/freenet-core#3972

[AI-assisted - Claude]

…upFired)

Add two variants to the host<->delegate protocol enums so a delegate can
schedule future execution without a connected UI (key rotation, TTL pruning,
scheduled publication):

- OutboundDelegateMsg::ScheduleWakeup { at: SystemTime, tag: Vec<u8> }
- InboundDelegateMsg::WakeupFired { tag: Vec<u8> }

Both are appended at the end of their enums (bincode variant tag 8), so the
change is wire-compatible for every existing variant: delegate WASM compiled
against an older stdlib keeps deserializing everything it already understood.
New wire-format pin tests freeze the tags. OutboundDelegateMsg stays exhaustive
(deliberately not #[non_exhaustive]) so the host must handle every outbound
variant; the inaccurate doc claiming it was already non_exhaustive is corrected.

Bumps 0.8.2 -> 0.8.3. Host-side implementation lands in freenet-core#3972.

Refs: freenet/freenet-core#3972
[AI-assisted - Claude]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K8mqiskQracG7CDVLSxDJC
@sanity

sanity commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Host-side consumer: freenet/freenet-core#4666 (issue #3972). This stdlib PR must merge + publish 0.8.3 first (stdlib-first), then the core PR can build.

[AI-assisted - Claude]

@sanity

sanity commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Coordination note for whoever picks this up: tag 8 is now taken, and this PR needs to move to 9.

Delegate unsubscribe (UnsubscribeContractRequest / UnsubscribeContractResponse, closing the half of #2830 that was specified but never built) is landing in #98 and appends to these same two enums. Both changes wanted the next free tag. @sanity settled the ordering: unsubscribe takes 8, this PR rebases onto it at 9.

Why it needed settling rather than being left to merge order: these enums are bincode-encoded with the variant index in declaration order, so two independent "append at the end" changes silently renumber each other. Nothing caught that before — #98 adds a pin asserting the tag of every variant in both enums, backed by an exhaustive match inside the crate so a new variant is a compile error until it is pinned. Once #98 merges, a colliding append fails CI loudly instead of shipping a wire break.

Two other things worth knowing before rebasing, neither of them new to this PR but both easy to miss:

Nothing here is a judgment on the wakeup design itself, which is unchanged and still wanted — freenet-core#5467 Phase 2 tracks it, and notes that the host half (freenet-core#4666, branch feat/3972-delegate-wakeup @ 9139913c) was written and Full-tier reviewed before being closed unmerged for want of a live consumer.

[AI-assisted - Claude]

@sanity

sanity commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Recording a tag decision here so it does not live only in a working session, since whoever rebases this will not have seen it.

Ian ruled that unsubscribe takes tag 8; this PR moves to tag 9.

freenet-stdlib#98 appends UnsubscribeContractRequest to OutboundDelegateMsg and UnsubscribeContractResponse to InboundDelegateMsg, both at tag 8. So ScheduleWakeup and WakeupFired become tag 9 in their respective enums, and this PR's two wire-format pin tests need their expected tags updated from 8 to 9.

Both enums are bincode-encoded with the variant index in declaration order, so two independent "append at the end" branches silently renumber each other. That is now a loud failure rather than a silent one: #98 adds a per-variant tag pin whose map is an exhaustive match inside the defining crate, so appending a variant without pinning it is a compile error, and a probe asserts the next tag along does not decode so the variant counts cannot drift. Rebasing onto it will tell you immediately if a tag collides.

Two other things worth knowing before picking this up, neither a criticism of the change itself:

  1. This branch currently reverts four fixes merged to main since July, including the fixed_size_field decode-panic hardening and the unknown_union_discriminant change in delegate_interface.rs. That is branch staleness rather than intent, but a naive rebase would undo them.
  2. The version target has moved on twice. The description says it bumps 0.8.2 to 0.8.3; 0.8.5 is published and main is already at an unreleased 0.9.0 carrying a breaking change (fix(security)!: remove RegisterDelegateWithPredecessors wire variant (0.9.0) #91).

[AI-assisted - Claude]

sanity added a commit that referenced this pull request 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