Skip to content

feat(net)!: a job's exact route continues its claim's instance - #8063

Merged
kixelated merged 92 commits into
mainfrom
quest/m2/announce-takeover
Nov 8, 2026
Merged

kixelated merged 92 commits into
mainfrom
quest/m2/announce-takeover

Conversation

@kixelated

@kixelated kixelated commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A worker claims a prefix and answers each request by publishing that path's exact route. The job minted a fresh instance, so the first subscription through the claim was reset as soon as the exact route landed, on every wire. lite-07 papered over this with ANNOUNCE_TAKEOVER (moq-dev/moq#6994): a predecessor Announce ID that every origin, relay and binding had to track. That was a second continuity rule next to the instance, and the maintainer chose the instance (2026-10-31).

Approach

A claim lends its instance to its jobs.

  • A prefix route that names no instance lends the one it mints.
  • Request::route without a named instance binds the claim's current instance and epoch when the route first announces or its instance() is first read, not when it is created. A hidden job that never read it joins whatever instance the claim holds once it is ready.
  • A job that read its instance, as a moq-crypto signer does, announces under it or not at all. Once another job's end moves the claim past it, announce() fails with Unroutable and its request goes back on the claim's queue, every requester still waiting, so the worker prepares the job anew (Request's drop and ServeState::requeue in origin.rs; requeueJob in JS). This holds in Rust, JS, C and moq-ffi alike; moq-c and moq-ffi read a route's instance only when asked or once it announces.
  • A reader resolved through the claim continues on the exact route as the same stream. On moq-lite-07 that holds across relays; the instance rides ANNOUNCE_START.

A job's end moves the claim on.

  • When an exact route carrying the claim's current instance stops serving it (withdrawn, closed, dropped, or renamed), the claim moves to a fresh instance at epoch 0.
  • This happens under the table lock, in the same step as the retraction, so no request resolves the path through the ended instance in between (OriginState::advance in rs/moq-net/src/model/origin.rs).
  • A restarted job is therefore another stream. A request pinned to the ended instance finds no route and is refused Unroutable without waking the worker.
  • Pending requests and held-broadcast answers survive the move; readers see the claim restart.
  • On lite-07, a relay serves a node's claims most specific first, and it queues a claim's restart behind every pending serve. A job that continues the old instance is therefore in place before the claim moves on, even on a multithreaded relay (Node::home and Outbox::push in rs/moq-net/src/route/node.rs).
  • A claim that names a stable instance lends nothing. A job that names its instance keeps it and never moves the claim.

Removed:

  • the lite-07 ANNOUNCE_TAKEOVER (0x5) codec, compression and routed publisher/subscriber handling; 0x5 is now skipped as an unknown type;
  • RouteEvent::Takeover;
  • the route layer's claim lineage (Claim::continues, the OwnClaims predecessor);
  • the origin's predecessor tracking (seen stamps, PendingFront via/first, Selected::continue_with, RouteEntry.takeover);
  • fuzz::takeover_route and the two takeover benches. origin/lend replaces origin/takeover.

JS mirrors the Rust design in js/net/src/origin.ts (a claim's Handoff, bound at first announce, advanced before the retraction reroutes). A peer's request pinned to an instance and waiting on a job is now served by that job's route. BroadcastCache and connection/forward.ts needed no change: the cache keys by instance.

Docs and drafts:

  • The lite draft drops ANNOUNCE_TAKEOVER and gains a MAY rule: an advertiser may announce a resolved path under the claim's Instance, and a claim restarts once that announcement ends.
  • doc/concept/identity.md rewrites the lazy-job lifecycle.
  • The epoch and cluster drafts now describe the instance.
  • The binding docs (Rust, C, moq-ffi, Python, Go, Swift, JS) describe lending.

Tests:

  • Model (origin.rs): a withdrawn job moves the claim; a hidden job takes the claim's instance when it announces; a job that read its instance is refused once the claim moves, and the worker gets the request again; a request pinned to an ended job is refused; a pinned request reaches its job after a sibling moves the claim; a claim and its job end independently; named instances are neither lent nor moved.
  • Integration (rs/moq-net/tests/integration/claim_continues.rs, replacing announce_takeover.rs): W -> R -> E chains on lite-06 and lite-07, with the claim beneath the path and at it; a restarted job; two workers; an unrequested publish; an older downstream; an older upstream hop; a diamond; late snapshots.
  • Real player (rs/moq-cli/tests/integration/instances.rs): moq_mux::Source reads a catalog and its pinned media through a claim over a real lite-07 relay on its first request, and the pin is refused once the media job ends.
  • The lite-07 cold-start warm-ups in restart.rs (claimed) and drained_claim.rs now run only for older wires and moq-transport.
  • JS covers the same cases in js/net and @moq/watch. The binding smoke tests (C, Go, Kotlin, Dart, Swift, Python) now expect a job to carry its claim's instance.

Impact

  • Breaking Rust API: origin::RouteEvent::Takeover is gone; remove its match arms. Start, Update, Restart and End remain. The JS takeover variant was internal and the API report is unchanged.
  • Behaviour, no new exports:
    • Request::route / request.route() without an instance now continues a minting claim's instance instead of minting its own.
    • Reading an unannounced job's Route::instance binds it, and its announce() then fails with Unroutable once the claim has moved past it. A job that never reads it binds at its first announce.
    • A prefix route's instance changes when a job holding it ends, which readers see as a restart of the claim.
    • moq-ffi's MoqRoute::instance_id() (and the Go, Python, Swift and Kotlin instance accessors over it) reads the route's current instance on each call instead of the one minted at creation; once cancelled it returns the last one read or announced under, or an empty string for a hidden route that did neither.
  • Breaking C borrow: moq_route_instance reads the route's current instance on each call, and the string it fills now borrows the route's storage only until the next moq_route_instance on that route or moq_route_free. Copy it out before calling again.
  • Wire: removes lite-07's ANNOUNCE_TAKEOVER (0x5) and adds nothing. lite-07 (moq-lite-07-wip) is unpublished. moq-transport and lite-06 and earlier are unchanged: they mint an instance per announcement, so their first subscription through a claim still resets. The exception is a claim at the answer's own path, where nothing new is announced.

Stacking

a8aeafded7, its follow-up merges, and 59d06f0f3b bring the branch up to origin/main at 979a794923. Two conflicts:

  • quest/m2/sequence-reuse.md: main finished and deleted that quest (moq-dev/moq#8202); this branch had only dropped a link from it, so the deletion stands.
  • rs/moq-net/src/model/origin.rs: moq-dev/moq#8194 changed OriginState::takeover, which this PR replaces with lender, to check RouteEntry::authorizes (scope minus the new excluded subtrees). lender now applies the same check, so a prefix route lends its instance only for paths it may serve; main's takeover is dropped.

e3a8c5743f merges origin/main at 76545a6b67 after moq-dev/moq#8139 moved relay tests onto memory::Net. goaway_cluster.rs conflicted only on next_update, which main deleted, so main's version stands. The Takeover arm this PR removes is also gone from main's new memory::record helper. 10a9767a6f merges origin/main at fe00d62cc6 without conflicts. baecc27d4b is quest check --fix, which drops the new quest/m2/route-roles.md's ## Required link to this PR's finished quest.

Built on moq-dev/moq#8023, which has merged. The PR merges origin/main at 6f66d5593a. Every conflicting file was unchanged on main since #8023's head 2a7e718e03, which this branch had already merged, so each one keeps this branch's version. quest check --fix dropped quest/m2/sequence-reuse.md's link to the deleted quest. The diff now shows only this PR's change.

Decisions

  • Fix the ROUTE_REQUEST backlog found in round 3 in feat(net)!: lite-07 routes through the route layer #8023, then merge it here and review again ✅ (maintainer, 2026-11-07, comment). Not chosen: fixing it in this PR; landing both and planning a follow-up. feat(net)!: lite-07 routes through the route layer #8023's 0b70a604ff coalesces ROUTE_REQUEST per identity and hands writes over only after prior ones flush; 2a7e718e03 gives route outputs a turn beside a reply flood.
  • Reading a hidden job's instance binds it ✅ (a) (maintainer, 2026-11-07, /quest-complete). announce() then refuses with Unroutable if the claim has moved on, and the worker prepares the job again. The signer stays valid, and only jobs that read their instance can be refused. Rejected: (b) document only, signing a hidden job after announce() or naming its instance, which leaves the trap in place; (c) bind every job at creation, which fails any job prepared while a sibling ends, even one that never signs. This settles the Codex review's medium finding at rs/moq-net/src/model/origin.rs:3048.
  • moq-c's moq_route_instance returns a string borrowed until the next moq_route_instance call on that route or moq_route_free ✅ (maintainer, 2026-11-07). Not chosen: a caller-provided buffer; keeping every string until free.

Choices made without asking

  • Binding at first announce or first read rather than creation, so a job prepared while a sibling ends never announces under a spent instance, and one that read its instance never announces under another.
  • A refused job's request goes back to the worker rather than failing its requesters, including those that joined it after the claim moved. The requeue happens in Request's drop, under the queue lock that a removal would take, so no handler can pop the request while the refused job still holds it.
  • Stable-instance claims lend nothing: a restarted job could not keep their promise. Jobs naming an instance keep it and never move the claim.
  • A claim's move keeps its pending requests and held-broadcast answers. Readers restart because the instance changed, but the handler is not asked again for every held path each time one job ends. Both stay safe: old bytes under a new instance are a restart, never a splice. JS was aligned with Rust on this.
  • The plan's open question on epoch-bound requests joining a continued route is moot: lite-07 TRACK and SUBSCRIBE carry an instance and no epoch, and the job binds the claim's epoch along with its instance.
  • Finished the quest instead of leaving the moq-transport item in it (see Follow-ups). That item is a moq-transport change the Goal lists as a non-goal, and its premise turned out wrong. Leaving it would keep Finalize moq-lite-07 blocked on a moq-transport question. quest rm dropped the links; the prose mentions now point here.
  • Kept the epoch draft's EPOCH code point and docname, rewriting only its text (see Follow-ups).

Validation

On 59d06f0f3b, through quest gate: just ci passed every module except test js, whose only failure is the known order-dependent a connection samples the transport only while a reservation reads the estimate (js/net/src/connection/bandwidth.test.ts, see Follow-ups). It fails identically (1 fail) in this worktree with js/net/src checked out from origin/main, and passes when run alone. cargo test -p moq-net --all-features --lib passed (3098) after porting the authorizes check.

On 9e40dbae51 through c76e9872bf, through quest gate: just ci passed every module except three. check python (a docstring line too long) and check dart (stale bindings, since the UniFFI checksum covers doc comments) were fixed in the next two commits, and the dart, then python and py modules passed on rerun. test js failed one test outside this change, a connection samples the transport only while a reservation reads the estimate (js/net/src/connection/bandwidth.test.ts). It fails the same way in this worktree with js/net/src/origin*.ts reverted to baecc27d4b, passes in isolation, and the whole js/net suite passes in a fresh checkout of origin/main and of baecc27d4b, so it depends on the order bun reads test files in, not on this change. On c76e9872bf, just ci passed every module except that same test js failure. cargo test -p moq-net --all-features passed (3087 unit tests, including a_job_that_read_its_instance_is_refused_once_the_claim_moves).

Earlier:

On baecc27d4b, through quest gate: just ci on 10a9767a6f passed every module except check quest. baecc27d4b fixed that, and CHECK_MODULES="quest markdown" just ci then passed.

On 5d6e7fd9ac, through quest gate: just ci passed, as it did on the merge e3a8c5743f. a_job_is_read_before_its_claims_restart (rs/moq-net/src/route/node.rs) failed before 5d6e7fd9ac, with live read before live/x.

Earlier:

On 41ecad4b40 (after merging #8023 at 2a7e718e03 and origin/main at a9d7925123), through quest gate:

  • just check origin/main: every module passed (test rs: 11164 passed, 145 skipped). swift skips off macOS.
  • quest check: pass.

Earlier:

On c7e9a828d5 / c1d875e480 (after merging origin/main at 9920a3f550), through quest gate:

  • just check origin/main on c7e9a828d5: every module passed except two.
    • test rs did not compile: main's new lite07_serving helper lacked node. Fixed in c1d875e480, then CHECK_MODULES=rs just check origin/main passed (11158 passed, 145 skipped). This also clears CI's Check failure, moq-relay config::tests::repository_configs_load, which main fixed in moq-dev/moq#8144.
    • test js: 1 failure, lite-07 answers a bounded window that resolves empty and FINs in reply (js/net/src/lite/publisher.test.ts), a flake in main's code that moq-dev/moq#8150 quarantines. It passed 5 of 5 runs of the whole file in isolation.
  • quest check: pass.

Earlier:

On 915074a36d, through quest gate:

  • just check: every module passed except test rs, whose only failure is moq-relay config::tests::repository_configs_load: it parses every tracked TOML under test/ as a relay config and rejects test/audio-quality/clients/js/bunfig.toml, which main added in moq-dev/moq#8078 (unknown field test). cargo test -p moq-relay --lib repository_configs_load fails the same way on origin/main at b5805859aa, so it is pre-existing; no open PR fixes it yet. swift skips off macOS.
  • quest check: pass.

On 335ebe3280, before the latest merges: targeted nextest (moq-net, moq-c, moq-ffi, --all-features, filtered to instance, route, job and claim tests) gave 422 passed, including a_hidden_job_takes_the_claims_instance_when_it_announces, dynamic_broadcast_request (moq-ffi) and dynamic_serves_a_request_under_a_prefix (moq-c).

Earlier, on eea486e0e7, before the merges: just test interop --all --publishers rust,python,go,cpp --subscribers rust,python,go,cpp,js-native-node,js-native-bun,c,gst gave 159 passed, 0 failed, 22 skipped. The skips are JS lite-07 cells pending quest/m2/cluster-routing/routes/js.md. The moq-cli real-player claim test passed 10 runs out of 10. Neither was rerun after the merges.

Left to CI

Browser-driven lanes were not run on this host:

  • the js (headless Chromium) publisher and subscriber cells of just test interop --all;
  • the interop --media browser checks;
  • just test harness, just test media, just test audio-quality, just test video, just test smoke and just bench-browser.

Swift tests run only on the macOS platform CI.

Follow-ups

  • bandwidth.test.ts depends on test-file order (recommend planning it): a connection samples the transport only while a reservation reads the estimate swaps the global WebTransport and the shared connection pool, and fails deterministically when bun reads js/net/src in some directory orders (see Validation). It passes on origin/main in a fresh checkout, so CI does not show it yet.

  • A reordered moq-transport restart folds into the old instance. On a solicit = false session, a publisher restart's new PUBLISH_NAMESPACE can arrive before the old one's PUBLISH_NAMESPACE_DONE: they travel on separate request streams. The subscriber deliberately folds a repeat advertisement into the instance it holds (attach's occupied branch in rs/moq-net/src/ietf/subscriber.rs; separate_adverts_refcount_the_route, pathless_adverts_never_replace_the_source). A probe confirmed that the reordered restart produces no route event at all, so per-announcement instances do not resolve it, contrary to what the deleted quest's plan assumed. Options:

    • (a, recommended) On a restart over a solicit = false session, the publisher opens the new PUBLISH_NAMESPACE only once the peer has closed the old advertisement's request stream. This costs one round trip per restart, keeps the refcount rule and changes no wire.
    • (b) The subscriber mints a fresh instance for each PUBLISH_NAMESPACE, so a duplicate advertisement restarts readers needlessly.
    • (c) Accept the splice risk on epochless moq-transport.
  • CodeRabbit findings on feat(net)!: lite-07 routes through the route layer #8023's code, confirmed but outside this PR (the threads on this PR say where; none are in lines this PR changes):

    • lite-07 answers re-advertise a lite-06-learned route to the peer it came through, since OwnClaims has no split horizon (rs/moq-net/src/lite/publisher.rs, the routed::Answer build);
    • AnnounceServe::poll resets the stream on a protocol violation instead of letting Control close the session;
    • subscriber/routed.rs scans every route per RouteStart and every claim per Active (index by node ID);
    • route/node.rs home re-collects departed identities on every flush, through router.collect;
    • the publisher/routed.rs Answer::poll loop has no cooperative budget;
    • a withdrawal batch for a still-carried claim sends no floor, so its ends wait for the route's next batch (narrow).
  • Rename the epoch draft's EPOCH to INSTANCE (moq-instance). Its parameter names an instance but is called EPOCH, and the draft still orders different instances by UUIDv7 time. It is unpublished and unimplemented. The rename also touches doc/concept/standard.md, the cluster draft and quest/m5/ietf-cluster-peers.md.

Models

Size: XL
Implementer: Claude Code, claude-opus-5-5, unknown effort
Implementer: Claude Code, claude-opus-5-5, medium effort
Implementer: Claude Code, claude-opus-5-5, high effort (iterate, S)
Implementer: Claude Code, claude-opus-5-5, medium effort (iterate, S)

Review

Rewritten by quest review after each round.

  • Reviewer: Codex, gpt-6.1-sol, xhigh effort
  • Session: 01a1b9d9-683c-7681-bb23-c13ef3ad3761, round 2
  • Head: 59d06f0
  • Base: origin/main at 979a794
  • Score: 8/10, No actionable regressions were found in the merge’s interaction with the branch’s continuity logic, but builds and tests were not run, as instructed.
  • Findings: none

kixelated and others added 30 commits November 5, 2026 22:33
A floor is now one marker per route across the stream's interest. The
router presents its per-region view onto it: claims inside keep their
path, the most specific covering claim moves onto the interest (moved,
not ended, when withdrawn), and the floor certifies only when the view
knows the whole interest at one revision. Claims a stream carries
uncertified stay in its heard view and show where no certified floor
proves them absent; they go offline, never END, when their stream drops
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The origin keeps a route layer node its lite-07 sessions will share,
created on first use with a driver task that announces every route the
origin serves itself. A route the route layer selected names its origin
node, which a reader can filter on and which ranks routes by the
rendezvous hash of the path and that node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every relay now takes a random Hop ID on each start: `--cluster-id`,
`MOQ_CLUSTER_ID`, and `[cluster] id` are gone and refused as unknown
settings, matching the route layer's per-process node identity.

A cluster link costs at least 1, the price of an unpriced link. A peer
priced at 0 (`?cost=0`, or an object `cost` or `egress` of 0) is a
configuration error, as is a LAN-advertised `cluster.node` whose cost is
0 or malformed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ANNOUNCE_START and ANNOUNCE_TAKEOVER carry the Route ID, ANNOUNCE_UPDATE
only the Announce Cost, and ANNOUNCE_RESTART no hops; hop bases are
gone. ANNOUNCE_OK has no body and SETUP no Hop parameter on lite-07, a
Cost of 0 is refused, and SUBSCRIBE, FETCH, TRACK, TRACKS, and DATAGRAMS
carry the Node ID.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each lite-07 announce stream registers with the origin's route node: the
subscriber feeds it the peer's routes and claim batches (closed by
ANNOUNCE_FLOOR) and serves the claims the node hands it, minting sources
whose requests name the claim's Node ID; the publisher answers from the
node, filtering hidden, out-of-scope, and ungranted claims (a floor then
certifies nothing). Requests resolve through the Node ID they name.

A scoped peer's identities are qualified under its proven route key. A
per-connection proxy preference decides whether a peer is sent routes
learned from other peers: relays on, the library default off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A claim is served through every stream with a usable feasible route to
its origin node, so the origin holds the standby and fails over at once
when a session drops, as it did with hop chains. Own claims reach the
node before the first stream answers, and feeding them asks no peer.

A re-filed stream withdraws what it no longer serves, a covering claim
is answered like a covering reader sees it, and a route that entered
here for exactly the requested path ranks newest-first. The mesh
counterexamples now retract once everywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A front ending released the copy of every track nobody read, including one
that had already finished. A replaced run whose track finished before the
restart reached the subscriber therefore lost its cached groups, and a
recorder fetching them failed with the segment unavailable. Main hits the
same failure once the old run's end lands first; lite-07's routed restart
just arrives a little later. An ended copy subscribes nothing, so keeping it
costs only what its readers already hold.

moq-hls's restart suite gains a case that lets the old run's end settle
before the new instance announces, so the ordering no longer decides it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A client took the grant its relay answered with as a scope on the relay, so
it qualified the relay's identities and refused every one the relay had
already qualified: two clients behind a relay never saw each other's
endpoints. A connection's identities are qualified when its publish grant is
scoped, and that grant is the one this side issued, the permit's limit. What
the peer granted this side limits only what this side does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A relay built from the library now opts into proxying, as the relay binary
does, or a lite-07 peer is sent only its own claims: moq-mux's links,
moq-stats' and moq-tokio's relays, and the goaway suite's fake upstream.

Lite-07 carries no hop chain, so the hop-limit tests run on the older wires
only, and tiered_regions checks reachability and core failover rather than
which core a path's chain names. The first hop picks a pool's origin by a
hash every node agrees on, so prefix_pool checks that the downstream relay
agrees with the upstream one and that a joining worker takes only paths it
wins. With lite-07's takeover gone until quest/m2/announce-takeover.md,
claim_restart wakes the worker before subscribing, as the older wires do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lite-07 session drives the route layer now, so its blanket dead_code
allowance goes: test-only queries are gated on test, and builds without
async allow only what the async session alone drives. ROUTE_REQUEST and the
announce replies lose their allowances too. The routed streams log each
announce and restart as the older wire does, and `Client::with_cost`
documents that lite-07 refuses zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A relay translating a moq-lite-07 claim for a moq-lite-06 peer starts the chain
with the claiming node's route identity hop. When that chain came back to the
claiming node over lite-06, nothing in it named the node's own origin hop, so it
was filed as one of the node's own claims again and kept the broadcast circling
after its publisher left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arries on

One publisher reaches a relay through two neighbors and the preferred one goes
away. Wires that mint an instance per announcement restart the route onto the
standby's; lite-07 carries the publisher's instance and already serves the claim
through both, so it continues with nothing announced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ontract

# Conflicts:
#	rs/moq-net/MIGRATE.md
#	rs/moq-net/src/route/mod.rs
A route-layer claim records the claim it took over and the instance that
one served. The origin's own takeovers feed it, a relay keeps it from the
ANNOUNCE_TAKEOVER it received, and an answering stream sends the claim as
a takeover of its predecessor when the peer held that predecessor, under
the same route, before the batch, and every claim it holds at that path
serves the same instance. The receiver forwards the claim with its
predecessor's route, and in place keeps the predecessor's sources.

The legacy publisher and subscriber takeover paths were unreachable once
lite-07 routed, so they go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
js/net still speaks lite-07 from before the route layer, so the in-tree
lanes running it on lite-07-wip fail against this checkout's relay. Native
JS dials its defaults in the interop matrix, and every lane that would run
it on lite-07 records a skip naming quest/m2/cluster-routing/routes/js.md,
which restores them. The same goes for lite_tracks_interop and the lite-07
legs of the max-age and relay-fill JS suites.

The routes epic records the maintainer's 2026-11-06 picks: ANNOUNCE_TAKEOVER
ported onto Route IDs, and a route sent on a stream only while it meets the
stream's interest. session.md keeps the scoping work it implements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ontract

# Conflicts:
#	rs/moq-net/MIGRATE.md
…ing, serves per claim

- A lite-07 stream hands the node only the peer's claims the session's limit admits,
  not every claim within its regions. A limit narrower than its regions certifies
  nothing, so a floor never certifies a claim it left out absent. A permit change
  that admits a different set files the stream again, as a scope change does.
- Claim changes between floors are kept as their net change to the committed claims,
  so what a stream holds is bounded by its claims, not by the messages it read.
- The node serves a claim only through the streams that can carry it: within what
  this node asked the neighbor for and within its scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stream whose reader stalls kept every route change and batch the node queued.
Its outbox now holds one entry per identity: an end first, then the last start and
hold, and one batch with each claim's last change and the last floor, starts before
ends as the router orders them. A claim the reader was never sent leaves nothing
when it ends, so what a stalled stream holds is bounded by its claims.

A route started and ended unread reaches the reader as an end alone, which it acks
at once since the peer never held the route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main now has the release cut write migration notes; this PR carries them in its
Impact section instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…only held routes

The outbox now knows which routes its reader holds. A ROUTE_END of a route the
reader never read, a start or a restart since its last read, cancels what is
pending and is acked to the router at once, as the reader would: no peer holds
it. So churning fresh identities leaves nothing behind, and an end, restart, and
end of a route the reader holds reaches it as one end with one echo owed.

The answer no longer acks an unread end itself; the node does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A lite-07 answer read only the requested prefix from the route node, so a
mounted handle's target never reached a lite-07 reader, as it does in process and
on lite-06. The answer now reads each mount beneath its prefix through a node
stream of its own, at the target, and names what it presents under the mount
point on the same routes. What the origin holds beneath a mount point stays
hidden. A route stands while any of its streams carries it; one that stops early
ends what it presented and is acked at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Automated review by review (OpenAI)

Reviewed head 5d6e7fd against base 3ce26d1, following up on 41ecad4.

No new actionable findings in the serving-order fix. In rs/moq-net/src/route/node.rs:219–235 and 619–622, descendant jobs are queued before a claim restart, and a changed instance/epoch no longer inherits an earlier queued Serve's position. The new regression covers both drained and stalled readers without wall-clock timing.

The existing signing-identity finding remains open, not a new discovery: rs/moq-net/src/model/origin.rs:3048–3049 binds an unnamed hidden job to the claim's current instance at announce time. A signer prepared from the earlier instance() value keeps that old identity (rs/moq-crypto/src/sign/broadcast.rs:29–45); if a sibling ends before announce, the route advertises a different instance and subscribers verify against it. The ordering fix does not address this mismatch.

Direction: keep the instance-based continuity design and the local ordering fix. Resolve the documented hidden-job identity decision before landing, preferably by binding an observed identity and refusing announcement after that identity is spent, with a regression covering signer preparation followed by a sibling ending.

Verification limits: GitHub-only static follow-up focused on the ordering delta, surrounding node/queue logic and regression, plus the existing Rust signing concern. Inherited main changes were not re-reviewed wholesale. No quest commands, builds, tests, interop or browser checks run; author-reported validation was not independently reproduced. Rechecked open/non-draft state, full head/base and prior automated reviews immediately before posting.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

This pass reviewed the commits pushed since the prior pullfrog review (41ecad4b4): the origin/main merge e3a8c5743 and the fix commit 5d6e7fd9a. Commits merged in from main were not treated as new work from this PR.

  • A claim's restart waits behind its job's Serve — Outbox::push keeps a queued Serve in its slot only when the replacement names the same instance and epoch, so a claim moving to a fresh instance takes a new slot behind every pending serve instead of overwriting an earlier one.
  • Shown claims are served most specific first — State::home walks a node's claims in reverse path order, so a job's route is queued before its claim's restart when both changes reach the session in one batch. That is the real path: the origin's answer outbox merges two own changes into a single ROUTE_BATCH, and limit() replays a whole route's claims in one batch.
  • Regression test — route::node::tests::a_job_is_read_before_its_claims_restart drives the job announce and the claim restart through one batch onto a stalled stream. Reverting either half of the fix fails it, so both are load-bearing and covered.
  • Merge with origin/main — goaway_cluster.rs uses main's memory::record rather than its local next_update, and memory::record drops its RouteEvent::Takeover arm to match this PR removing the variant. git diff --merge-base origin/main over those files shows only that arm, so no branch-owned assertion was lost.

cargo test -p moq-net --lib route::node passes (11 tests).

Pullfrog  | View workflow run | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

…r quest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kixelated and others added 4 commits November 7, 2026 17:40
A signer built from `route.instance()` before a hidden job announces
named the claim's instance at that moment. If another job under the
claim ended before the announce, the job announced under the claim's
new instance and subscribers rejected every signed frame.

Reading an unannounced job's instance now binds it. `announce()` then
refuses with Unroutable once the claim has moved past it, and its
requesters ask the claim again: a front treats a refusal for an
instance its route has moved off as not standing, and JS requeues the
request. Jobs that never read their instance still bind at announce.
moq-c and moq-ffi no longer read the instance at creation, which would
have bound every job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A front that joined the shared request after the claim moved asks
under the new instance, so treating the refusal as stale missed it and
it failed with Unroutable. Requeue the request on the claim instead, as
JS does, so every requester keeps waiting, and drop the front rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Automated review by review (OpenAI)

Reviewed head c76e987 against base fe00d62, incrementally since 5d6e7fd, separating the main merge and quest-link cleanup.

The prior hidden-job signing-identity finding is resolved in the inspected code. In rs/moq-net/src/model/origin.rs:3022–3031, reading an unnamed hidden job's instance binds it; lines 3075–3102 refuse announcement once that identity is spent instead of advertising the signer's bytes under another identity. C and moq-ffi no longer accidentally bind hidden jobs during handle creation. JS mirrors the read-or-announce binding and refusal behavior.

The follow-up requeue preserves the shared pending request rather than failing only a stale front: Request::drop hands it back through ServeState::requeue and RequestQueue::requeue. The Rust and JS regressions cover requesters arriving both before and after the claim moves, with a replacement job answering them. No new actionable findings in these substantive updates or their binding/documentation changes.

Direction: retain the observed-identity binding and shared-request retry. No additional changes requested from this pass.

Verification limits: GitHub-only static review of the incremental implementation, surrounding lifecycle/queue logic, changed tests, bindings and documentation. No commands, builds, tests, binding regeneration, cryptographic end-to-end test, browser lanes or interop run; author-reported validation was not independently reproduced. Inherited main changes were not re-reviewed wholesale. Rechecked open/non-draft state, head/base and existing reviews immediately before posting.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

This pass reviewed the three origin/main merges since the prior reviewed head (c76e9872b). The PR's own patch is unchanged: git patch-id over git diff 08a1f8ac2 a8aeafded and git diff ded9eb52 cceb38e3b is identical (4976e711…), and each merge's diff against its main parent is exactly this PR's 102-file diff, so no conflict resolution altered or dropped anything from the branch's contribution.

  • Merged origin/main at 08a1f8ac2, 118cbb17a and ded9eb52 — 497 main-owned files. None of the PR's core implementation is touched: model/origin.rs, route/node.rs, js/net/src/origin.ts, claim_continues.rs and origin-request.test.ts are absent from the delta.
  • Main's edits in the 27 PR-touched files are peripheral — the ietf/lite publisher and subscriber, moq-relay/src/cluster.rs, the binding smoke tests and the guides, carrying main's own renames (abort→reject, WindowPush→WindowEventPush, CACHE_EXPIRY→DEFAULT_EXPIRY, Moq.socket→Moq.Socket, the new frame producer/consumer types, and Datagram no longer re-exported from model/mod.rs).
  • The lending rule and the takeover removal still hold at HEAD — the claim-continuation text is intact in doc/lib/js/net.md, doc/lib/rs/moq-net.md, the C and Dart guides and doc/concept/identity.md, and no RouteEvent::Takeover, ANNOUNCE_TAKEOVER or three-argument forward( reference remains under rs/.

Verification: bun test src/origin-request.test.ts src/integration.test.ts in js/net passed (162 tests, 0 failures) against the merged tree, and all 24 review threads are resolved. A local cargo check could not run because the sandbox's build-directory lock is held by a leftover process, so the Rust side rests on the byte-identical patch plus CI, whose Check run was in progress on cceb38e3b.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

…eover

# Conflicts:
#	rs/moq-net/src/model/origin.rs

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

This pass reviewed the single merge pushed since the prior review (59d06f0f3, merging origin/main at 979a79492); the commits it brings in from main are not this PR's own work.

  • Stats-prefix exclusion absorbed — the branch takes main's RouteEntry.excluded, OriginState::authorizes, Producer::exclude/publishable, the routed subscriber's admits(origin, …), and the relay's publisher() exclusion of the stats subtree for non-peer grants.
  • Only the required field lands in the PR's route entry — the PR's own contribution changes by excluded: self.excluded.clone() in the RouteEntry that Announcing::announce builds, since that literal is the branch's rewrite of main's.
  • Lending now respects the claim's authorization — the merge also hand-added .filter(|entry| entry.authorizes(path)) to lender, so a job takes a claim's instance only when the claim's scope and exclusions admit the job's path. That is the same gate main had on the takeover() path this PR deletes, so the rule survives its replacement rather than disappearing with it.

cargo check -p moq-net --all-features passes on the merged tree; the full unit suite exceeds the sandbox shell cap, so CI's Check is the authority.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

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