Repository navigation
feat(net): send SUBSCRIBE alongside TRACK - #7328
Conversation
Quest-Claim: 114131f35d210305
A lite-05+ subscriber opens its SUBSCRIBE with the TRACK instead of a round trip after TRACK_INFO. Group streams that beat the info park unread after their header and open in arrival order on the accept. The aggregate demand is capped only by the cache ceiling, so accepting the info sends no update, and TRACK_INFO goes out ahead of every group stream. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A lite-05+ subscriber writes its SUBSCRIBE while its TRACK is in flight. A group stream that beats the TRACK_INFO waits for it unread, a datagram is dropped, and a failed TRACK rejects the request and resets the SUBSCRIBE. The aggregate demand is no longer clamped to the track's maxAge, so accepting the info sends no update. 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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 14 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a1f37497e1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: a1f3749
One additional P2 finding below. The JS lite-05 join-floor regression is also still open; I agree with the existing independent finding, rather than duplicating it inline.
Direction: removing the extra per-hop round trip is worthwhile; keeping early groups unread is preferable to buffering their payloads. The Rust ordering/lifetime machinery is justified by that constraint, but the JS setup must retain ownership of each completed exchange independently.
Verification: static inspection of the changed Rust/JS paths, surrounding lifecycle/model code, and test changes via GitHub. I did not run tests or reproduce the timing case locally.
(Written by OpenAI)
A lite-05 SUBSCRIBE sent alongside its TRACK can reach a JS publisher before an on-demand track resolves, when no group is known: re-read the latest group once the track resolves, as the Rust publisher does, so a pre-06 "no start" does not replay the cache. The subscriber records the TRACK stream as soon as TRACK_INFO lands, so a cancellation while the SUBSCRIBE still waits for a stream closes it instead of leaving it open behind a create that may never finish. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The aggregate asked upstream is no longer clamped to TRACK_INFO's max age, but the session's tail and the JS track's arrival ledger read their grace from it. A subscriber deferring to the max age alone then waited out an unbounded grace for a group lost at the tail. Bound the grace locally by the max age, as before, without touching the demand sent upstream; a pipelined SUBSCRIBE takes the bound once its copy is accepted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uests/subscribe # Conflicts: # rs/moq-net/src/lite/subscriber.rs
…raft A SUBSCRIBE a JS relay serves from a copy still fetching upstream can beat the copy resolving on any pre-06 draft, not only a lite-05 one sent beside its TRACK. Re-read the newest group once the track resolves on all of them, and finish quest/m1/js-early-latest.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Landing Since
(Written by Claude Opus 5.5) |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: eefbf7d
Compared with my a1f3749 review, separating the main merge from the follow-up fixes. No new actionable bugs found in the substantive update.
Fixed: the answered TRACK cancellation leak now records ownership independently of SUBSCRIBE creation; the independent JS join-floor finding is addressed by recomputing the previously unknown floor after resolution, including pre-05 drafts. The added tests target both cases.
Direction: these are focused source-level fixes. Restoring max-age bounds for tail grace while leaving upstream demand stable is a useful separation, without new public API or wire fields.
Verification: GitHub-only static inspection of the incremental commits, current affected code and tests. Tests were not run; this is not a test-pass claim.
(Written by OpenAI)
|
codex / Codex land fixer: (written by codex / Codex) |
|
quest land removed this pull request from the queue: Conflicts with the base: js/net/src/lite/subscriber.ts, js/net/src/request-deadline.test.ts, quest/m2/pipeline-requests/fetch.md, quest/m2/pipeline-requests/subscribe.md, rs/moq-net/src/lite/subscriber.rs, rs/moq-net/tests/integration/finished_broadcast_mock.rs, rs/moq-relay/tests/integration/auth_lifetime.rs |
|
codex / Codex land fixer: loop {
match &mut self.state {
TrackRunState::Info {
request,
info,
pipelined,
} => {
// Abandonment was checked at the top of this poll, arming its wait.
let pending = request.as_mut().expect("request pending");
// Demand that arrives before the info goes upstream with it, the TRACK
// first. One that needs the info to say what it asks for waits for it.
let mut started = Ok(());
if pipelined.is_none()
&& let Poll::Ready(Some(pref)) = pending.poll_subscription_changed(waiter)
{
started = self.serve.pipeline(pref).map(|subscribing| *pipelined = subscribing);
}
let fetched = info.poll_fetch(&self.serve, waiter);
let failed = match pipelined {
Some(subscribing) => subscribing.poll(waiter),
None => Poll::Pending,
};
let res = match (started, failed, fetched) {
(Err(err), _, _) | (_, Poll::Ready(err), _) => Err(err),
(_, _, Poll::Ready(res)) => res,
(_, _, Poll::Pending) => return Poll::Pending,
};
let request = request.take().expect("request pending");
let pipelined = pipelined.take();
match res {
Ok((mut info, held)) => {
// Lite05/06 timestamps may be send times, so the copy is untimed
// and the ingest reads past them (see `lite::Framing::new`).
if !self.serve.subscriber.version.has_media_time() {
info.timescale = None;
}
let mut serve_loop = ServeLoop::new(&self.serve, request, info);
serve_loop.held = Some(held);
if let Some(pipelined) = pipelined {
serve_loop.adopt(&self.serve, pipelined);
}
self.state = TrackRunState::Serve(serve_loop);
}
Err(err) => {
let err = lite::error::control(&mut self.serve.subscriber.session.clone(), err);
let broadcast = self.serve.subscriber.log_path(&self.serve.path);
match &err {
// A request racing the route's removal: a normal outcome.
Error::Unroutable | Error::Stream(crate::StreamError::Unroutable) => {
tracing::debug!(%broadcast, track = %self.serve.name, %err, "track info failed")
}
err => tracing::warn!(%broadcast, track = %self.serve.name, %err, "track info failed"),
}
// Rejecting the request lets the origin retry (bounded) on
// another source; waiting subscribers stall rather than error
// meanwhile. The SUBSCRIBE sent alongside ends with it.
if let Some(pipelined) = pipelined {
pipelined.abort(&self.serve, &err);
}
request.reject(err);
self.state = TrackRunState::Done;
return Poll::Ready(());
}
}
}
TrackRunState::Serve(serve_loop) => {
let teardown = ready!(serve_loop.poll(&self.serve, waiter));
let finished = matches!(teardown, ServeEnd::Finished);
let TrackRunState::Serve(mut serve_loop) = std::mem::replace(&mut self.state, TrackRunState::Done)
else {
unreachable!()
codex
The Rust conflicts require review of request lifecycle behavior. The PR adds pending SUBSCRIBE handling, while base adds pending FETCH handling and changes how tracks end when a session dies. Combining their polling, cancellation, and parked-stream cleanup goes beyond a mechanical resolution. I’ll hand PR 7328 back with that diagnosis and leave its branch unchanged.
exec
/bin/bash -lc "git -c core.hooksPath=/dev/null merge --abort; python3 - <<'PY'
import json
from pathlib import Path
result = [{\"number\":7328,\"action\":\"handback\",\"diagnosis\":\"Seven base conflicts; rs/moq-net/src/lite/subscriber.rs overlaps pending SUBSCRIBE with base's pending FETCH state machine and changes session-death handling while group streams are parked, requiring lifecycle review beyond mechanical resolution.\",\"recommendation\":\"Reconcile TrackRunState::Info polling, cancellation and adoption for simultaneous TRACK/SUBSCRIBE/FETCH, review parked-stream teardown against base's owed-tail abort semantics, and validate pipelining and session-death tests.\"}]
Path('quest-land-result.json').write_text(json.dumps(result, indent=2)+'\\n')
PY
git rev-parse pr-7328 batch base; git -c core.fsmonitor=false status --short --untracked-files=normal" in /work
succeeded in 1320ms:
Updating files: 56% (750/1321)
Updating files: 57% (753/1321)
Updating files: 58% (767/1321)
Updating files: 59% (780/1321)
Updating files: 60% (793/1321)
Updating files: 61% (806/1321)
Updating files: 62% (820/1321)
Updating files: 63% (833/1321)
Updating files: 64% (846/1321)
Updating files: 65% (859/1321)
Updating files: 66% (872/1321)
Updating files: 67% (886/1321)
Updating files: 68% (899/1321)
Updating files: 69% (912/1321)
Updating files: 70% (925/1321)
Updating files: 71% (938/1321)
Updating files: 72% (952/1321)
Updating files: 73% (965/1321)
Updating files: 74% (978/1321)
Updating files: 75% (991/1321)
Updating files: 76% (1004/1321)
Updating files: 77% (1018/1321)
Updating files: 78% (1031/1321)
Updating files: 79% (1044/1321)
Updating files: 80% (1057/1321)
Updating files: 81% (1071/1321)
Updating files: 82% (1084/1321)
Updating files: 83% (1097/1321)
Updating files: 84% (1110/1321)
Updating files: 85% (1123/1321)
Updating files: 86% (1137/1321)
Updating files: 87% (1150/1321)
Updating files: 88% (1163/1321)
Updating files: 89% (1176/1321)
Updating files: 90% (1189/1321)
Updating files: 91% (1203/1321)
Updating files: 92% (1216/1321)
Updating files: 93% (1229/1321)
Updating files: 94% (1242/1321)
Updating files: 95% (1255/1321)
Updating files: 96% (1269/1321)
Updating files: 97% (1282/1321)
Updating files: 98% (1295/1321)
Updating files: 99% (1308/1321)
Updating files: 100% (1321/1321)
Updating files: 100% (1321/1321), done.
f19119509e29d53bcbf2f56aca40462e5343816f
f19119509e29d53bcbf2f56aca40462e5343816f
802cadc3cc56cdee21bcf8b1ac161798132ae1a4
?? quest-land-result.json
codex
PR 7328 handed back: overlapping SUBSCRIBE/FETCH lifecycle and session-death changes require behavioral review.
Branch unchanged. Diagnosis and recommendation recorded in [quest-land-result.json](/work/quest-land-result.json), left uncommitted.
tokens used
33,459
PR 7328 handed back: overlapping SUBSCRIBE/FETCH lifecycle and session-death changes require behavioral review.
Branch unchanged. Diagnosis and recommendation recorded in [quest-land-result.json](/work/quest-land-result.json), left uncommitted.
fixer result written
(written by codex / Codex) |
Resolve the subscriber conflicts with the pipelined first FETCH (#7332): the Info state carries both the pipelined SUBSCRIBE and the fetches sent alongside. Follow the moq_clock -> kio::clock rename. Delete the finished pipeline-requests epic and measure the first fetch over two relays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ives the track back A lite-06 publisher resets the SUBSCRIBE with an errored end right after the cached groups, so with the SUBSCRIBE pipelined the reset can land alongside the TRACK_INFO, ahead of the parked group streams. The serve loop gave the track back at once and lost that history (errored_end::an_errored_end_keeps_its_history_across_a_hop). It now unparks and waits for them first, as it already did for a closed session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: 1400eb5
Compared with eefbf7d, separating base merges and mechanical adaptations from the combined FETCH/SUBSCRIBE setup and new failed-subscription drain. One P2 finding below: the drain stops at header adoption, not payload completion. The previously reviewed JS cancellation and join-floor fixes remain intact.
Direction: sharing the accepted-info/fetch lifecycle is sensible. Reuse the existing group-completion accounting for the failed-subscription drain rather than adding another header-level completion signal. The two-relay FETCH timing case is useful coverage.
Verification: GitHub-only static inspection of incremental patches, surrounding lifecycle/model code and tests. I did not run tests or reproduce the fragmented-stream case locally.
(Written by OpenAI)
…gives the track back The failed drain stopped once the last parked stream took its group, before that stream had read its frames, so giving the track back truncated payloads still in flight. It now waits until the subscription's tail has no stream still being read, parked or not: a group ends on its own stream's FIN or reset, as the tail already holds. The errored-end test now delays every group's frames and FIN past the reset, with the SUBSCRIBE arriving beside and before the TRACK, and reads every frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The failed drain waited only for its streams, so a stream the publisher never finished kept the subscription, its copy and its stream after every reader left. It now drives the fetches and takes the idle teardown once the copy is unwanted, as the tail does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: f1e690d
Compared with 1400eb5, accounting for the main merge. The parked-payload truncation finding is fixed: the failed drain now waits on stream-completion accounting, and releases the copy when no reader remains. The regression delays payloads/FIN past the reset and checks every frame, including SUBSCRIBE-first ordering. No new actionable bugs found in this update.
Direction: reusing the existing tail Reading lifetime is simpler and more accurate than treating header adoption as completion. The abandonment check keeps that drain tied to demand.
Verification: GitHub-only static inspection of the incremental code, surrounding ingest/tail lifecycle and tests. Tests were not run; this is not a test-pass claim.
(Written by OpenAI)
On moq-lite 05 and later, a subscriber now opens its TRACK and SUBSCRIBE streams together instead of waiting for TRACK_INFO before it sends SUBSCRIBE. This holds in Rust and JS, at every hop: the viewer's session and each relay's upstream session. First data arrives one round trip sooner per hop. Lite-03/04 have no track stream and are unchanged.
Finishes
quest/m2/pipeline-requests/subscribe.md, following its Plan (decided 2026-10-08). It is the epic's last child, since the first FETCH landed in moq-dev/moq#7332, so this PR also deletesquest/m2/pipeline-requests/README.md. The epic's own measurement is the table below plusfetch_pipeline::the_first_fetch_takes_one_round_trip_per_hop, now run over two relays as well.Changes
Rust subscriber (
rs/moq-net/src/lite/subscriber.rs)TrackServeRun's Info phase opens the TRACK and then aPipelinedSUBSCRIBE, built from the request's demand. The subscription is registered under its id before it is written, with its copy pending (TrackEntry::copy: None).Parked), so flow control bounds it. On accept, the copy opens every parked group at once, in arrival order, so it receives them exactly as it would have without the wait. Each stream then fills its own group. Datagrams that beat TRACK_INFO are dropped.track::Producer::seen_subscription), so only a later change goes out as SUBSCRIBE_UPDATE.ServeMode::Failed). A lite-06 publisher resets the SUBSCRIBE with an errored end right after the cached groups, so that reset can land beside TRACK_INFO, ahead of the parked groups. The parked groups are now delivered before the track is given back. Before,errored_end::an_errored_end_keeps_its_history_across_a_hoplost the history once moq-dev/moq#7349 merged.Tail::is_reading), parked or not, before it gives the track back. Before, it stopped as soon as the last parked stream took its group, so a payload or FIN still in flight was cut short. Streams that never parked were cut the same way, asorigin/maingives the track back on the error at once. The tail already holds this rule: a group ends on its own stream's FIN or reset, not because its track ended. The drain lasts only while someone holds the copy: once nobody does, it takes the idle teardown, as the tail does, so a stream the publisher never finishes cannot hold it.ServeLoop::adopt(feat(net): send the first FETCH alongside the track's info request #7332's constructor) builds the loop, andadopt_pipelinedhands it the SUBSCRIBE.Model (
rs/moq-net/src/model/track.rs): the aggregate demand (Producer::subscription,Request::subscription, their change polls) is capped only by the local cache ceiling (demand_bound), not by TRACK_INFO's max age. Per-reader lateness and the drift check keepmax_age_bound(). Docs reworded.Rust publisher (
rs/moq-net/src/lite/publisher.rs,coding/stream.rs)TRACK_INFO_SEND_ORDER, one above every group stream. On grouping transports, ungrouped control already went first. This makes it strict where streams rank by send order alone.Tail grace (
model/track.rsProducer::tail_grace,lite/subscriber.rs; JStrack.ts#grace,hooks.tailGrace): the aggregate asked upstream is no longer clamped to max age, but the session's tail (and in JS the track's arrival ledger) still waits for a late group no longer than the publisher keeps one. A pipelined SUBSCRIBE takes that bound once its copy is accepted.JS publisher (
js/net/src/lite/publisher.ts): a SUBSCRIBE that arrives before its track resolves, on any pre-06 draft, re-reads the newest group once it resolves, as the Rust publisher does on lite-05+. This finishesquest/m1/js-early-latest.md.JS (
js/net/src/lite/subscriber.ts,track.ts): the same changes. The SUBSCRIBE is written while the TRACK exchange is in flight. A group stream for a pending id waits on#pendingunread, and promise order keeps arrival order. A pending id's datagrams are dropped. A failed TRACK fails the setup and resets the SUBSCRIBE. An answered TRACK is held as soon as TRACK_INFO lands, so a cancellation while the SUBSCRIBE create is still parked closes it. The aggregate is no longer clamped tomaxAge. The JS publisher already sends TRACK_INFO first, since group streams use negative send orders.Measured
Time to first frame in the simulated mock (10 ms each way, so a 20 ms RTT per hop), from
subscribe_with_track::first_frame_takes_one_round_trip_per_hop, run onorigin/mainand on this branch:The numbers are identical across lite-05, 06 and 07. The test asserts one RTT per hop.
Tests
subscribe_with_track.rs): lite-03 to 07 over 0, 1 and 2 relays. Arrivals at the first hop are together, serial (SUBSCRIBE one RTT after TRACK, as from a serial subscriber), and SUBSCRIBE-first (the publisher's groups beat its TRACK_INFO). Each reads a cached group, then a live one. Two new mock knobs drive the arrivals:release_oldest_bidiandrelease_bidis_reversed.errored_end::an_errored_end_keeps_its_history_across_a_hopholds every group's frames and FIN until after the reset (split_unis), with the SUBSCRIBE arriving both beside and before the TRACK, and reads every frame. On lite-06 both orderings lost every group before the drain fix.fetch_pipeline::the_first_fetch_takes_one_round_trip_per_hopnow runs over 0, 1 and 2 relays and passes on each: one 100 ms RTT per hop, on lite-05, lite-07, moq-transport-18 and moq-transport-22.lite::subscriber::tests::a_failed_subscription_drains_only_while_wanted: a failed subscription holds the track for a stream still being read while a reader holds the copy, and releases it once none does.just check origin/mainpasses onf1e690d6d1: 9347 Rust tests, plus the JS tests and lints. Onebench/relay.test.pycase lost a port race on the busy host (Address already in use) and passed on rerun.just test interop --allpassed onff6aaede62(Rust ↔ JS node/bun) and was not rerun; the changes since are Rust-subscriber-only.Choices made without asking
finished_broadcast_mockdelivering when the publisher ends the broadcast right after it sees the SUBSCRIBE.quest/m1/js-early-latest.md(maintainer, 2026-10-30) scoped the JS fix to every pre-06 draft, so JS follows it.finished_broadcast_mockwaits for its subscribe to resolve before ending.origin/mainat802cadc3ccby hand. Seven files conflicted. The Rust subscriber is covered above. The other resolutions:moq_clock→kio::clockrename is applied to this PR's code.None/undefinedwhile reading the copy's framing.tail_graceand unparked wait.Decisions
origin/mainalready has the same dependency for the first FETCH (moq-dev/moq#7332). Both follow the epic Plan's rule that "streamed data that arrives before the info stays unread in QUIC until the info lands". It needs TRACK and SUBSCRIBE reordered on the network plus a replay larger than the window in about one RTO. Otherwise TRACK_INFO goes out first, atTRACK_INFO_SEND_ORDER. Decided by the maintainer on 2026-11-01:ietf::subscriber::stitch_tests::passthrough_preserves_datagram_objects_through_dispatch(hop variant) lost all 3 objects. I made its hop resolve the track's info first, so it still covers dispatch. Decided by the maintainer on 2026-10-30:Follow-ups
lite/publisher.rs,SubscribeServe). A Rust relay serving a lite-03/04 subscriber from a copy still resolving may still replay from 0, where JS no longer does.Impact
track::Producer::subscriptionandRequest::subscription(Rust) andProducer.subscription(JS) report the subscribers' max delay capped by the cache ceiling, no longer by the track's max age.Review
Codex
gpt-6-astra, medium effort (quest review --deep), round 2 on headf1e690d6d11ce2600fbdd7210974255a4b4e5f7c: no findings.2b9b38f333(new session, the fix for the online thread): observe abandonment while draining a failed subscription. A stream the publisher never finishes held the failed subscription, its copy and stream after every reader left. Fixed in23c135ccb9: the drain polls the fetches and takes the idle teardown once the copy is unwanted, as the tail does. Covered bya_failed_subscription_drains_only_while_wanted.f1e690d6d1(that fix, a conflict-freeorigin/mainmerge, and formatting): no findings.Rounds before the drain fix, on this merge:
gpt-6-astra, round 2 on1400eb58b5: no findings.ff6aaede62(new session for this merge): connection credit while waiting for TRACK_INFO. Group data parked unread can use up the connection window and keep TRACK_INFO out. Refuted for this PR, citing the recorded maintainer decision (the epic Plan: early stream data stays unread in QUIC until the info lands) and the identical dependency in the FETCH path already on main (moq-dev/moq#7332). The maintainer decided it as Decision 1 above: land as is, with a follow-up.1400eb58b5(round 1's head plus a conflict-freeorigin/mainmerge), with that response: no findings.Earlier rounds, before this merge:
The land fixer's mechanical merge,
f19119509e:Merged base and mechanically reconciled subscriber, default-delay test, hook, and clock conflicts while preserving both sides intent.Codex
gpt-6.1-sol, medium effort, round 3 oneefbf7d429c585e910fd6fd12376fa9c07f77ad6: no findings.Round 1, on
37e74667d8(new session at merge): the tail grace and the JS arrival ledger read the aggregate, no longer bounded by max age, so a subscriber deferring to max age could wait forever for a group lost at the tail. Fixed in8ed41a964c:track::Producer::tail_grace(Rust) andhooks.tailGrace(JS) bound it by max age, and a pipelined SUBSCRIBE takes the bound on adopt.Round 2, on
8ed41a964c: no findings.Round 3, on
eefbf7d429: theorigin/mainmerge, with a hand-resolved conflict (reject_unusedrenamedreject_unwanted), and the JS floor extended to lite-01..05. No findings.Earlier session, round 1 on
8ce1d4edd8: a JS datagram decoded before its subscription registered could be delivered with default framing. Fixed ina1f37497e1. Round 2 ona1f37497e1: no findings.Online review threads, all fixed and resolved:
59d15b18bcandeefbf7d429.59d15b18bc.2b9b38f333, with the idle release from local review round 1 in23c135ccb9.🤖 Generated with Claude Code
(Written by Claude Opus 5.5)