Skip to main content

moqtap_proxy/
capability.rs

1//! What is representable, per draft, per site, per stream — and why not.
2//!
3//! One function answers that question — [`classify`] — and it has exactly
4//! two callers: [`Capabilities::supports`] / [`Capabilities::supports_on`],
5//! which publish the table a caller reads before a run, and the
6//! engine's executor, which decides what actually happens during one. That
7//! is the whole of the design: the table and the engine are the same code,
8//! so the table cannot become a documented lie about the engine.
9//! `tests/action_matrix.rs` asserts it against observed behaviour on all
10//! drafts.
11//!
12//! # Where each [`Refusal`] comes from
13//!
14//! Not every refusal is a `(site, kind)` fact, so not every refusal is
15//! [`classify`]'s to produce:
16//!
17//! * **Classified here**, from `(site, kind)` plus [`CapCtx`]:
18//!   [`Refusal::WrongSite`], [`Refusal::ControlStreamResetIllegal`],
19//!   [`Refusal::LengthChanged`], [`Refusal::WouldRedefineSubgroupId`],
20//!   [`Refusal::WouldDestroyStatusObject`], [`Refusal::ReservedHeaderMode`] and
21//!   [`Refusal::PayloadNotDelimited`].
22//! * **Produced by the executor**, because they depend on the action's payload
23//!   or on session state rather than on the pair: [`Refusal::WrongComposition`]
24//!   (what a `Delay` / `Hold` wrapped), [`Refusal::ErrorCodeOutOfRange`] (the
25//!   numeric code) and [`Refusal::SessionAlreadyClosing`] (a close already in
26//!   flight). They are reachable, and the sweep observes them; they are simply
27//!   not decidable from a kind.
28//! * **Table-only**: [`Refusal::StreamNotFramed`] and
29//!   [`Refusal::ControlFrameNotDecodable`]. Both appear only inside
30//!   [`Support::NotAttemptable`] and [`Support::Unreachable`], where nothing is
31//!   ever attempted, so neither is ever emitted as a
32//!   `ProxyEvent::ActionRefused`.
33//!
34//! `tests/action_matrix.rs::every_declared_refusal_is_reachable_or_declared_table_only`
35//! asserts that split per *variant*, in both directions.
36//!
37//! # The table answers for a **build**, not only for a draft
38//!
39//! [`DraftVersion`] carries all variants under every feature set,
40//! so [`Capabilities::for_draft`] answers for drafts this binary cannot
41//! speak. A reduced-draft build — `--no-default-features --features
42//! draft07`, a shipped configuration and one of CI's rows — cannot
43//! frame a byte of the drafts it left out, and
44//! `ProxySessionConfig::default().draft` is `Draft14` with nothing
45//! validating it against the compiled set. [`draft_is_compiled`] is
46//! therefore a fact [`classify`] reads, exactly like the draft number, and
47//! the object **and control** sites on an uncompiled draft are
48//! [`Support::Unreachable`] rather than [`Support::Yes`]. The two fail in
49//! different decoders and report different events, so they are two reads
50//! of the same fact rather than one; see [`Instead`], which is where each
51//! names what a run emits in its place. In the default all-drafts build
52//! every row of the table is unchanged.
53
54use moqtap_codec::version::DraftVersion;
55
56use crate::shape::{ClassRule, MatchKind, Matcher, ShapeProfile};
57use crate::types::BypassReason;
58use crate::types::DataStreamType;
59
60/// The fourth value of the two-bit subgroup-ID mode, which no draft assigns.
61///
62/// Drafts 16 through 20 reserve it by name and list the type bytes that carry
63/// it; draft-15 arrives at the same eight bytes by leaving them out of its
64/// table. Either way no header the decoder returns holds this value.
65///
66/// The codec stores a placeholder zero for **both** mode 1 (*subgroup ID is the
67/// first object's ID*) and this one, which is why [`CapCtx::subgroup_id_mode`]
68/// exists at all: without it a reserved-mode header is indistinguishable from a
69/// first-object-mode header and the elide guard reports
70/// [`Refusal::WouldRedefineSubgroupId`] for something that is not a subgroup ID
71/// question.
72const RESERVED_SUBGROUP_ID_MODE: u8 = 3;
73
74/// Where a decision was taken.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76#[non_exhaustive]
77pub enum Site {
78    /// [`ProxyHook::on_control_message`](crate::hook::ProxyHook::on_control_message).
79    Control,
80    /// [`ProxyHook::on_object`](crate::hook::ProxyHook::on_object).
81    Object,
82    /// [`ProxyHook::on_datagram`](crate::hook::ProxyHook::on_datagram).
83    Datagram,
84    /// [`ProxyHook::on_stream_open`](crate::hook::ProxyHook::on_stream_open).
85    StreamOpen,
86    /// [`ProxyHook::on_stream_header`](crate::hook::ProxyHook::on_stream_header).
87    StreamHeader,
88    /// [`ProxyHook::on_stream_end`](crate::hook::ProxyHook::on_stream_end).
89    ///
90    /// Honours [`Action::Pass`](crate::action::Action::Pass),
91    /// [`Action::ResetStream`](crate::action::Action::ResetStream) on a
92    /// **data** stream, and
93    /// [`Action::CloseSession`](crate::action::Action::CloseSession) on
94    /// either kind of stream — a session close is session-scoped, so no
95    /// site can be the wrong one for it. Everything else is refused;
96    /// delaying a stream's end is expressed by delaying its last object.
97    /// [`CapCtx::is_control_stream`] is what splits the two published
98    /// columns.
99    StreamEnd,
100}
101
102/// A capability, named independently of whether
103/// [`Action`](crate::action::Action) can express it.
104///
105/// Every kind here names a capability some value can express. A variant that
106/// no value can carry is a unit that compiles and never runs, and a table row
107/// saying so is a row about this crate's plans rather than about what it
108/// does — so the family of constructor-less capabilities is simply absent
109/// from this enum, and absence is what the table says by not listing it.
110///
111/// [`Self::ReplaceObject`] is deliberately **not** in that family, and the
112/// distinction is what keeps the table honest: a value that carries it to
113/// [`Site::Object`] exists (`Action::Replace(b)`), so the engine really is
114/// asked and really does refuse, with [`Refusal::WrongSite`]. The
115/// deferred-capability id printed on the *variant* names the gap; it is
116/// not the refusal a run emits. See the variant's own rustdoc.
117///
118/// **Attempt mapping** — how `tests/action_matrix.rs` turns a `(site,
119/// kind)` pair into something to run. Every kind maps to exactly one
120/// expression, and a kind whose mapping does not typecheck at a site is
121/// precisely a `NotAttemptable` cell:
122///
123/// | Kind | The attempt |
124/// |---|---|
125/// | `Pass` | `Action::Pass` |
126/// | `Replace` | `Action::Replace(b)` |
127/// | `ReplacePayload` | `Action::ReplacePayload(b)`, `b.len() == payload_len` |
128/// | `ReplaceObject` | `Action::Replace(b)` **at `Site::Object` only** — the same expression as `Replace`, so those two cells must agree, and `action_matrix.rs` asserts that they do. At every other site there is no attempt: the same expression there is `Replace`'s attempt, and this kind names a unit those sites do not carry (`NotAttemptable::KindNotDefinedAtThisSite`) |
129/// | `Delay` | `Action::Pass.delayed(d)` |
130/// | `Hold` | `Action::Pass.held(gate)` |
131/// | `DropElide` | `Action::Drop(DropMode::Elide)` |
132/// | `Truncate` | `Action::Truncate { bytes, code }` |
133/// | `ResetStream` | `Action::ResetStream { code }` |
134/// | `CloseSession` | `Action::CloseSession { code, reason }` |
135/// | `Open` | `StreamAction::Open` |
136/// | `Reject` | `StreamAction::Reject { code }` |
137/// | `OpenAfter` | `StreamAction::OpenAfter(d)` |
138/// | `SerializeAfter` | `StreamAction::SerializeAfter(key)` |
139///
140/// `Action::ReplacePayload` with a mismatched length **is** constructible,
141/// and it is refused with [`Refusal::LengthChanged`] — the `ReplacePayload`
142/// cell's `Conditional` failing. Re-framing an object around a new length
143/// is a different capability, and it has no row here because it has no
144/// value: there is nothing to attempt and therefore nothing to refuse.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146#[non_exhaustive]
147pub enum ActionKind {
148    /// [`Action::Pass`](crate::action::Action::Pass).
149    Pass,
150    /// [`Action::Replace`](crate::action::Action::Replace).
151    Replace,
152    /// [`Action::ReplacePayload`](crate::action::Action::ReplacePayload) at
153    /// the original length.
154    ReplacePayload,
155    /// [`Action::Delay`](crate::action::Action::Delay).
156    Delay,
157    /// [`Action::Hold`](crate::action::Action::Hold).
158    Hold,
159    /// [`Action::Drop`](crate::action::Action::Drop) with
160    /// [`DropMode::Elide`](crate::action::DropMode::Elide).
161    DropElide,
162    /// [`Action::Truncate`](crate::action::Action::Truncate).
163    Truncate,
164    /// [`Action::ResetStream`](crate::action::Action::ResetStream).
165    ResetStream,
166    /// [`Action::CloseSession`](crate::action::Action::CloseSession).
167    CloseSession,
168    /// [`StreamAction::Open`](crate::action::StreamAction::Open).
169    Open,
170    /// [`StreamAction::Reject`](crate::action::StreamAction::Reject).
171    Reject,
172    /// Replacing a whole wire object. **Attemptable and refused, not
173    /// unconstructible** — `Action::Replace(b)` at [`Site::Object`] is
174    /// exactly this attempt, so the engine is really asked and answers
175    /// with [`Refusal::WrongSite`]. Whole-object replacement at the object
176    /// site is a real attempt that really is refused, which is why this
177    /// kind is published and why its refusal is one a run emits.
178    ///
179    /// At every non-object site it is
180    /// `NotAttemptable { why: KindNotDefinedAtThisSite, refusal:
181    /// WrongSite { site, action: ReplaceObject } }`: a control frame, a
182    /// datagram and a stream are not objects, so no value carries this
183    /// kind there and nothing is ever attempted.
184    ReplaceObject,
185    /// Opening the peer stream after a delay.
186    /// [`StreamAction::OpenAfter`](crate::action::StreamAction::OpenAfter).
187    ///
188    /// `open_after_and_serialize_after_are_constructible` — the pair of
189    /// doc-tests that used to prove this capability's *absence* now proves
190    /// its presence, and they remain the crate's only compile-time proof
191    /// that the two variants exist with the shape they do. They are ordinary
192    /// doc-tests rather than inverted `compile_fail` blocks on purpose: a
193    /// `compile_fail` block that fails for the *wrong* reason reports `ok`
194    /// exactly as loudly as one that fails for the right one, which is how
195    /// the two blocks this replaces went on passing while asserting a
196    /// **struct**-variant syntax (`OpenAfter { after: … }`) that never
197    /// matched the tuple variants that actually exist. An ordinary
198    /// doc-test can only pass by compiling *and* running.
199    ///
200    /// ```
201    /// // open_after_and_serialize_after_are_constructible (1 of 2)
202    /// use std::time::Duration;
203    /// use moqtap_proxy::action::StreamAction;
204    ///
205    /// let a = StreamAction::OpenAfter(Duration::from_millis(1));
206    /// assert!(matches!(a, StreamAction::OpenAfter(d) if d == Duration::from_millis(1)));
207    /// ```
208    ///
209    /// ```
210    /// // open_after_and_serialize_after_are_constructible (2 of 2)
211    /// use moqtap_proxy::action::StreamAction;
212    /// use moqtap_proxy::event::ProxySide;
213    /// use moqtap_proxy::shape::StreamKey;
214    ///
215    /// // A session-local id plus the side it arrived on — never a
216    /// // transport stream id, which is the constant 0 on WebTransport.
217    /// let key = StreamKey { side: ProxySide::ClientToProxy, id: 7 };
218    /// let b = StreamAction::SerializeAfter(key);
219    /// assert!(matches!(b, StreamAction::SerializeAfter(k) if k == key));
220    /// ```
221    ///
222    /// And the verdicts, which is the one place the two kinds disagree:
223    /// `SerializeAfter` takes exactly the verdict
224    /// [`Self::Open`] takes at every site, and `OpenAfter` takes the same
225    /// except at [`Site::StreamHeader`], where the peer stream already
226    /// exists and there is nothing left to defer.
227    ///
228    /// ```
229    /// use moqtap_proxy::capability::{classify, ActionKind, CapCtx, Refusal, Site, Support};
230    /// for site in [Site::StreamOpen, Site::StreamHeader] {
231    ///     assert_eq!(
232    ///         classify(site, ActionKind::SerializeAfter, &CapCtx::default()),
233    ///         classify(site, ActionKind::Open, &CapCtx::default()),
234    ///         "SerializeAfter tracks Open at every site",
235    ///     );
236    /// }
237    /// assert_eq!(
238    ///     classify(Site::StreamOpen, ActionKind::OpenAfter, &CapCtx::default()),
239    ///     Support::Yes,
240    /// );
241    /// assert_eq!(
242    ///     classify(Site::StreamHeader, ActionKind::OpenAfter, &CapCtx::default()),
243    ///     Support::No(Refusal::WrongSite {
244    ///         site: Site::StreamHeader,
245    ///         action: ActionKind::OpenAfter,
246    ///     }),
247    /// );
248    /// ```
249    OpenAfter,
250    /// Head-of-line simulation.
251    /// [`StreamAction::SerializeAfter`](crate::action::StreamAction::SerializeAfter).
252    ///
253    /// Its constructor proof hangs on [`Self::OpenAfter`], with its pair.
254    SerializeAfter,
255}
256
257/// Whether a capability is available.
258///
259/// Five verdicts, not three. `Yes` / `No` / `Conditional` alone fit a large
260/// part of the table [`classify`] answers not at all: 34 of its 84 site×kind
261/// pairs — six sites, fourteen kinds — are ruled out by the *return type* (a
262/// site that returns [`StreamAction`](crate::action::StreamAction) cannot be
263/// handed an [`Action`](crate::action::Action), nor the other way round), and
264/// the object and control cells on a draft this build did not compile name a
265/// refusal the engine can never emit because the hook is never invoked
266/// there. Written `—` or "unreachable" in prose, neither class is anything
267/// `tests/action_matrix.rs` can assert; as verdicts, both are.
268#[derive(Debug, Clone, PartialEq, Eq)]
269#[non_exhaustive]
270pub enum Support {
271    /// The engine executes it and the wire changes.
272    Yes,
273    /// Attemptable, and the engine refuses it with this reason. Exactly
274    /// one `ProxyEvent::ActionRefused` per attempt.
275    No(Refusal),
276    /// Executable, gated on a per-unit fact the table caller did not
277    /// supply. The precondition is named so a caller can test for it.
278    Conditional(Precondition),
279    /// **No value of [`Action`](crate::action::Action) or
280    /// [`StreamAction`](crate::action::StreamAction) can carry this kind to
281    /// this site**, so the engine can never be asked and no
282    /// `ActionRefused` can ever be emitted.
283    ///
284    /// Two families, and [`NotAttemptable`] names which:
285    ///
286    /// 1. a site whose return type is the other enum (`Pass` at
287    ///    `StreamOpen`, `Reject` at `Object`, …) — two variants,
288    ///    [`NotAttemptable::SiteReturnsAction`] and
289    ///    [`NotAttemptable::SiteReturnsStreamAction`], so that the table
290    ///    says which direction the mismatch runs in;
291    /// 2. a kind whose unit does not exist at this site —
292    ///    [`ActionKind::ReplaceObject`] anywhere but [`Site::Object`].
293    ///
294    /// `refusal` is what the published table reports and is never emitted
295    /// as an event. For both families it is a variant that *is* reachable
296    /// elsewhere in the matrix ([`Refusal::WrongSite`]). The split is per
297    /// *variant*, not per cell: a variant is table-only when no cell
298    /// anywhere emits it as a real `ActionRefused`.
299    /// What the sweep observes is **zero action events** — no `ActionApplied`,
300    /// no `ActionRefused`, no `ActionFailed` — and `actions_refused` unchanged.
301    /// It is not *zero events of any kind*: per-stream impairments are a
302    /// property of the stream, not of the kind swept, so a stream the framer
303    /// gave up on still reports its one `Impairment { FramerBypass { .. } }`
304    /// while every `NotAttemptable` cell on it stays silent.
305    NotAttemptable {
306        /// Which family, so a reader is not left to infer it.
307        why: NotAttemptable,
308        /// What the table reports. Never emitted as an event.
309        refusal: Refusal,
310    },
311    /// Constructible and well-formed, but the hook is **never invoked**
312    /// for this cell, so nothing is ever attempted and no `ActionRefused`
313    /// is ever emitted.
314    ///
315    /// Two occupants, each reporting what the run does emit rather than
316    /// the refusal it cannot. Both are a draft this build did not compile,
317    /// one decoder apart — see `object_framing_bypass`:
318    ///
319    /// 1. **any** stream on such a draft, where the stream *header* decode
320    ///    returns `UnsupportedDraft` first — see [`draft_is_compiled`], which
321    ///    is why this verdict is a build fact and not only a draft fact.
322    /// 2. the **control** site on such a draft, where
323    ///    `AnyControlMessage::decode` has no arm and
324    ///    `ControlStreamParser::feed` steps over every frame before the
325    ///    hook is offered one. Same fact as case 1, a different decoder,
326    ///    and a different report — which is what [`Instead`] is for.
327    ///
328    /// There was a third, and its going is worth a sentence because it is
329    /// the shape of thing this enum is easiest to be wrong about. A fetch
330    /// stream on drafts 18, 19 and 20 used to occupy this verdict, on the
331    /// grounds that nothing on such a stream settles the Group Order its
332    /// Group IDs are differences against. Nothing on the *stream* still
333    /// does; the FETCH that opened it always did, and the session reads it
334    /// now — see `fetch_group_order_is_needed`. The cell was answering a
335    /// question about a draft with a fact about one component.
336    ///
337    /// A caller that reads the table by draft number alone will not see
338    /// case 1 coming, which is why the table answers by build rather than by
339    /// number. It is not a state a *session* can now reach —
340    /// [`ProxySession::run`](crate::session::ProxySession::run) refuses an
341    /// uncompiled draft with
342    /// [`ProxyError::DraftNotCompiled`](crate::error::ProxyError::DraftNotCompiled)
343    /// before it dials — but the table is answerable without a session, and a
344    /// caller asking it about a draft this build does not carry has to be
345    /// told the truth about that draft rather than about draft numbers in
346    /// general.
347    Unreachable {
348        /// What the table reports. Never emitted as an event.
349        refusal: Refusal,
350        /// What the run emits instead, and how often.
351        instead: Instead,
352    },
353}
354
355/// What a run reports in place of the action event a
356/// [`Support::Unreachable`] cell can never produce.
357///
358/// Every `Unreachable` cell owes one, and giving the field a type of its own is
359/// what collects the debt: a cell whose only honest answer would be *nothing at
360/// all is emitted* finds no variant here to reach for, so it cannot be
361/// published until the report it needs exists. The control site on an
362/// uncompiled draft is the case in point: no hook is ever offered a frame
363/// there, and its verdict can be [`Support::Unreachable`] rather than
364/// [`Support::Yes`] only because
365/// [`ImpairmentKind::ControlFrameNotDecodable`](crate::event::ImpairmentKind::ControlFrameNotDecodable)
366/// gives it something true to point at.
367///
368/// The field is not a second copy of `refusal`. A refusal names what the
369/// *table* would say; this names what an observer will actually see on the
370/// wire-facing side, which is the only thing a run can be checked against.
371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372#[non_exhaustive]
373pub enum Instead {
374    /// [`ProxyEvent::Impairment`](crate::event::ProxyEvent::Impairment)
375    /// carrying
376    /// [`ImpairmentKind::FramerBypass`](crate::event::ImpairmentKind::FramerBypass)
377    /// with this reason, **once per such stream**.
378    FramerBypass(BypassReason),
379    /// [`ProxyEvent::Impairment`](crate::event::ProxyEvent::Impairment)
380    /// carrying
381    /// [`ImpairmentKind::ControlFrameNotDecodable`](crate::event::ImpairmentKind::ControlFrameNotDecodable),
382    /// **once per control stream direction**, however many frames were
383    /// refused; the running figure is
384    /// [`Counters::control_frames_not_decodable`](crate::instrument::Counters::control_frames_not_decodable).
385    ///
386    /// Carries no reason, because on this cell there is only one: the
387    /// build. A frame refused on a draft that *was* compiled produces the
388    /// same event and no table cell, since it is a fact about one frame.
389    ControlFrameNotDecodable,
390}
391
392/// Why a [`Support::NotAttemptable`] cell cannot be reached.
393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394#[non_exhaustive]
395pub enum NotAttemptable {
396    /// This site's hook method returns
397    /// [`StreamAction`](crate::action::StreamAction), and `kind` names a
398    /// content action.
399    SiteReturnsStreamAction,
400    /// This site's hook method returns [`Action`](crate::action::Action),
401    /// and `kind` names a stream action.
402    SiteReturnsAction,
403    /// No value of [`Action`](crate::action::Action) or
404    /// [`StreamAction`](crate::action::StreamAction) carries this kind to
405    /// this site, so nothing here can be attempted.
406    NoConstructor,
407    /// The kind names a unit this site does not carry, so no value can
408    /// bring it here even though the *expression* that would carry it is
409    /// well-typed at this site under a different kind.
410    ///
411    /// The only occupant is [`ActionKind::ReplaceObject`] at any
412    /// site but [`Site::Object`]: `Action::Replace(b)` typechecks at
413    /// `Site::Control` and `Site::Datagram`, but there it *is* the
414    /// [`ActionKind::Replace`] attempt — a control frame is not an
415    /// object. The accompanying refusal is
416    /// [`Refusal::WrongSite`], the same refusal the attemptable
417    /// `ReplaceObject × Object` cell really emits, so a reader comparing
418    /// the table against a run sees one consistent answer.
419    KindNotDefinedAtThisSite,
420}
421
422// ── Table-only refusals ────────────────────────────────────────────────
423//
424// `Support::NotAttemptable` and `Support::Unreachable` both carry a
425// `Refusal` the engine never emits, because in both cases nothing is ever
426// attempted. Exactly one `Refusal` variant is table-only —
427// `Refusal::StreamNotFramed` — and
428// `tests/action_matrix.rs::every_declared_refusal_is_reachable_or_declared_table_only`
429// asserts that split in both directions: every other variant must be
430// observed as a real `ActionRefused` somewhere in the sweep, and this one
431// must never be.
432
433/// A runtime fact a [`Support::Conditional`] verdict depends on.
434#[derive(Debug, Clone, Copy, PartialEq, Eq)]
435#[non_exhaustive]
436pub enum Precondition {
437    /// The replacement must be exactly `ObjectMeta::payload_len` bytes and
438    /// the object must not carry a status.
439    ReplacementLengthEqualsPayload,
440    /// The object must not be index 0 of a stream whose subgroup ID is
441    /// defined as the first object's ID.
442    NotFirstObjectOfImplicitSubgroup,
443    /// The object must not carry an Object Status.
444    NotAStatusObject,
445    /// The unit's payload must start at a known offset.
446    ///
447    /// True at the object site on every draft (`wire_len -
448    /// payload_length`). At the **datagram** site it is true on every
449    /// draft but draft-14, and false on three counts:
450    ///
451    /// * **draft-14**, where `AnyDatagramHeader` is a `DatagramObject`
452    ///   whose `decode` consumes the payload, so the only derivable
453    ///   offset is the whole datagram;
454    /// * a **status datagram**, which has no payload slot;
455    /// * a datagram whose header **did not decode**, where the hook still
456    ///   fires but no offset exists.
457    ///
458    /// Failing it is [`Refusal::PayloadNotDelimited`].
459    DatagramPayloadDelimited,
460    /// The replacement must fit the connection's maximum datagram size.
461    ///
462    /// **[`classify`] cannot evaluate this one.** Nothing in
463    /// `moqtap-client`'s transport exposes a maximum datagram size, so
464    /// [`CapCtx`] has no field for it and this verdict is always
465    /// `Conditional`: the actual answer comes from `send_datagram`
466    /// failing. That makes it the one precondition whose failure is a
467    /// `ProxyEvent::ActionFailed` rather than an `ActionRefused` — the
468    /// action was admitted and the transport rejected it. Stated here
469    /// rather than left to be inferred.
470    WithinMaxDatagramSize,
471}
472
473/// Why an action could not be executed.
474///
475/// Reported per attempt, never once per stream. A rule that would have
476/// fired forty times and was refused forty times reports forty.
477#[derive(Debug, Clone, PartialEq, Eq)]
478#[non_exhaustive]
479pub enum Refusal {
480    /// The action has no meaning at this site.
481    WrongSite {
482        /// Where it was attempted.
483        site: Site,
484        /// What was attempted.
485        action: ActionKind,
486    },
487    /// A `Delay` or `Hold` wrapped an action the engine cannot schedule.
488    WrongComposition {
489        /// What the modifier wrapped.
490        detail: &'static str,
491    },
492    /// Performing it would be a session-level protocol violation — a reset
493    /// or truncation of a control stream, on any draft 07-21.
494    ControlStreamResetIllegal,
495    /// `ReplacePayload` whose length differs from the original.
496    LengthChanged {
497        /// The original payload length.
498        from: u64,
499        /// The replacement's length.
500        to: u64,
501    },
502    /// Eliding index 0 of a stream whose subgroup ID is the first object's
503    /// ID would silently redefine the subgroup ID downstream.
504    WouldRedefineSubgroupId,
505    /// Eliding an object that carries an Object Status would destroy what
506    /// may be a boundary marker.
507    WouldDestroyStatusObject,
508    /// The stream header's subgroup-ID mode field holds a value this
509    /// draft reserves, so the header is not interpretable and no object
510    /// on the stream can be safely renumbered.
511    /// Distinct from [`Self::WouldRedefineSubgroupId`] on purpose. On drafts
512    /// 15 through 19 the codec stores a placeholder zero for **both** mode 1
513    /// (*subgroup ID is the first object's ID*) and mode 3 (reserved), so an
514    /// accessor returning `Option<u64>` cannot tell them apart and the earlier
515    /// guard would have reported `WouldRedefineSubgroupId` for a reserved-mode
516    /// header, where that reason is simply untrue.
517    /// `AnySubgroupHeader::subgroup_id_mode()` is what makes the distinction
518    /// available.
519    ReservedHeaderMode {
520        /// The mode value read from the header-type octet.
521        mode: u8,
522    },
523    /// The unit's payload boundary is not derivable, so a
524    /// payload-preserving splice cannot be located.
525    ///
526    /// Datagrams only. See [`Precondition::DatagramPayloadDelimited`] for
527    /// the three cases.
528    PayloadNotDelimited {
529        /// Which case: `*draft-14 header decode consumes the payload*`,
530        /// `*status datagram has no payload*`, or `*datagram header did not
531        /// decode*`.
532        detail: &'static str,
533    },
534    /// The framer stopped parsing this stream, so there is nothing
535    /// addressable to act on.
536    ///
537    /// **Table-only** — see the module's note above [`Precondition`]. The
538    /// engine never emits it, because when it is true the hook is never
539    /// called.
540    StreamNotFramed {
541        /// Why the framer gave up.
542        reason: BypassReason,
543    },
544    /// The decoder refused this control frame, so there is nothing decoded
545    /// to act on.
546    ///
547    /// **Table-only** — see the module's note above [`Precondition`]. The
548    /// engine never emits it, because when it is true
549    /// [`ProxyHook::on_control_message`](crate::hook::ProxyHook::on_control_message)
550    /// is never called: the message it would be handed is the thing that
551    /// did not decode.
552    ///
553    /// Published for a draft this build did not compile, where
554    /// `AnyControlMessage::decode` has no arm and refuses every frame the
555    /// stream carries. One malformed frame on a draft that *is* compiled
556    /// is refused for the same reason, but that is a fact about one frame
557    /// rather than about the pair the table answers for, so no cell
558    /// publishes it and the run reports it as
559    /// [`ImpairmentKind::ControlFrameNotDecodable`](crate::event::ImpairmentKind::ControlFrameNotDecodable)
560    /// instead.
561    ControlFrameNotDecodable,
562    /// The application error code exceeds the QUIC varint range
563    /// (2^62 - 1). Nothing was sent and the stream stays usable.
564    ErrorCodeOutOfRange {
565        /// The code that was requested.
566        code: u64,
567    },
568    /// A session close is already in flight.
569    SessionAlreadyClosing,
570}
571
572/// The facts [`classify`] needs.
573///
574/// A caller building the published table leaves the per-unit fields `None`
575/// and gets [`Support::Conditional`] where the answer depends on them; the
576/// engine fills them in and gets [`Support::Yes`] or [`Support::No`].
577///
578/// The struct is `#[non_exhaustive]`, so build one with
579/// [`CapCtx::default`] and assign the fields that are known.
580#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
581#[non_exhaustive]
582pub struct CapCtx {
583    /// The draft the session is running as.
584    pub draft: Option<DraftVersion>,
585    /// Which stream kind, at the object site.
586    pub stream_kind: Option<DataStreamType>,
587    /// Whether the stream is a control stream.
588    ///
589    /// Read at [`Site::StreamEnd`], which publishes two columns: `Some(true)`
590    /// selects the control column, and `Some(false)` or `None` the data one.
591    pub is_control_stream: Option<bool>,
592    /// Zero-based index of the object within its stream.
593    pub index_in_stream: Option<u64>,
594    /// Whether the stream header determines the subgroup ID.
595    pub subgroup_id_resolved: Option<bool>,
596    /// Whether the object carries an Object Status.
597    pub is_status_object: Option<bool>,
598    /// Declared payload length.
599    pub payload_len: Option<u64>,
600    /// Length of a proposed replacement payload.
601    pub replacement_len: Option<u64>,
602    /// Whether this unit's payload starts at a known offset.
603    ///
604    /// Always `Some(true)` at the object site. At the datagram site the
605    /// engine sets it from `data.len() - cursor.len()` being a real
606    /// boundary — false on draft-14, on a status datagram, and when the
607    /// header did not decode. Drives
608    /// [`Precondition::DatagramPayloadDelimited`].
609    pub payload_delimited: Option<bool>,
610    /// The two-bit subgroup-ID mode, on the drafts 15-21 whose header type
611    /// carries one. `None` on drafts 07-14, which have no such pair of bits,
612    /// and when the caller did not supply it. Mode 1 is *subgroup ID is the
613    /// first object's ID*; mode 3 is the value no draft assigns. Drives the
614    /// split between [`Refusal::WouldRedefineSubgroupId`] and
615    /// [`Refusal::ReservedHeaderMode`].
616    pub subgroup_id_mode: Option<u8>,
617}
618
619/// The single source of truth for what is executable.
620///
621/// [`Capabilities::supports`] and the engine's executor are its only two
622/// callers, which is what keeps the published table and the engine from
623/// disagreeing. `tests/action_matrix.rs` asserts the table against observed
624/// behaviour on all the drafts.
625///
626/// # How the verdict is reached
627///
628/// In order, because the order is what makes [`ActionKind::ReplaceObject`]
629/// have exactly one reading:
630///
631/// 1. `ReplaceObject` off [`Site::Object`] is `NotAttemptable
632///    { KindNotDefinedAtThisSite, WrongSite { .. } }`, and at `Site::Object`
633///    it is the same `No(WrongSite { .. })` as `Replace` — one value for
634///    both rows, since one expression carries both.
635/// 2. A return-type mismatch is `NotAttemptable { SiteReturns.., WrongSite
636///    { .. } }`.
637/// 3. The object site behind a stream the framer cannot address is
638///    [`Support::Unreachable`]: the hook is never invoked there, so no
639///    refusal can be emitted and the run's reportable fact is the bypass.
640///    One fact reaches this step: a draft this build did not compile
641///    ([`draft_is_compiled`]). Whether a fetch stream can be addressed is a
642///    property of the stream rather than of the draft — see
643///    `fetch_group_order_is_needed`.
644/// 4. The control site on a draft this build did not compile is
645///    [`Support::Unreachable`] too, for the same reason one decoder later:
646///    every frame is stepped over before the hook is offered one.
647/// 5. Otherwise the per-site rules apply.
648///
649/// # What an unsupplied fact means
650///
651/// A `None` field is *the caller did not say*, which yields
652/// [`Support::Conditional`] naming the fact — never a guess. Two `None`s are
653/// read structurally rather than conditionally, because a table caller supplies
654/// neither and the published cell must still be the right one:
655///
656/// * `draft: None` reads as *no draft-specific restriction applies*, so the
657///   elide guard is evaluated as though the draft had a first-object subgroup
658///   mode — the conservative side, since it yields `Conditional` rather than
659///   `Yes`.
660/// * `stream_kind: None` reads as a **subgroup** stream, which is what
661///   [`Capabilities::supports`] publishes; [`Capabilities::supports_on`] is how
662///   a caller asks about fetch.
663pub fn classify(site: Site, kind: ActionKind, cx: &CapCtx) -> Support {
664    if let Some(answer) = not_attemptable(site, kind) {
665        return answer;
666    }
667
668    // A stream the framer cannot address never reaches the object site, so
669    // nothing can be attempted and nothing can be refused. One fact lands
670    // here: *any* stream on a draft this build did not compile.
671    let framing_bypass = match (site, cx.draft) {
672        (Site::Object, Some(draft)) => object_framing_bypass(draft, cx.stream_kind),
673        _ => None,
674    };
675    if let Some(reason) = framing_bypass {
676        return Support::Unreachable {
677            refusal: Refusal::StreamNotFramed { reason },
678            instead: Instead::FramerBypass(reason),
679        };
680    }
681
682    // The same build fact one decoder later. On a draft this build did not
683    // compile, `AnyControlMessage::decode` has no arm, so
684    // `ControlStreamParser::feed` steps over every frame and the hook is
685    // never offered one — nothing is attempted here and nothing can be
686    // refused. It is a separate check rather than a wider `framing_bypass`
687    // because the two report different events, and a cell that pointed at
688    // the wrong one would send a reader looking for a `FramerBypass` that
689    // no control stream emits.
690    //
691    // `draft: None` falls through to the per-site rules, as everywhere
692    // else in this function: it means the caller did not say, and a build
693    // fact cannot be read off a draft nobody named.
694    if site == Site::Control && cx.draft.is_some_and(|draft| !draft_is_compiled(draft)) {
695        return Support::Unreachable {
696            refusal: Refusal::ControlFrameNotDecodable,
697            instead: Instead::ControlFrameNotDecodable,
698        };
699    }
700
701    match site {
702        Site::Control => classify_control(kind),
703        Site::Object => classify_object(kind, cx),
704        Site::Datagram => classify_datagram(kind, cx),
705        Site::StreamOpen | Site::StreamHeader => classify_stream_decision(site, kind),
706        Site::StreamEnd => classify_stream_end(kind, cx),
707    }
708}
709
710/// What a draft can express, queryable before a run.
711#[derive(Debug, Clone, Copy)]
712pub struct Capabilities {
713    draft: DraftVersion,
714}
715
716impl Capabilities {
717    /// The capability table for a draft.
718    #[must_use]
719    pub fn for_draft(draft: DraftVersion) -> Self {
720        Self { draft }
721    }
722
723    /// Whether `kind` is available at `site`, with no per-unit facts.
724    ///
725    /// At [`Site::Object`] this is the **subgroup**-stream column;
726    /// [`Self::supports_on`] answers for a named stream kind.
727    #[must_use]
728    pub fn supports(&self, site: Site, kind: ActionKind) -> Support {
729        classify(site, kind, &CapCtx { draft: Some(self.draft), ..CapCtx::default() })
730    }
731
732    /// Whether `kind` is available at `site` for a given stream kind.
733    #[must_use]
734    pub fn supports_on(
735        &self,
736        site: Site,
737        kind: ActionKind,
738        stream_kind: DataStreamType,
739    ) -> Support {
740        classify(
741            site,
742            kind,
743            &CapCtx {
744                draft: Some(self.draft),
745                stream_kind: Some(stream_kind),
746                ..CapCtx::default()
747            },
748        )
749    }
750
751    /// Whether a shaping rule keyed on `field` can ever claim a unit
752    /// arriving as `kind`. [`supports_matcher`], bound to this draft.
753    #[must_use]
754    pub fn supports_matcher(&self, kind: MatchKind, field: MatcherKey) -> bool {
755        supports_matcher(self.draft, kind, field)
756    }
757
758    /// Admit one class rule, or refuse it naming the draft and the key.
759    ///
760    /// The first key the rule names that [`supports_matcher`] answers
761    /// `false` for is the refusal, in the order the keys are declared on
762    /// [`Matcher`] — the same first-match convention
763    /// [`ShapeProfile::try_new`] uses for
764    /// [`ShapeError::InertMatcher`](crate::shape::ShapeError::InertMatcher),
765    /// so a rule with two dead keys reports the one an author reading their
766    /// own configuration top to bottom reaches first.
767    ///
768    /// # A rule that names no stream kind is judged against both
769    ///
770    /// [`Matcher::stream_kind`] is optional, and a rule that omits it claims
771    /// units of **any** kind. Such a rule is refused only when its key is
772    /// carried by none of them, because refusing it for being dead on fetch
773    /// alone would reject a rule that shapes subgroup traffic perfectly well
774    /// — and a false rejection here is worse than the silence this exists to
775    /// end, since it rejects a configuration that works.
776    pub fn admit_class(&self, class: &ClassRule) -> Result<(), UnsupportedMatcherKey> {
777        let aimed = class.matcher.stream_kind;
778        for key in keys_named(&class.matcher).into_iter().flatten() {
779            let carried = match aimed {
780                Some(kind) => supports_matcher(self.draft, kind, key),
781                None => ANY_KIND.iter().any(|&kind| supports_matcher(self.draft, kind, key)),
782            };
783            if !carried {
784                return Err(UnsupportedMatcherKey {
785                    class: class.name.clone(),
786                    draft: self.draft,
787                    kind: aimed,
788                    key,
789                });
790            }
791        }
792        Ok(())
793    }
794
795    /// Admit every class in a profile, or refuse at the first dead key.
796    ///
797    /// The pre-run check [`ShapeProfile::try_new`] cannot make: that
798    /// constructor validates the configuration alone and has no draft, so a
799    /// rule keyed on something the negotiated draft does not carry is valid
800    /// to it. This is the same question asked once a draft is known.
801    ///
802    /// # A session asks it twice, and the second time is not redundant
803    ///
804    /// Once before it dials, against the draft it is about to frame with,
805    /// which is the only moment a profile can be refused with nothing yet
806    /// forwarded. And once more when the peers name a draft, which drafts 07
807    /// to 14 do in their SETUP rather than in the ALPN they share — so for
808    /// that cohort the first answer was given about a configured guess and
809    /// the second is given about the session actually running. The two
810    /// differ only where a draft this build did not compile is involved, and
811    /// that is exactly the case where every rule in the profile is dead.
812    pub fn admit_profile(&self, profile: &ShapeProfile) -> Result<(), UnsupportedMatcherKey> {
813        profile.classes().iter().try_for_each(|class| self.admit_class(class))
814    }
815}
816
817// ── What a shaping rule may key on ─────────────────────────────────────
818
819/// Every kind a rule that names none of them may claim.
820///
821/// All three, and the list is read only for a matcher whose
822/// [`Matcher::stream_kind`] is `None`: such a rule is refused for a key only
823/// when **no** kind carries it. Leaving [`MatchKind::Datagram`] out of the list
824/// would refuse a rule keyed on something only a datagram carries, and
825/// including a kind that carried nothing would admit a rule that claims nothing
826/// — which is why the list is the answer to *what could this rule claim* rather
827/// than a restatement of the enum.
828const ANY_KIND: [MatchKind; 3] = [MatchKind::Subgroup, MatchKind::Fetch, MatchKind::Datagram];
829
830/// One value key a [`Matcher`] can be built on.
831///
832/// Six variants, spelled as the [`Matcher`] fields are, so a refusal names
833/// something an author can search their own configuration for — the same
834/// contract
835/// [`ShapeError::InertMatcher`](crate::shape::ShapeError::InertMatcher)'s
836/// `key` field carries, and deliberately the same spelling, so the two
837/// rejections read alike.
838///
839/// Two [`Matcher`] fields are **not** here, and their absence is a decision
840/// rather than an omission:
841///
842/// * [`Matcher::side`] is the forwarding task's own direction label, not a
843///   field any unit carries, so no draft can fail to carry it. The one side
844///   value that names nothing a hook site sees is already rejected by
845///   [`ShapeProfile::try_new`].
846/// * [`Matcher::stream_kind`] names *which* units a rule claims rather than a
847///   field those units carry. It is [`supports_matcher`]'s second argument, not
848///   one of its answers.
849///
850/// Distinct from
851/// [`MatcherField`](crate::shape::MatcherField), which is the *run's*
852/// vocabulary and covers a different set: that enum names the four keys a
853/// live session can report absent on a unit it actually saw, this one names
854/// the six keys a configuration can be built on before any session exists.
855/// They overlap on three names and neither is a superset of the other.
856#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
857#[non_exhaustive]
858pub enum MatcherKey {
859    /// [`Matcher::track_alias`].
860    TrackAlias,
861    /// [`Matcher::group_id`].
862    GroupId,
863    /// [`Matcher::subgroup_id`].
864    SubgroupId,
865    /// [`Matcher::object_id`].
866    ObjectId,
867    /// [`Matcher::priority`], MoQT's `publisher_priority`.
868    Priority,
869    /// [`Matcher::every_nth`].
870    EveryNth,
871}
872
873impl MatcherKey {
874    /// Every key, in the order the fields are declared on [`Matcher`].
875    ///
876    /// Published so a caller can sweep the whole axis without transcribing
877    /// it; [`Capabilities::admit_class`] reports in this order too.
878    pub const ALL: [MatcherKey; 6] = [
879        MatcherKey::TrackAlias,
880        MatcherKey::GroupId,
881        MatcherKey::SubgroupId,
882        MatcherKey::ObjectId,
883        MatcherKey::Priority,
884        MatcherKey::EveryNth,
885    ];
886
887    /// The key's name as the [`Matcher`] field is spelled.
888    #[must_use]
889    pub const fn field_name(self) -> &'static str {
890        match self {
891            MatcherKey::TrackAlias => "track_alias",
892            MatcherKey::GroupId => "group_id",
893            MatcherKey::SubgroupId => "subgroup_id",
894            MatcherKey::ObjectId => "object_id",
895            MatcherKey::Priority => "priority",
896            MatcherKey::EveryNth => "every_nth",
897        }
898    }
899}
900
901/// Whether a rule keyed on `field` can **ever** claim a unit arriving as
902/// `kind`, on `draft`, in this build.
903///
904/// A shaping rule keyed on something the negotiated draft does not carry
905/// arms, matches nothing, and reports success — the silent no-op this crate
906/// exists to make loud. Without this predicate the only way to learn it is
907/// to run the session and read `Impairment{ShapeRuleUnmatchable}` out of the
908/// report, which requires a run, traffic of the right shape, and a reader.
909/// The answer needs nothing but the draft and the compiled feature set, so
910/// it is answerable before the run, and [`Capabilities::admit_profile`] turns
911/// it into a refusal.
912///
913/// # Why the second argument is a [`MatchKind`] and not a [`Site`]
914///
915/// Shaping only ever sees framed objects. [`Site`] spans the control frame,
916/// the two stream decisions and the stream end, none of which a [`Matcher`]
917/// can be aimed at, and it does *not* distinguish the two things that decide
918/// this question — a subgroup stream from a fetch one. [`MatchKind`] is the
919/// axis the answer actually varies on, and it is the axis a rule is written
920/// against.
921///
922/// # The two facts, in the order they are read
923///
924/// 1. **A draft this build did not compile frames nothing at all.** The
925///    stream header decode returns `UnsupportedDraft`, the framer latches
926///    [`BypassReason::DecodeError`] and forwards the stream uninterpreted,
927///    so no [`ObjectMeta`](crate::framer::ObjectMeta) is ever built and *no*
928///    key can match — see [`draft_is_compiled`], which is reachable by
929///    default rather than only under exotic flags. This is why the predicate
930///    answers for a build and not only for a draft, exactly as
931///    [`classify`] does.
932/// 2. **A fetch stream carries no track alias, and a datagram carries no
933///    subgroup ID, on any draft.** A fetch header carries a request ID
934///    where a subgroup header carries an alias, and no datagram of any
935///    draft belongs to a subgroup. These are the two answers that vary by
936///    *kind* rather than by draft, and they are why the predicate takes
937///    the kind at all.
938///
939/// # What it deliberately does not refuse, and why
940///
941/// [`Matcher::subgroup_id`] and [`Matcher::priority`] are the two keys whose
942/// absence can be a property of one **header** rather than of the draft — a
943/// header in *subgroup ID is the first object's ID* mode (ten drafts) or
944/// drafts 16-21's reserved mode 3 carries no subgroup ID, and drafts 15-21 omit
945/// the publisher priority whenever the header sets the default-priority bit, on
946/// a subgroup header and on a datagram alike. Neither is a *draft* fact. Every
947/// one of the drafts also has header shapes that carry both — modes 0
948/// and 2 on 16-20, an explicit subgroup ID field elsewhere, and a clear
949/// default-priority bit — and every fetch object on the drafts that frame one
950/// carries both unconditionally. So a rule keyed on either can match on every
951/// draft, and this predicate answers `true`.
952///
953/// There is deliberately no fact about a stream *kind* that yields no unit
954/// at all. A fetch stream the session cannot resolve is one stream rather
955/// than a draft, and it reports itself as
956/// `Impairment { FramerBypass { FetchGroupOrderUnknown } }` while it happens
957/// — see `fetch_group_order_is_needed`.
958///
959/// The one place `subgroup_id` crosses the line is a rule aimed at
960/// [`MatchKind::Datagram`], which fact 2 above refuses: there the absence is
961/// not a header's but the carrier's, and no draft has a datagram shape that
962/// carries one. A rule that names **no** kind and keys on `subgroup_id` is
963/// still admitted, because it is a working subgroup rule that datagram
964/// traffic simply walks past.
965///
966/// Refusing them would reject rules that work, which is a worse failure than
967/// the one being fixed: a run that shapes nothing can at least be observed,
968/// while a configuration rejected at startup cannot run at all. A key the
969/// wire withheld from **one unit** stays what it already was —
970/// `Impairment{ShapeRuleUnmatchable}`, reported per class per field, once
971/// per session — because that answer depends on the traffic and nothing
972/// before the run can know it.
973#[must_use]
974pub fn supports_matcher(draft: DraftVersion, kind: MatchKind, field: MatcherKey) -> bool {
975    // Read in the same wire order `object_framing_bypass` reads them: the
976    // stream header decodes first, so an uncompiled draft fails before the
977    // fetch-object question is ever reached.
978    if !draft_is_compiled(draft) {
979        return false;
980    }
981    // A fetch header carries a request ID where a subgroup header carries a
982    // track alias, so `ObjectFramer` builds every fetch object with
983    // `track_alias: None` and an absent key never matches.
984    if kind == MatchKind::Fetch && field == MatcherKey::TrackAlias {
985        return false;
986    }
987    // A datagram carries one Object and belongs to no subgroup, on every one
988    // of the drafts. There is no header shape anywhere in the family
989    // that puts a Subgroup ID on one, which is what makes this a refusal here
990    // rather than a `MatcherField::SubgroupId` report from a run: the answer
991    // does not depend on a header the session has not seen yet.
992    !(kind == MatchKind::Datagram && field == MatcherKey::SubgroupId)
993}
994
995/// The keys a matcher names, in [`Matcher`] field order.
996///
997/// A fixed-size array of `Option` rather than a `Vec`, matching
998/// `Matcher::unmatchable_fields`: the shape reads as the struct does, so a
999/// key added to [`Matcher`] and forgotten here is visible as a missing row
1000/// rather than as a shorter list.
1001fn keys_named(matcher: &Matcher) -> [Option<MatcherKey>; 6] {
1002    [
1003        matcher.track_alias.as_ref().map(|_| MatcherKey::TrackAlias),
1004        matcher.group_id.as_ref().map(|_| MatcherKey::GroupId),
1005        matcher.subgroup_id.as_ref().map(|_| MatcherKey::SubgroupId),
1006        matcher.object_id.as_ref().map(|_| MatcherKey::ObjectId),
1007        matcher.priority.as_ref().map(|_| MatcherKey::Priority),
1008        matcher.every_nth.map(|_| MatcherKey::EveryNth),
1009    ]
1010}
1011
1012/// A class rule keyed on something no unit it could claim ever carries.
1013///
1014/// Returned by [`Capabilities::admit_class`] and
1015/// [`Capabilities::admit_profile`] **before** a session forwards anything,
1016/// so the rule never arms. The message names the draft and the key, because
1017/// either alone is unactionable: "keys on `track_alias`" does not say which
1018/// session it is dead in, and "draft-19" does not say what to change.
1019///
1020/// `#[non_exhaustive]` with public fields: nobody constructs an error, and a
1021/// later release naming a seventh key must not be a breaking change.
1022/// Reading the fields from outside the crate stays legal, which is what lets
1023/// a caller branch on the key rather than parse the message.
1024///
1025/// [`Display`](std::fmt::Display) is hand-written rather than a `thiserror`
1026/// attribute, unlike
1027/// [`ShapeError`](crate::shape::ShapeError): the message has two shapes,
1028/// because a rule that named no [`MatchKind`] was judged against both framed
1029/// kinds and naming one of them in the refusal would misreport what the rule
1030/// asked for.
1031#[derive(Debug, Clone, PartialEq, Eq)]
1032#[non_exhaustive]
1033pub struct UnsupportedMatcherKey {
1034    /// The [`ClassRule::name`] holding the dead key.
1035    pub class: String,
1036    /// The draft the session would run as.
1037    pub draft: DraftVersion,
1038    /// The stream kind the rule was aimed at, or `None` when it named none
1039    /// and the key is carried by neither framed kind on this draft.
1040    pub kind: Option<MatchKind>,
1041    /// The key that can never match.
1042    pub key: MatcherKey,
1043}
1044
1045impl std::fmt::Display for UnsupportedMatcherKey {
1046    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1047        let (class, key, draft) = (&self.class, self.key.field_name(), self.draft);
1048        match self.kind {
1049            Some(kind) => {
1050                write!(f, "class {class} keys on {key}, which no {kind:?} unit carries on {draft}")
1051            }
1052            None => {
1053                write!(f, "class {class} keys on {key}, which no framed unit carries on {draft}")
1054            }
1055        }
1056    }
1057}
1058
1059impl std::error::Error for UnsupportedMatcherKey {}
1060
1061// ── The two site-independent `NotAttemptable` families ─────────────────
1062
1063/// Whether this site's hook method returns
1064/// [`StreamAction`](crate::action::StreamAction) rather than
1065/// [`Action`](crate::action::Action).
1066const fn site_returns_stream_action(site: Site) -> bool {
1067    matches!(site, Site::StreamOpen | Site::StreamHeader)
1068}
1069
1070/// Whether this kind is one of the four
1071/// [`StreamAction`](crate::action::StreamAction) decisions.
1072///
1073/// **The compiler does not check this list.** It is a `matches!`, not an
1074/// exhaustive `match`, so a `StreamAction` variant left out of it silently
1075/// becomes `NotAttemptable { SiteReturnsStreamAction }` at
1076/// [`Site::StreamOpen`] and [`Site::StreamHeader`] — the published table
1077/// then says a site's return type rules out a variant of that very return
1078/// type. `tests/action_matrix.rs::the_published_table_and_classify_agree`
1079/// is the gate that catches it, because its hand-transcribed twin of this
1080/// list is written independently.
1081const fn is_stream_decision(kind: ActionKind) -> bool {
1082    matches!(
1083        kind,
1084        ActionKind::Open | ActionKind::Reject | ActionKind::OpenAfter | ActionKind::SerializeAfter
1085    )
1086}
1087
1088/// The two families of [`Support::NotAttemptable`], in the order
1089/// [`classify`] documents: `ReplaceObject`'s single reading, then the
1090/// return-type mismatch.
1091fn not_attemptable(site: Site, kind: ActionKind) -> Option<Support> {
1092    if kind == ActionKind::ReplaceObject && site != Site::Object {
1093        return Some(Support::NotAttemptable {
1094            why: NotAttemptable::KindNotDefinedAtThisSite,
1095            refusal: Refusal::WrongSite { site, action: kind },
1096        });
1097    }
1098
1099    let why = match (site_returns_stream_action(site), is_stream_decision(kind)) {
1100        (true, false) => NotAttemptable::SiteReturnsStreamAction,
1101        (false, true) => NotAttemptable::SiteReturnsAction,
1102        _ => return None,
1103    };
1104    Some(Support::NotAttemptable { why, refusal: Refusal::WrongSite { site, action: kind } })
1105}
1106
1107/// The answer [`not_attemptable`] already gave, restated.
1108///
1109/// Every kind that reaches a site helper's "filtered earlier" arm was
1110/// answered before dispatch. Recomputing it keeps each helper a total
1111/// function instead of a panicking one — a capability table that can panic
1112/// is worse than one that repeats itself.
1113fn filtered_earlier(site: Site, kind: ActionKind) -> Support {
1114    not_attemptable(site, kind).unwrap_or(Support::NotAttemptable {
1115        why: NotAttemptable::KindNotDefinedAtThisSite,
1116        refusal: Refusal::WrongSite { site, action: kind },
1117    })
1118}
1119
1120// ── Per-draft facts ────────────────────────────────────────────────────
1121
1122/// Why [`ObjectFramer`](crate::framer::ObjectFramer) cannot address objects
1123/// on a stream of this shape, if it cannot.
1124///
1125/// One reason survives here, and it is the one the **wire** reaches first: a
1126/// draft this build did not compile fails at the stream header and reports
1127/// [`BypassReason::DecodeError`], so nothing after it is ever asked.
1128///
1129/// Whether a fetch stream can be addressed is not a property of the draft:
1130/// the session reads the Group Order off the FETCH and the framer takes it
1131/// from there — see [`fetch_group_order_is_needed`]. That case belongs to one
1132/// stream rather than to the table, and is reported per stream.
1133///
1134/// `stream_kind: None` reads as a subgroup stream, matching
1135/// [`Capabilities::supports`]'s published column.
1136const fn object_framing_bypass(
1137    draft: DraftVersion,
1138    stream_kind: Option<DataStreamType>,
1139) -> Option<BypassReason> {
1140    let _ = stream_kind;
1141    if !draft_is_compiled(draft) {
1142        return Some(BypassReason::DecodeError);
1143    }
1144    None
1145}
1146
1147/// Whether **this build** compiled a codec for `draft`.
1148///
1149/// [`DraftVersion`] carries all variants under every feature set,
1150/// so the table is *answerable* for a draft this binary cannot speak — and
1151/// that is exactly the case worth getting right. A build that did not
1152/// compile a draft cannot frame one byte of it, so a table that answers by
1153/// draft **number** alone publishes [`Support::Yes`] for work the binary
1154/// cannot do: the documented lie this module exists to prevent.
1155///
1156/// It is reachable **by default**, not only under exotic flags:
1157/// `ProxySessionConfig::default().draft` is [`DraftVersion::Draft14`], so a
1158/// `--no-default-features --features draft07` binary — a shipped
1159/// configuration and one of CI's rows — is configured for draft 14
1160/// unless its caller says otherwise.
1161///
1162/// # This is also the predicate a session is admitted on
1163///
1164/// [`ProxySession::run`](crate::session::ProxySession::run) asks this before
1165/// it dials and refuses with
1166/// [`ProxyError::DraftNotCompiled`](crate::error::ProxyError::DraftNotCompiled)
1167/// when the answer is `false`, so the run that the paragraph below describes
1168/// no longer happens to anybody. One predicate serves both, which is what
1169/// keeps the table's verdict and the session's admission from becoming two
1170/// lists that disagree — and the paragraph below stays because it is still
1171/// the reason the verdict is [`Support::Unreachable`] rather than
1172/// [`Support::Yes`].
1173///
1174/// # The mechanism, so the verdict is not taken on trust
1175///
1176/// `moqtap-proxy`'s `draftNN` features forward to **both** `moqtap-codec` and
1177/// `moqtap-client`, so a draft that is off here is off in the codec.
1178/// `AnySubgroupHeader::decode_stream` and `AnyFetchHeader::decode_stream` then
1179/// fall through to their catch-all arm and return
1180/// `CodecError::UnsupportedDraft(*draft DraftNN not enabled via feature
1181/// flag*)`. That is not an incomplete-input error
1182/// (`parser::data::is_incomplete_error` admits only the `UnexpectedEnd`
1183/// spellings), so
1184/// [`ObjectFramer`](crate::framer::ObjectFramer)'s header poll takes its
1185/// terminal `Err` arm, latches [`BypassReason::DecodeError`] and forwards the
1186/// stream uninterpreted. No object on it ever reaches
1187/// [`ProxyHook::on_object`](crate::hook::ProxyHook::on_object), which is
1188/// precisely [`Support::Unreachable`].
1189///
1190/// # Why `cfg!` and not `#[cfg]`
1191///
1192/// A `cfg!` per arm keeps the function **total**. A `#[cfg]` per arm would
1193/// make the match non-exhaustive and force a catch-all, and the table would
1194/// stop being able to answer for the very drafts this exists to answer for.
1195///
1196/// # The control site reads it too, one decoder later
1197///
1198/// [`Site::Control`] on an uncompiled draft is never invoked either:
1199/// `ControlStreamParser::feed` steps over a frame whose
1200/// `AnyControlMessage::decode` fails, so the hook is offered nothing. That
1201/// cell published [`Support::Yes`] for a while, because the honest verdict
1202/// needs `instead` to name a report and this path emitted none. It emits
1203/// [`ImpairmentKind::ControlFrameNotDecodable`](crate::event::ImpairmentKind::ControlFrameNotDecodable)
1204/// now, so the cell is [`Support::Unreachable`] with
1205/// [`Instead::ControlFrameNotDecodable`] — see [`classify`], step 4.
1206///
1207/// # What it does *not* cover
1208///
1209/// [`Site::StreamOpen`], [`Site::StreamEnd`] and [`Site::Datagram`] need no
1210/// codec to fire and are unaffected; [`Site::StreamHeader`] fires only
1211/// behind a decoded header and shares the object site's fate.
1212#[must_use]
1213pub const fn draft_is_compiled(draft: DraftVersion) -> bool {
1214    match draft {
1215        DraftVersion::Draft07 => cfg!(feature = "draft07"),
1216        DraftVersion::Draft08 => cfg!(feature = "draft08"),
1217        DraftVersion::Draft09 => cfg!(feature = "draft09"),
1218        DraftVersion::Draft10 => cfg!(feature = "draft10"),
1219        DraftVersion::Draft11 => cfg!(feature = "draft11"),
1220        DraftVersion::Draft12 => cfg!(feature = "draft12"),
1221        DraftVersion::Draft13 => cfg!(feature = "draft13"),
1222        DraftVersion::Draft14 => cfg!(feature = "draft14"),
1223        DraftVersion::Draft15 => cfg!(feature = "draft15"),
1224        DraftVersion::Draft16 => cfg!(feature = "draft16"),
1225        DraftVersion::Draft17 => cfg!(feature = "draft17"),
1226        DraftVersion::Draft18 => cfg!(feature = "draft18"),
1227        DraftVersion::Draft19 => cfg!(feature = "draft19"),
1228        DraftVersion::Draft20 => cfg!(feature = "draft20"),
1229        DraftVersion::Draft21 => cfg!(feature = "draft21"),
1230    }
1231}
1232
1233/// Every draft this build could take as a default, in the order it would take
1234/// them.
1235///
1236/// Draft-14 first, because that is the value this had before the build's own
1237/// draft list was consulted and a full build should not change; then the newest
1238/// draft downwards, because a build that trimmed its drafts kept the ones it
1239/// means to speak and the newest of those is the likeliest thing meant by
1240/// naming none.
1241const DEFAULT_DRAFT_ORDER: [DraftVersion; DraftVersion::ALL.len()] = default_draft_order();
1242
1243/// [`DEFAULT_DRAFT_ORDER`], built from [`DraftVersion::ALL`] rather than
1244/// transcribed from it.
1245///
1246/// A `const fn` because the caller is one. Transcribing the series here meant
1247/// a draft could be left out of the default order without anything failing,
1248/// and the symptom would have been the proxy quietly defaulting to a different
1249/// draft than the one a full build means to prefer.
1250const fn default_draft_order() -> [DraftVersion; DraftVersion::ALL.len()] {
1251    // Draft-14 heads the order, then the rest newest-first. `matches!` rather
1252    // than `==` because `PartialEq` is not usable in const context.
1253    let mut out = [DraftVersion::Draft14; DraftVersion::ALL.len()];
1254    let mut filled = 1;
1255    let mut i = DraftVersion::ALL.len();
1256    while i > 0 {
1257        i -= 1;
1258        let draft = DraftVersion::ALL[i];
1259        if !matches!(draft, DraftVersion::Draft14) {
1260            out[filled] = draft;
1261            filled += 1;
1262        }
1263    }
1264    out
1265}
1266
1267/// The draft a session configuration takes when the caller names none.
1268///
1269/// Draft-14 wherever the build has it, which is every build that did not trim
1270/// its drafts, and the newest draft the build does have otherwise — so a
1271/// reduced-draft build never starts out naming a draft it cannot speak.
1272///
1273/// # Why a default cannot simply refuse
1274///
1275/// [`Default`] returns a value, so it has no way to tell a caller that the
1276/// build left out the draft it would have chosen. Keeping draft-14 regardless
1277/// does not avoid the problem, it moves it: on a build without draft-14
1278/// [`draft_is_compiled`] answers `false`, [`supports_matcher`] refuses every
1279/// key on every stream kind, and a class rule that is perfectly well formed is
1280/// reported as naming a key the draft does not carry. That is a configuration
1281/// error raised against the author of a configuration that has nothing wrong
1282/// with it.
1283///
1284/// # The check below is a compile-time one, and it has to be
1285///
1286/// A test asserting the same thing would never run. The per-draft rows build
1287/// this crate fourteen times under `--no-default-features --features draftNN`
1288/// and stop at `clippy --all-targets`, so a reduced-draft build is *compiled*
1289/// fourteen times a round and its tests are run none — and a reduced-draft
1290/// build is the only kind that can have this defect. A const assertion fails
1291/// the compile, which is the one thing those rows do look at.
1292///
1293/// # Ablated, and the numbers are the account of why this survived
1294///
1295/// Putting the old value back — draft-14 chosen without consulting the build:
1296///
1297/// ```text
1298/// error[E0080]: evaluation panicked: the default draft is one this build did not compile
1299/// error: could not compile `moqtap-proxy` (lib) due to 1 previous error
1300/// ```
1301///
1302/// **Exit 101 under `--no-default-features --features draft07`, exit 101 under
1303/// the same with draft19, and exit 0 under `--all-features`.** The build every
1304/// round runs first cannot see this defect at all, and the drafts that can
1305/// are compiled and never run.
1306pub const DEFAULT_DRAFT: DraftVersion = default_draft();
1307
1308const fn default_draft() -> DraftVersion {
1309    let mut i = 0;
1310    while i < DEFAULT_DRAFT_ORDER.len() {
1311        if draft_is_compiled(DEFAULT_DRAFT_ORDER[i]) {
1312            return DEFAULT_DRAFT_ORDER[i];
1313        }
1314        i += 1;
1315    }
1316    panic!("this build compiled no draft at all, so there is no default to take")
1317}
1318
1319const _: () = assert!(
1320    draft_is_compiled(DEFAULT_DRAFT),
1321    "the default draft is one this build did not compile"
1322);
1323
1324/// Whether a fetch stream on this draft can be read only by an endpoint that
1325/// knows the Group Order the fetch was asked for.
1326///
1327/// A statement about the draft, not about the build and not about any one
1328/// session; whether the draft was compiled at all is [`draft_is_compiled`],
1329/// asked first by [`object_framing_bypass`] because the header decode happens
1330/// first.
1331///
1332/// **False on drafts 07-17.** Drafts 07-14 write each object's identity
1333/// outright, and drafts 15, 16 and 17 let an object leave a field off and
1334/// take the object before it — draft-16 Section 10.4.4.1: "Group ID is the
1335/// prior Object's Group ID" — which the reader carries the running state
1336/// for. Either way an absolute Location comes out of the stream and nothing
1337/// else, which is all addressing an object needs.
1338///
1339/// **True on drafts 18, 19 and 20**, where the Group ID is a difference and the
1340/// fetch's Group Order decides its sign. Nothing on the data stream states
1341/// the order, and the wrong choice decodes as willingly as the right one, so
1342/// a reader has to be told — see [`BypassReason::FetchGroupOrderUnknown`],
1343/// where the consequence of the wrong answer is written out.
1344///
1345/// # Where the answer comes from
1346///
1347/// One control message settles it. Draft-19 Section 10.12.3: "The publisher
1348/// responding to a FETCH is responsible for delivering all available Objects
1349/// in the requested range in the requested order (see Section 10.2.8)", and
1350/// draft-19 Section 10.2.8 states what a FETCH that carries no GROUP_ORDER
1351/// parameter has asked for: "If omitted from FETCH, the receiver uses
1352/// Ascending (0x1)." So a session that reads the FETCH knows the order for
1353/// that Request ID,
1354/// carrying it in a
1355/// [`FetchGroupOrders`](crate::framer::FetchGroupOrders) table the framer
1356/// takes it out of when the response stream opens.
1357///
1358/// That is why this is a draft fact and the bypass is not. The bypass now
1359/// belongs to one stream: a fetch stream naming a request this session never
1360/// saw asked for, which is a publisher answering something nobody requested.
1361///
1362/// The match is exhaustive on purpose. As a `matches!` this answered `false`
1363/// for a draft nobody had listed, which is the answer that reads a fetch
1364/// stream without knowing the order — the one reading this function exists to
1365/// prevent. A fifteenth draft must fail to compile here until someone has read
1366/// its FETCH section and said which side it is on.
1367pub(crate) const fn fetch_group_order_is_needed(draft: DraftVersion) -> bool {
1368    match draft {
1369        DraftVersion::Draft07
1370        | DraftVersion::Draft08
1371        | DraftVersion::Draft09
1372        | DraftVersion::Draft10
1373        | DraftVersion::Draft11
1374        | DraftVersion::Draft12
1375        | DraftVersion::Draft13
1376        | DraftVersion::Draft14
1377        | DraftVersion::Draft15
1378        | DraftVersion::Draft16
1379        | DraftVersion::Draft17 => false,
1380        DraftVersion::Draft18
1381        | DraftVersion::Draft19
1382        | DraftVersion::Draft20
1383        | DraftVersion::Draft21 => true,
1384    }
1385}
1386
1387/// Whether this draft defines a *subgroup ID is the first object's ID* stream
1388/// type.
1389///
1390/// Ten drafts do: every one from 11 on. Drafts 07-10 always carry the
1391/// subgroup ID explicitly, so eliding index 0 there redefines nothing.
1392///
1393/// The two wordings are worth telling apart, because reading only the later
1394/// one makes the earlier drafts look as though they lack the mode. Drafts 11
1395/// through 15 state it as a property of the type value — draft-15 Section
1396/// 10.4.2: "the Subgroup ID is either 0 (for Types 0x10-11 and 0x18-19) or the
1397/// Object ID of the first object transmitted in this subgroup (for Types
1398/// 0x12-13 and 0x1A-1B)" — while drafts 16 through 20 name a SUBGROUP_ID_MODE
1399/// field and give mode 1 the sentence "The Subgroup ID field is absent and the
1400/// Subgroup ID is the Object ID of the first Object transmitted in this
1401/// Subgroup". Different prose, one stream: `0x12` on both sides of the change.
1402///
1403/// Draft-15's absence here was not a narrower guard but a silent one. Skipping
1404/// the block admitted the elide instead of refusing it, so a hook removing
1405/// index 0 of a draft-15 first-object stream handed the receiver a stream
1406/// whose subgroup ID had become the second object's. The draft list the tests
1407/// sweep carried the same omission, so no run ever asked.
1408///
1409/// **Nothing that checks this list may read it.** Two places state the same
1410/// partition independently and are what a narrowing here contradicts: the
1411/// `a_first_object_carrier_exists` in this module's tests, which names every
1412/// draft in an exhaustive match, and the copy in `tests/action_matrix.rs`,
1413/// transcribed from the drafts and driving end-to-end probes. Both cuts have
1414/// been run — dropping draft-15, and narrowing to 17-19 — and each is caught
1415/// by both. A test that took the fact from *here* instead passed under both.
1416///
1417/// The restatement discipline above and the exhaustive match below answer two
1418/// different failures and neither substitutes for the other: restatement
1419/// catches this list saying the *wrong* thing about a draft it names, and
1420/// exhaustiveness catches it saying *nothing* about a draft that has just been
1421/// added. As a `matches!` a fifteenth draft would silently take the drafts
1422/// 07-10 answer.
1423const fn has_implicit_subgroup_id_mode(draft: DraftVersion) -> bool {
1424    match draft {
1425        DraftVersion::Draft07
1426        | DraftVersion::Draft08
1427        | DraftVersion::Draft09
1428        | DraftVersion::Draft10 => false,
1429        DraftVersion::Draft11
1430        | DraftVersion::Draft12
1431        | DraftVersion::Draft13
1432        | DraftVersion::Draft14
1433        | DraftVersion::Draft15
1434        | DraftVersion::Draft16
1435        | DraftVersion::Draft17
1436        | DraftVersion::Draft18
1437        | DraftVersion::Draft19
1438        | DraftVersion::Draft20
1439        | DraftVersion::Draft21 => true,
1440    }
1441}
1442
1443/// Whether a header's reserved subgroup-ID mode has to be told apart from
1444/// mode 1 before an object behind it can be judged. Drafts 15-21.
1445///
1446/// **Not the drafts that name a SUBGROUP_ID_MODE field**, which is neither a
1447/// superset nor a subset of this. Drafts 16 through 20 name one — draft-16:
1448/// "Type values with SUBGROUP_ID_MODE set to 0b11: 0x16, 0x17, 0x1E, 0x1F,
1449/// 0x36, 0x37, 0x3E, 0x3F. This mode is reserved for future use." Draft-15
1450/// names nothing and states the same three carriers as table columns, then
1451/// leaves the fourth combination out of the table. The wording is what
1452/// differs; the two bits and their four values are not.
1453///
1454/// What decides it is where `AnySubgroupHeader::subgroup_id` answers `None`
1455/// for more than one reason. On these six it answers `None` for both mode 1
1456/// and the fourth combination, so `None` alone cannot say whether the first
1457/// object defines the subgroup or the header is one no receiver should read,
1458/// and the mode has to be consulted. Drafts 11 through 14 give each carrier a
1459/// stream type of its own and assign every type they define, so their `None`
1460/// means the first object and nothing else; drafts 07-10 always put the ID on
1461/// the wire and never answer `None` at all.
1462///
1463/// Draft-15 and draft-16 are in the set on that reading alone. Draft-15 names
1464/// no mode field and draft-16 names one, so the field is not what puts either
1465/// of them here; a `None` that could mean either reading is.
1466///
1467/// Exhaustive rather than a `matches!`, because the question this asks is not
1468/// one a new draft can be assumed out of: the sentence above is about what
1469/// `AnySubgroupHeader::subgroup_id` answers `None` for on that draft, and only
1470/// reading the draft settles it.
1471const fn subgroup_id_mode_must_be_consulted(draft: DraftVersion) -> bool {
1472    match draft {
1473        DraftVersion::Draft07
1474        | DraftVersion::Draft08
1475        | DraftVersion::Draft09
1476        | DraftVersion::Draft10
1477        | DraftVersion::Draft11
1478        | DraftVersion::Draft12
1479        | DraftVersion::Draft13
1480        | DraftVersion::Draft14 => false,
1481        DraftVersion::Draft15
1482        | DraftVersion::Draft16
1483        | DraftVersion::Draft17
1484        | DraftVersion::Draft18
1485        | DraftVersion::Draft19
1486        | DraftVersion::Draft20
1487        | DraftVersion::Draft21 => true,
1488    }
1489}
1490
1491// ── Per-site rules ─────────────────────────────────────────────────────
1492
1493/// The control site, which is honoured on every draft.
1494///
1495/// The site is shown every message the session's control plane carries,
1496/// whichever shape that plane has. On drafts 07-16 the plane is the one
1497/// client-initiated bidirectional stream. On 17-20 it is a pair of
1498/// unidirectional streams — each peer opens one and begins it with SETUP —
1499/// and bidirectional streams carry requests; `session.rs` identifies the
1500/// pair by its stream type and pipes both it and the request streams through
1501/// the control path, so SETUP reaches the hook there too.
1502///
1503/// Draft-16 is both at once and is the only draft that is: a bidirectional
1504/// control stream, and SUBSCRIBE_NAMESPACE on a bidirectional stream of its
1505/// own beside it. Its request streams take the same control path, so this
1506/// column reads the same for it as for every other draft.
1507///
1508/// `tests/control_plane_uni.rs` is the end-to-end reading behind this
1509/// column, and `tests/draft16_request_streams.rs` is the one for the draft
1510/// that needs both answers.
1511fn classify_control(kind: ActionKind) -> Support {
1512    let honoured = Support::Yes;
1513
1514    match kind {
1515        ActionKind::Pass
1516        | ActionKind::Replace
1517        | ActionKind::Delay
1518        | ActionKind::Hold
1519        | ActionKind::DropElide
1520        | ActionKind::CloseSession => honoured,
1521        // A control frame has no payload slot the proxy can locate.
1522        ActionKind::ReplacePayload => {
1523            Support::No(Refusal::WrongSite { site: Site::Control, action: kind })
1524        }
1525        // On every draft: a request stream is still a control-plane
1526        // stream, so 17-20 are refused for the same reason as 07-16.
1527        ActionKind::Truncate | ActionKind::ResetStream => {
1528            Support::No(Refusal::ControlStreamResetIllegal)
1529        }
1530        ActionKind::Open
1531        | ActionKind::Reject
1532        | ActionKind::ReplaceObject
1533        | ActionKind::OpenAfter
1534        | ActionKind::SerializeAfter => filtered_earlier(Site::Control, kind),
1535    }
1536}
1537
1538/// The object site, on a stream the framer can address: subgroup streams
1539/// on every compiled draft, and fetch streams on the drafts whose objects
1540/// this codec can read.
1541fn classify_object(kind: ActionKind, cx: &CapCtx) -> Support {
1542    match kind {
1543        ActionKind::Pass
1544        | ActionKind::Delay
1545        | ActionKind::Hold
1546        | ActionKind::Truncate
1547        | ActionKind::ResetStream
1548        | ActionKind::CloseSession => Support::Yes,
1549        // One classification for both rows: `Action::Replace(b)` is the
1550        // attempt for each, so the cells are the same value, and the
1551        // refusal names the capability being refused.
1552        ActionKind::Replace | ActionKind::ReplaceObject => Support::No(Refusal::WrongSite {
1553            site: Site::Object,
1554            action: ActionKind::ReplaceObject,
1555        }),
1556        ActionKind::ReplacePayload => object_replace_payload(cx),
1557        ActionKind::DropElide => object_drop_elide(cx),
1558        ActionKind::Open
1559        | ActionKind::Reject
1560        | ActionKind::OpenAfter
1561        | ActionKind::SerializeAfter => filtered_earlier(Site::Object, kind),
1562    }
1563}
1564
1565/// The object site's `ReplacePayload` rule: the replacement must be the
1566/// declared payload length, and the object must not carry a status.
1567///
1568/// The status guard is evaluated first: an object with a status has no
1569/// payload slot to splice into, so its length is not the interesting fact.
1570fn object_replace_payload(cx: &CapCtx) -> Support {
1571    if cx.is_status_object == Some(true) {
1572        return Support::No(Refusal::WouldDestroyStatusObject);
1573    }
1574    match (cx.payload_len, cx.replacement_len) {
1575        (Some(from), Some(to)) if from != to => Support::No(Refusal::LengthChanged { from, to }),
1576        (Some(_), Some(_)) if cx.is_status_object == Some(false) => Support::Yes,
1577        (Some(_), Some(_)) => Support::Conditional(Precondition::NotAStatusObject),
1578        _ => Support::Conditional(Precondition::ReplacementLengthEqualsPayload),
1579    }
1580}
1581
1582/// The object site's `DropElide` rule, in guard order: facts about the
1583/// stream before facts about the object.
1584///
1585/// On a subgroup stream, the header's subgroup-ID mode first (a reserved
1586/// mode says something different about the wire than a first-object mode
1587/// does), then whether eliding this object would redefine the subgroup ID.
1588/// The status guard is last and applies on every draft and every stream
1589/// kind.
1590///
1591/// **A fetch stream reaches only the status guard**, on every draft. Nothing
1592/// about a fetch object's own bytes can stop a removal: the framer pays for one
1593/// by re-encoding the survivor's framing against the frame that is now in front
1594/// of it. The subgroup guards below are skipped rather than answered, because a
1595/// fetch object states its own Subgroup ID or states that it has none, so
1596/// *eliding this would redefine the subgroup ID* is not a sentence about it.
1597fn object_drop_elide(cx: &CapCtx) -> Support {
1598    let subgroup_stream = cx.stream_kind != Some(DataStreamType::Fetch);
1599
1600    let implicit_mode = cx.draft.is_none_or(has_implicit_subgroup_id_mode);
1601
1602    if subgroup_stream && implicit_mode {
1603        match cx.index_in_stream {
1604            // Not the first object: the subgroup ID is already pinned by
1605            // an object that is still on the wire, so eliding this one
1606            // redefines nothing.
1607            Some(index) if index != 0 => {}
1608            Some(_) => {
1609                if cx.draft.is_none_or(subgroup_id_mode_must_be_consulted)
1610                    && cx.subgroup_id_mode == Some(RESERVED_SUBGROUP_ID_MODE)
1611                {
1612                    return Support::No(Refusal::ReservedHeaderMode {
1613                        mode: RESERVED_SUBGROUP_ID_MODE,
1614                    });
1615                }
1616                match cx.subgroup_id_resolved {
1617                    Some(false) => return Support::No(Refusal::WouldRedefineSubgroupId),
1618                    None => {
1619                        return Support::Conditional(Precondition::NotFirstObjectOfImplicitSubgroup)
1620                    }
1621                    Some(true) => {}
1622                }
1623            }
1624            None => {
1625                return Support::Conditional(Precondition::NotFirstObjectOfImplicitSubgroup);
1626            }
1627        }
1628    }
1629
1630    match cx.is_status_object {
1631        Some(true) => Support::No(Refusal::WouldDestroyStatusObject),
1632        Some(false) => Support::Yes,
1633        None => Support::Conditional(Precondition::NotAStatusObject),
1634    }
1635}
1636
1637/// The datagram site.
1638///
1639/// # A datagram is policed and never paced, and that is a decision
1640///
1641/// `Truncate` and `ResetStream` name a stream and a datagram has none, so
1642/// those two are a type error wearing a refusal. `Delay` and `Hold` are
1643/// refused for a different reason, and an author who reaches that refusal is
1644/// owed the reason rather than the mechanism.
1645///
1646/// **A rate aimed at datagrams drops what it cannot cover, at the instant the
1647/// datagram arrived.** `forward_datagrams` asks the class's bucket and
1648/// discards every answer but *now* — including `Later`, where an instant does
1649/// exist and the datagram could have been held until it. So a datagram-mode
1650/// track can be held to a rate; what it cannot be is smoothed.
1651///
1652/// Smoothing would need a per-connection queue, and the argument against one
1653/// is not that it is hard:
1654///
1655/// - **It would model nothing.** A bottleneck queues by link, not by track: a
1656///   router does not know which track a datagram belongs to. Class-aware
1657///   *policing* is a real box — an operator rate-limiter drops over rate —
1658///   and class-aware *smoothing* is a scheduler inside a router, which is not
1659///   a condition a player is ever placed in.
1660/// - **It would impose an order the protocol does not have.** A datagram
1661///   belongs to no stream and has no successor to renumber. A FIFO would make
1662///   this proxy the one hop on the path that never reorders, which is a less
1663///   faithful network, not a more controlled one.
1664/// - **The capability already exists one layer down.** `quinn-netem` delays,
1665///   jitters and reorders at the socket, under the whole connection — which
1666///   is exactly the scope a link-level queue has. It is not class-aware, and
1667///   that is the correct scope for it rather than a gap in it.
1668///
1669/// So the answer for *smooth this traffic* is `quinn-netem`, and the answer
1670/// for *hold this track to a rate* is a class over a bucket, which is here.
1671/// The framed sites keep `Delay` and `Hold` because a stream **has** a
1672/// delivery order: holding object N and then N+1 preserves a guarantee the
1673/// protocol makes, where holding two datagrams would manufacture one.
1674///
1675/// `tests/actions_shaping.rs` pins both halves — that a dry bucket discards,
1676/// and that a *live* rate discards too rather than deferring to the instant
1677/// it names, which is the assertion a queue would break.
1678fn classify_datagram(kind: ActionKind, cx: &CapCtx) -> Support {
1679    match kind {
1680        ActionKind::Pass | ActionKind::DropElide | ActionKind::CloseSession => Support::Yes,
1681        // Always conditional: no transport in the workspace exposes a
1682        // maximum datagram size, so the verdict comes from
1683        // `send_datagram` failing, as `ActionFailed`.
1684        ActionKind::Replace => Support::Conditional(Precondition::WithinMaxDatagramSize),
1685        ActionKind::ReplacePayload => datagram_replace_payload(cx),
1686        // Two refusals with two reasons, both above: a stream action naming a
1687        // carrier that has no stream, and a deliberate absence of pacing.
1688        ActionKind::Delay | ActionKind::Hold | ActionKind::Truncate | ActionKind::ResetStream => {
1689            Support::No(Refusal::WrongSite { site: Site::Datagram, action: kind })
1690        }
1691        ActionKind::Open
1692        | ActionKind::Reject
1693        | ActionKind::ReplaceObject
1694        | ActionKind::OpenAfter
1695        | ActionKind::SerializeAfter => filtered_earlier(Site::Datagram, kind),
1696    }
1697}
1698
1699/// The datagram site's `ReplacePayload` rule: the payload's start offset
1700/// must be derivable, or there is nowhere to splice the replacement in.
1701fn datagram_replace_payload(cx: &CapCtx) -> Support {
1702    match cx.payload_delimited {
1703        Some(true) => Support::Yes,
1704        Some(false) => {
1705            Support::No(Refusal::PayloadNotDelimited { detail: payload_not_delimited_detail(cx) })
1706        }
1707        None => Support::Conditional(Precondition::DatagramPayloadDelimited),
1708    }
1709}
1710
1711/// Which of [`Precondition::DatagramPayloadDelimited`]'s three cases failed.
1712fn payload_not_delimited_detail(cx: &CapCtx) -> &'static str {
1713    if cx.draft == Some(DraftVersion::Draft14) {
1714        "draft-14 header decode consumes the payload"
1715    } else if cx.is_status_object == Some(true) {
1716        "status datagram has no payload"
1717    } else {
1718        "datagram header did not decode"
1719    }
1720}
1721
1722/// The two stream-decision sites.
1723///
1724/// [`ActionKind::SerializeAfter`] takes exactly the verdict
1725/// [`ActionKind::Open`] takes at both sites: it defers the first *write*,
1726/// which either site can still decide. [`ActionKind::OpenAfter`] takes the
1727/// same at [`Site::StreamOpen`] and is refused at [`Site::StreamHeader`],
1728/// because the peer stream is opened before a byte of the source is read —
1729/// by the header site there is nothing left to defer, and moving `open_uni`
1730/// behind the header decision would erase the published difference between
1731/// the two reject sites.
1732///
1733/// The refusal is what keeps the header cell honest, and it is observable:
1734/// the engine reports `ProxyEvent::ActionRefused` naming
1735/// [`Refusal::WrongSite`], and the stream is forwarded unchanged. Admitting
1736/// it there instead would publish a delay nothing performs —
1737/// `tests/open_after_ordering.rs` runs exactly that mutation and records
1738/// what a caller would get.
1739fn classify_stream_decision(site: Site, kind: ActionKind) -> Support {
1740    match kind {
1741        ActionKind::Open | ActionKind::Reject | ActionKind::SerializeAfter => Support::Yes,
1742        ActionKind::OpenAfter => match site {
1743            Site::StreamOpen => Support::Yes,
1744            _ => Support::No(Refusal::WrongSite { site, action: kind }),
1745        },
1746        ActionKind::Pass
1747        | ActionKind::Replace
1748        | ActionKind::ReplacePayload
1749        | ActionKind::Delay
1750        | ActionKind::Hold
1751        | ActionKind::DropElide
1752        | ActionKind::Truncate
1753        | ActionKind::ResetStream
1754        | ActionKind::CloseSession
1755        | ActionKind::ReplaceObject => filtered_earlier(site, kind),
1756    }
1757}
1758
1759/// The stream-end site's two columns: data streams and control streams.
1760///
1761/// `CloseSession` is honoured on both: a session close is session-scoped,
1762/// so no site can be the wrong one for it. `ResetStream` turns a clean FIN
1763/// into a reset on a **data** stream and is refused on a control stream,
1764/// where it would be a session-level protocol violation — as is
1765/// `Truncate`, which is the same violation with a prefix attached.
1766fn classify_stream_end(kind: ActionKind, cx: &CapCtx) -> Support {
1767    let control = cx.is_control_stream == Some(true);
1768    match kind {
1769        ActionKind::Pass | ActionKind::CloseSession => Support::Yes,
1770        ActionKind::ResetStream => {
1771            if control {
1772                Support::No(Refusal::ControlStreamResetIllegal)
1773            } else {
1774                Support::Yes
1775            }
1776        }
1777        ActionKind::Truncate => {
1778            if control {
1779                Support::No(Refusal::ControlStreamResetIllegal)
1780            } else {
1781                Support::No(Refusal::WrongSite { site: Site::StreamEnd, action: kind })
1782            }
1783        }
1784        // Delaying a stream's end is expressed by delaying its last object;
1785        // there is no unit here to replace or drop.
1786        ActionKind::Replace
1787        | ActionKind::ReplacePayload
1788        | ActionKind::Delay
1789        | ActionKind::Hold
1790        | ActionKind::DropElide => {
1791            Support::No(Refusal::WrongSite { site: Site::StreamEnd, action: kind })
1792        }
1793        ActionKind::Open
1794        | ActionKind::Reject
1795        | ActionKind::ReplaceObject
1796        | ActionKind::OpenAfter
1797        | ActionKind::SerializeAfter => filtered_earlier(Site::StreamEnd, kind),
1798    }
1799}
1800
1801#[cfg(test)]
1802mod tests {
1803    use super::*;
1804
1805    /// Every draft, in publication order, from the codec's own list.
1806    ///
1807    /// Not feature-gated: [`DraftVersion`] carries all variants under every
1808    /// draft feature set, so the table is answerable for a draft this build
1809    /// cannot speak.
1810    const DRAFTS: [DraftVersion; DraftVersion::ALL.len()] = DraftVersion::ALL;
1811
1812    /// All fourteen kinds — the axis every table test below sweeps.
1813    const KINDS: [ActionKind; 14] = [
1814        ActionKind::Pass,
1815        ActionKind::Replace,
1816        ActionKind::ReplacePayload,
1817        ActionKind::Delay,
1818        ActionKind::Hold,
1819        ActionKind::DropElide,
1820        ActionKind::Truncate,
1821        ActionKind::ResetStream,
1822        ActionKind::CloseSession,
1823        ActionKind::Open,
1824        ActionKind::Reject,
1825        ActionKind::ReplaceObject,
1826        ActionKind::OpenAfter,
1827        ActionKind::SerializeAfter,
1828    ];
1829
1830    const SITES: [Site; 6] = [
1831        Site::Control,
1832        Site::Object,
1833        Site::Datagram,
1834        Site::StreamOpen,
1835        Site::StreamHeader,
1836        Site::StreamEnd,
1837    ];
1838
1839    fn wrong_site(site: Site, action: ActionKind) -> Support {
1840        Support::No(Refusal::WrongSite { site, action })
1841    }
1842
1843    fn returns_action(site: Site, action: ActionKind) -> Support {
1844        Support::NotAttemptable {
1845            why: NotAttemptable::SiteReturnsAction,
1846            refusal: Refusal::WrongSite { site, action },
1847        }
1848    }
1849
1850    fn returns_stream_action(site: Site, action: ActionKind) -> Support {
1851        Support::NotAttemptable {
1852            why: NotAttemptable::SiteReturnsStreamAction,
1853            refusal: Refusal::WrongSite { site, action },
1854        }
1855    }
1856
1857    fn kind_not_here(site: Site, action: ActionKind) -> Support {
1858        Support::NotAttemptable {
1859            why: NotAttemptable::KindNotDefinedAtThisSite,
1860            refusal: Refusal::WrongSite { site, action },
1861        }
1862    }
1863
1864    fn unreachable_with(reason: BypassReason) -> Support {
1865        Support::Unreachable {
1866            refusal: Refusal::StreamNotFramed { reason },
1867            instead: Instead::FramerBypass(reason),
1868        }
1869    }
1870
1871    /// The control site's verdict on a draft this build did not compile.
1872    fn unreachable_control() -> Support {
1873        Support::Unreachable {
1874            refusal: Refusal::ControlFrameNotDecodable,
1875            instead: Instead::ControlFrameNotDecodable,
1876        }
1877    }
1878
1879    /// The object-site verdict the framing facts alone dictate, or `None`
1880    /// when the framer can address the stream and the per-kind rules decide.
1881    ///
1882    /// Built from [`object_framing_bypass`], which is the function under
1883    /// test — so it is used only to *select* which expectation applies, never
1884    /// as the expectation itself. The compiled-set rows are asserted against
1885    /// the feature flags directly in
1886    /// [`the_object_site_is_unreachable_on_a_draft_this_build_did_not_compile`].
1887    fn framing_verdict(draft: DraftVersion, stream_kind: DataStreamType) -> Option<Support> {
1888        object_framing_bypass(draft, Some(stream_kind)).map(unreachable_with)
1889    }
1890
1891    /// A draft this build compiled, for the per-unit tests whose subject is
1892    /// a guard that does not depend on the draft.
1893    ///
1894    /// The `expect` is unreachable, not a skip: this helper and its two
1895    /// callers are compiled exactly when at least one draft feature is on,
1896    /// so `find` always succeeds. A `--no-default-features` build has no
1897    /// object site at all — it is not that those two facts go untested
1898    /// there, it is that there is no object site for them to be facts
1899    /// about, the same reason `exec.rs` compiles its object-site units only
1900    /// where their draft was compiled. What is *not* acceptable is the
1901    /// shape this replaced: a build that compiles clean under
1902    /// `-D warnings` and then panics at run time.
1903    #[cfg(any(
1904        feature = "draft07",
1905        feature = "draft08",
1906        feature = "draft09",
1907        feature = "draft10",
1908        feature = "draft11",
1909        feature = "draft12",
1910        feature = "draft13",
1911        feature = "draft14",
1912        feature = "draft15",
1913        feature = "draft16",
1914        feature = "draft17",
1915        feature = "draft18",
1916        feature = "draft19",
1917        feature = "draft20",
1918        feature = "draft21"
1919    ))]
1920    fn some_compiled_draft() -> DraftVersion {
1921        DRAFTS
1922            .into_iter()
1923            .find(|d| draft_is_compiled(*d))
1924            .expect("gated on `any(draft07..draft20)`, so the compiled set is non-empty")
1925    }
1926
1927    /// A configuration nobody edited can shape the traffic it will see.
1928    ///
1929    /// The consequence rather than the value. A class rule naming an ordinary
1930    /// key is put to the capability table at the draft a default configuration
1931    /// takes, and it is carried. Where the default names a draft the build did
1932    /// not compile, [`supports_matcher`] answers `false` for every key on every
1933    /// stream kind, and a rule with nothing wrong with it is refused as naming
1934    /// one the draft does not carry.
1935    ///
1936    /// **This cannot fail on a full build**, which is why the const assertion
1937    /// beside [`DEFAULT_DRAFT`] is what holds the invariant and this is the
1938    /// statement of what the invariant is for. A reduced-draft build is the
1939    /// only kind that can have the defect, and those are compiled rather than
1940    /// run.
1941    #[test]
1942    fn a_default_configuration_can_shape_the_draft_it_names() {
1943        let draft = crate::session::ProxySessionConfig::default().draft;
1944        assert!(
1945            supports_matcher(draft, MatchKind::Subgroup, MatcherKey::GroupId),
1946            "a default configuration names {draft:?}, which this build did not compile, so \
1947             every matcher key is refused on it"
1948        );
1949    }
1950
1951    /// The compiled drafts for which `pred` holds, so a sweep that needs a
1952    /// draft-shape property still runs in a reduced-draft build and is
1953    /// simply empty where no such draft was compiled.
1954    ///
1955    /// **Do not pass a predicate the sweep is checking.** Narrowing such a
1956    /// predicate narrows this loop rather than failing a row in it: the drafts
1957    /// that drop out stop being asked, every draft left passes, and the sweep
1958    /// reports green over a smaller set than it covered before. Pass
1959    /// `|_| true` and let the body name each draft's answer, or pass a fact
1960    /// the code under test does not read.
1961    fn compiled_drafts_where(pred: fn(DraftVersion) -> bool) -> Vec<DraftVersion> {
1962        DRAFTS.into_iter().filter(|d| draft_is_compiled(*d) && pred(*d)).collect()
1963    }
1964
1965    /// Whether a subgroup stream on this draft can take its Subgroup ID from
1966    /// the first object on it — stated here, per draft, and deliberately not
1967    /// read from [`has_implicit_subgroup_id_mode`].
1968    ///
1969    /// Two tests below turn on this fact and both used to take it from that
1970    /// predicate, which made each of them agree with whatever it said. Both
1971    /// narrowings were run. Removing draft-15 — the exact omission that once
1972    /// let the engine forward a stream whose Subgroup ID had silently become
1973    /// the second object's — left both passing, as did narrowing the predicate
1974    /// all the way to drafts 17-21.
1975    ///
1976    /// Eight other tests caught that second cut, so the fence was real; it was
1977    /// simply not here. `tests/action_matrix.rs` keeps its own copy of this
1978    /// fact, transcribed from the drafts rather than read off the engine, and
1979    /// the end-to-end probes it guards are what failed. This is the in-crate
1980    /// statement of the same fact, and the two are checked against each other
1981    /// by every verdict they both predict.
1982    ///
1983    /// The match is exhaustive on purpose: a fourteenth draft cannot join
1984    /// either side of the partition without an answer being written here.
1985    fn a_first_object_carrier_exists(draft: DraftVersion) -> bool {
1986        match draft {
1987            // Drafts 07-10 always put an explicit Subgroup ID in the header,
1988            // so index 0 defines nothing that outlives it.
1989            DraftVersion::Draft07
1990            | DraftVersion::Draft08
1991            | DraftVersion::Draft09
1992            | DraftVersion::Draft10 => false,
1993            DraftVersion::Draft11
1994            | DraftVersion::Draft12
1995            | DraftVersion::Draft13
1996            | DraftVersion::Draft14
1997            | DraftVersion::Draft15
1998            | DraftVersion::Draft16
1999            | DraftVersion::Draft17
2000            | DraftVersion::Draft18
2001            | DraftVersion::Draft19
2002            | DraftVersion::Draft20
2003            | DraftVersion::Draft21 => true,
2004        }
2005    }
2006
2007    /// The object site on a subgroup stream: every cell, on every draft.
2008    #[test]
2009    fn object_site_on_subgroup_streams_matches_the_published_table() {
2010        for draft in DRAFTS {
2011            let caps = Capabilities::for_draft(draft);
2012            let cell = |kind| caps.supports_on(Site::Object, kind, DataStreamType::Subgroup);
2013            // A subgroup stream is addressable on every draft this build
2014            // compiled, and on none it did not — see `draft_is_compiled`.
2015            let bypassed = framing_verdict(draft, DataStreamType::Subgroup);
2016
2017            for kind in [
2018                ActionKind::Pass,
2019                ActionKind::Delay,
2020                ActionKind::Hold,
2021                ActionKind::Truncate,
2022                ActionKind::ResetStream,
2023                ActionKind::CloseSession,
2024            ] {
2025                let want = bypassed.clone().unwrap_or(Support::Yes);
2026                assert_eq!(cell(kind), want, "{draft:?} {kind:?}");
2027            }
2028
2029            let want = bypassed
2030                .clone()
2031                .unwrap_or(Support::Conditional(Precondition::ReplacementLengthEqualsPayload));
2032            assert_eq!(cell(ActionKind::ReplacePayload), want, "{draft:?}: ReplacePayload support");
2033
2034            // Only the status guard on 07-10, the four drafts that always put
2035            // the subgroup ID on the wire and so have no first-object
2036            // carrier; the subgroup-ID guard leads on every other draft. The
2037            // fact comes from this module's tests rather than from the
2038            // predicate the table consults, so that narrowing that predicate
2039            // contradicts this row instead of moving it.
2040            let elide_headline = if a_first_object_carrier_exists(draft) {
2041                Precondition::NotFirstObjectOfImplicitSubgroup
2042            } else {
2043                Precondition::NotAStatusObject
2044            };
2045            let want = bypassed.clone().unwrap_or(Support::Conditional(elide_headline));
2046            assert_eq!(cell(ActionKind::DropElide), want, "{draft:?} elide");
2047
2048            let whole_object =
2049                bypassed.clone().unwrap_or(wrong_site(Site::Object, ActionKind::ReplaceObject));
2050            assert_eq!(cell(ActionKind::Replace), whole_object, "{draft:?}");
2051            assert_eq!(cell(ActionKind::ReplaceObject), whole_object, "{draft:?}");
2052
2053            for kind in [
2054                ActionKind::Open,
2055                ActionKind::Reject,
2056                ActionKind::OpenAfter,
2057                ActionKind::SerializeAfter,
2058            ] {
2059                assert_eq!(cell(kind), returns_action(Site::Object, kind), "{draft:?}");
2060            }
2061        }
2062    }
2063
2064    /// The object site on a fetch stream: every cell, on every draft.
2065    ///
2066    /// One column now, where there were two. A fetch stream is addressable on
2067    /// every draft this build compiled, and the drafts that need the fetch's
2068    /// Group Order to read one get it from the session rather than from the
2069    /// table — see [`fetch_group_order_is_needed`].
2070    #[test]
2071    fn object_site_on_fetch_streams_matches_the_published_table() {
2072        for draft in DRAFTS {
2073            let caps = Capabilities::for_draft(draft);
2074            let cell = |kind| caps.supports_on(Site::Object, kind, DataStreamType::Fetch);
2075            // `DecodeError` on any draft this build left out, and nothing
2076            // on any it compiled: the header decode fails first there, so a
2077            // fetch stream's own reasons are never reached.
2078            let bypassed = framing_verdict(draft, DataStreamType::Fetch);
2079            if !draft_is_compiled(draft) {
2080                assert_eq!(
2081                    bypassed.clone(),
2082                    Some(unreachable_with(BypassReason::DecodeError)),
2083                    "{draft:?} is not compiled: the header decode is what fails"
2084                );
2085            } else {
2086                assert_eq!(
2087                    bypassed.clone(),
2088                    None,
2089                    "{draft:?} is compiled, so its fetch objects are the per-kind rules' to                      decide"
2090                );
2091            }
2092
2093            for kind in [
2094                ActionKind::Pass,
2095                ActionKind::Delay,
2096                ActionKind::Hold,
2097                ActionKind::Truncate,
2098                ActionKind::ResetStream,
2099                ActionKind::CloseSession,
2100            ] {
2101                let want = bypassed.clone().unwrap_or(Support::Yes);
2102                assert_eq!(cell(kind), want, "{draft:?} {kind:?}");
2103            }
2104
2105            let want = bypassed
2106                .clone()
2107                .unwrap_or(Support::Conditional(Precondition::ReplacementLengthEqualsPayload));
2108            assert_eq!(cell(ActionKind::ReplacePayload), want, "{draft:?}");
2109
2110            // The status guard is the only one a fetch stream reaches, on
2111            // every draft: no subgroup-ID guard applies to an object that
2112            // states its own subgroup, and a removal that moves the survivors
2113            // is paid for by the framer rather than refused.
2114            let want =
2115                bypassed.clone().unwrap_or(Support::Conditional(Precondition::NotAStatusObject));
2116            assert_eq!(cell(ActionKind::DropElide), want, "{draft:?}");
2117
2118            for kind in [ActionKind::Replace, ActionKind::ReplaceObject] {
2119                let want =
2120                    bypassed.clone().unwrap_or(wrong_site(Site::Object, ActionKind::ReplaceObject));
2121                assert_eq!(cell(kind), want, "{draft:?} {kind:?}");
2122            }
2123
2124            // The four stream decisions stay `NotAttemptable` even where
2125            // the hook is never invoked — the return-type family is decided
2126            // before the framing bypass, so a cell on a draft this build
2127            // did not compile is `NotAttemptable`, not `Unreachable`.
2128            for kind in [
2129                ActionKind::Open,
2130                ActionKind::Reject,
2131                ActionKind::OpenAfter,
2132                ActionKind::SerializeAfter,
2133            ] {
2134                assert_eq!(cell(kind), returns_action(Site::Object, kind), "{draft:?}");
2135            }
2136        }
2137    }
2138
2139    /// The control site on every draft, which is one column and not two:
2140    /// the six honoured kinds are `Yes` on all fourteen this build carries.
2141    ///
2142    /// A draft it does not carry moves the **whole** classified column to
2143    /// [`Support::Unreachable`] together, refusals included. That is the
2144    /// object site's rule one decoder later and for the same reason: the
2145    /// hook is never offered a frame, so `ControlStreamResetIllegal` is a
2146    /// refusal nothing would ever be there to receive. Publishing it beside
2147    /// an unreachable `Pass` would say a reset was considered and declined
2148    /// where in fact nothing was considered at all.
2149    #[test]
2150    fn control_site_matches_the_published_table() {
2151        for draft in DRAFTS {
2152            let caps = Capabilities::for_draft(draft);
2153            let cell = |kind| caps.supports(Site::Control, kind);
2154
2155            // The two `NotAttemptable` families below are decided ahead of
2156            // reachability — `classify` steps 1 and 2 — so they keep their own
2157            // answers on every build, and this wrapper is deliberately not
2158            // applied to them.
2159            let unreachable = !draft_is_compiled(draft);
2160            let or_unreachable =
2161                |want: Support| if unreachable { unreachable_control() } else { want };
2162
2163            for kind in [
2164                ActionKind::Pass,
2165                ActionKind::Replace,
2166                ActionKind::Delay,
2167                ActionKind::Hold,
2168                ActionKind::DropElide,
2169                ActionKind::CloseSession,
2170            ] {
2171                assert_eq!(cell(kind), or_unreachable(Support::Yes), "{draft:?} {kind:?}");
2172            }
2173
2174            assert_eq!(
2175                cell(ActionKind::ReplacePayload),
2176                or_unreachable(wrong_site(Site::Control, ActionKind::ReplacePayload)),
2177                "{draft:?}"
2178            );
2179            for kind in [ActionKind::Truncate, ActionKind::ResetStream] {
2180                assert_eq!(
2181                    cell(kind),
2182                    or_unreachable(Support::No(Refusal::ControlStreamResetIllegal)),
2183                    "{draft:?} {kind:?}"
2184                );
2185            }
2186            for kind in [ActionKind::Open, ActionKind::Reject] {
2187                assert_eq!(cell(kind), returns_action(Site::Control, kind), "{draft:?}");
2188            }
2189            assert_eq!(
2190                cell(ActionKind::ReplaceObject),
2191                kind_not_here(Site::Control, ActionKind::ReplaceObject),
2192                "{draft:?}"
2193            );
2194        }
2195    }
2196
2197    /// The datagram site on every draft.
2198    #[test]
2199    fn datagram_site_matches_the_published_table() {
2200        for draft in DRAFTS {
2201            let caps = Capabilities::for_draft(draft);
2202            let cell = |kind| caps.supports(Site::Datagram, kind);
2203
2204            for kind in [ActionKind::Pass, ActionKind::DropElide, ActionKind::CloseSession] {
2205                assert_eq!(cell(kind), Support::Yes, "{draft:?} {kind:?}");
2206            }
2207            assert_eq!(
2208                cell(ActionKind::Replace),
2209                Support::Conditional(Precondition::WithinMaxDatagramSize),
2210                "{draft:?}"
2211            );
2212            assert_eq!(
2213                cell(ActionKind::ReplacePayload),
2214                Support::Conditional(Precondition::DatagramPayloadDelimited),
2215                "{draft:?}"
2216            );
2217            for kind in
2218                [ActionKind::Delay, ActionKind::Hold, ActionKind::Truncate, ActionKind::ResetStream]
2219            {
2220                assert_eq!(cell(kind), wrong_site(Site::Datagram, kind), "{draft:?} {kind:?}");
2221            }
2222        }
2223    }
2224
2225    /// The two stream-decision sites on every draft.
2226    #[test]
2227    fn stream_decision_sites_match_the_published_table() {
2228        for draft in DRAFTS {
2229            let caps = Capabilities::for_draft(draft);
2230            for site in [Site::StreamOpen, Site::StreamHeader] {
2231                assert_eq!(caps.supports(site, ActionKind::Open), Support::Yes);
2232                assert_eq!(caps.supports(site, ActionKind::Reject), Support::Yes);
2233                // `SerializeAfter` tracks `Open` at both sites;
2234                // `OpenAfter` is refused at the header site, where the peer
2235                // stream already exists.
2236                assert_eq!(
2237                    caps.supports(site, ActionKind::SerializeAfter),
2238                    Support::Yes,
2239                    "{draft:?} {site:?}"
2240                );
2241                let open_after = if site == Site::StreamOpen {
2242                    Support::Yes
2243                } else {
2244                    wrong_site(site, ActionKind::OpenAfter)
2245                };
2246                assert_eq!(
2247                    caps.supports(site, ActionKind::OpenAfter),
2248                    open_after,
2249                    "{draft:?} {site:?}"
2250                );
2251
2252                for kind in [
2253                    ActionKind::Pass,
2254                    ActionKind::Replace,
2255                    ActionKind::ReplacePayload,
2256                    ActionKind::Delay,
2257                    ActionKind::Hold,
2258                    ActionKind::DropElide,
2259                    ActionKind::Truncate,
2260                    ActionKind::ResetStream,
2261                    ActionKind::CloseSession,
2262                ] {
2263                    assert_eq!(
2264                        caps.supports(site, kind),
2265                        returns_stream_action(site, kind),
2266                        "{draft:?} {site:?} {kind:?}"
2267                    );
2268                }
2269                assert_eq!(
2270                    caps.supports(site, ActionKind::ReplaceObject),
2271                    kind_not_here(site, ActionKind::ReplaceObject)
2272                );
2273            }
2274        }
2275    }
2276
2277    /// The stream-end site's two columns — the split
2278    /// `CapCtx::is_control_stream` exists to express, swept both ways.
2279    #[test]
2280    fn stream_end_is_answered_for_both_data_and_control_streams() {
2281        for draft in DRAFTS {
2282            for is_control in [false, true] {
2283                let cx = CapCtx {
2284                    draft: Some(draft),
2285                    is_control_stream: Some(is_control),
2286                    ..CapCtx::default()
2287                };
2288                let cell = |kind| classify(Site::StreamEnd, kind, &cx);
2289
2290                // Honoured on both columns.
2291                assert_eq!(cell(ActionKind::Pass), Support::Yes, "{draft:?}");
2292                assert_eq!(cell(ActionKind::CloseSession), Support::Yes, "{draft:?}");
2293
2294                let reset = cell(ActionKind::ResetStream);
2295                if is_control {
2296                    assert_eq!(reset, Support::No(Refusal::ControlStreamResetIllegal));
2297                } else {
2298                    assert_eq!(reset, Support::Yes);
2299                }
2300
2301                let truncate = cell(ActionKind::Truncate);
2302                if is_control {
2303                    assert_eq!(truncate, Support::No(Refusal::ControlStreamResetIllegal));
2304                } else {
2305                    assert_eq!(truncate, wrong_site(Site::StreamEnd, ActionKind::Truncate));
2306                }
2307
2308                for kind in [
2309                    ActionKind::Replace,
2310                    ActionKind::ReplacePayload,
2311                    ActionKind::Delay,
2312                    ActionKind::Hold,
2313                    ActionKind::DropElide,
2314                ] {
2315                    assert_eq!(
2316                        cell(kind),
2317                        wrong_site(Site::StreamEnd, kind),
2318                        "{draft:?} control={is_control} {kind:?}"
2319                    );
2320                }
2321            }
2322        }
2323    }
2324
2325    /// The whole point of the module: one reading, not three.
2326    #[test]
2327    fn replace_object_has_exactly_one_reading() {
2328        for site in SITES {
2329            let verdict = classify(site, ActionKind::ReplaceObject, &CapCtx::default());
2330            if site == Site::Object {
2331                assert_eq!(
2332                    verdict,
2333                    wrong_site(Site::Object, ActionKind::ReplaceObject),
2334                    "the object site really is asked, and really refuses"
2335                );
2336            } else {
2337                assert_eq!(verdict, kind_not_here(site, ActionKind::ReplaceObject), "{site:?}");
2338            }
2339        }
2340    }
2341
2342    /// At the object site the two rows are one expression, so they are one
2343    /// value.
2344    #[test]
2345    fn replace_and_replace_object_agree_at_the_object_site() {
2346        for draft in DRAFTS {
2347            for stream_kind in [DataStreamType::Subgroup, DataStreamType::Fetch] {
2348                let caps = Capabilities::for_draft(draft);
2349                assert_eq!(
2350                    caps.supports_on(Site::Object, ActionKind::Replace, stream_kind),
2351                    caps.supports_on(Site::Object, ActionKind::ReplaceObject, stream_kind),
2352                    "{draft:?} {stream_kind:?}"
2353                );
2354            }
2355        }
2356    }
2357    /// The table half of
2358    /// `every_declared_refusal_is_reachable_or_declared_table_only`: the
2359    /// table-only variant appears **only** inside `NotAttemptable` /
2360    /// `Unreachable`, never as a `No(..)` the engine would have to emit.
2361    #[test]
2362    fn table_only_refusals_never_appear_as_no() {
2363        for draft in DRAFTS {
2364            let caps = Capabilities::for_draft(draft);
2365            for site in SITES {
2366                for kind in KINDS {
2367                    for verdict in [
2368                        caps.supports(site, kind),
2369                        caps.supports_on(site, kind, DataStreamType::Subgroup),
2370                        caps.supports_on(site, kind, DataStreamType::Fetch),
2371                    ] {
2372                        let Support::No(refusal) = verdict else {
2373                            continue;
2374                        };
2375                        assert!(
2376                            !matches!(
2377                                refusal,
2378                                Refusal::StreamNotFramed { .. }
2379                            ),
2380                            "{draft:?} {site:?} {kind:?} declares a table-only refusal as No({refusal:?})"
2381                        );
2382                    }
2383                }
2384            }
2385        }
2386    }
2387
2388    /// Every cell of the sweep axis has a verdict — no `(site, kind)` pair
2389    /// falls through a helper's filtered arm into a wrong answer.
2390    #[test]
2391    fn every_site_kind_pair_is_answered() {
2392        for draft in DRAFTS {
2393            let caps = Capabilities::for_draft(draft);
2394            for site in SITES {
2395                for kind in KINDS {
2396                    let verdict = caps.supports(site, kind);
2397                    // A filtered-arm leak would surface as a
2398                    // `KindNotDefinedAtThisSite` on a kind that is not
2399                    // `ReplaceObject`.
2400                    if let Support::NotAttemptable {
2401                        why: NotAttemptable::KindNotDefinedAtThisSite,
2402                        ..
2403                    } = verdict
2404                    {
2405                        assert_eq!(
2406                            kind,
2407                            ActionKind::ReplaceObject,
2408                            "{site:?} {kind:?} fell through to the filtered arm"
2409                        );
2410                    }
2411                }
2412            }
2413        }
2414    }
2415
2416    // ── The per-unit facts: `Conditional` resolving both ways ───────────
2417
2418    /// The length guard reads no draft field, so it is asserted on whatever
2419    /// draft this build compiled rather than on a hard-coded one — which is
2420    /// what keeps it running in a reduced-draft build, where a hard-coded
2421    /// draft-11 would be [`Support::Unreachable`] and measure nothing.
2422    ///
2423    /// Compiled where any draft was, because [`some_compiled_draft`] has an
2424    /// answer exactly there; a zero-draft build reaches no object site.
2425    #[cfg(any(
2426        feature = "draft07",
2427        feature = "draft08",
2428        feature = "draft09",
2429        feature = "draft10",
2430        feature = "draft11",
2431        feature = "draft12",
2432        feature = "draft13",
2433        feature = "draft14",
2434        feature = "draft15",
2435        feature = "draft16",
2436        feature = "draft17",
2437        feature = "draft18",
2438        feature = "draft19",
2439        feature = "draft20",
2440        feature = "draft21"
2441    ))]
2442    #[test]
2443    fn replace_payload_length_mismatch_is_length_changed() {
2444        let cx = CapCtx {
2445            draft: Some(some_compiled_draft()),
2446            payload_len: Some(1200),
2447            replacement_len: Some(800),
2448            is_status_object: Some(false),
2449            ..CapCtx::default()
2450        };
2451        assert_eq!(
2452            classify(Site::Object, ActionKind::ReplacePayload, &cx),
2453            Support::No(Refusal::LengthChanged { from: 1200, to: 800 })
2454        );
2455
2456        let ok = CapCtx { replacement_len: Some(1200), ..cx };
2457        assert_eq!(classify(Site::Object, ActionKind::ReplacePayload, &ok), Support::Yes);
2458    }
2459
2460    /// Gated with its neighbour, and for the same reason.
2461    #[cfg(any(
2462        feature = "draft07",
2463        feature = "draft08",
2464        feature = "draft09",
2465        feature = "draft10",
2466        feature = "draft11",
2467        feature = "draft12",
2468        feature = "draft13",
2469        feature = "draft14",
2470        feature = "draft15",
2471        feature = "draft16",
2472        feature = "draft17",
2473        feature = "draft18",
2474        feature = "draft19",
2475        feature = "draft20",
2476        feature = "draft21"
2477    ))]
2478    #[test]
2479    fn replace_payload_on_a_status_object_is_refused() {
2480        let cx = CapCtx {
2481            draft: Some(some_compiled_draft()),
2482            payload_len: Some(0),
2483            replacement_len: Some(0),
2484            is_status_object: Some(true),
2485            ..CapCtx::default()
2486        };
2487        assert_eq!(
2488            classify(Site::Object, ActionKind::ReplacePayload, &cx),
2489            Support::No(Refusal::WouldDestroyStatusObject)
2490        );
2491    }
2492
2493    /// The reserved-mode split, on every draft whose two mode bits have to be
2494    /// consulted **and** was compiled. Empty in a build that left all six
2495    /// out, which is the honest answer there: those cells are `Unreachable`.
2496    #[test]
2497    fn elide_guards_follow_the_execution_order() {
2498        for draft in compiled_drafts_where(subgroup_id_mode_must_be_consulted) {
2499            let base = CapCtx {
2500                draft: Some(draft),
2501                stream_kind: Some(DataStreamType::Subgroup),
2502                index_in_stream: Some(0),
2503                subgroup_id_resolved: Some(false),
2504                is_status_object: Some(false),
2505                ..CapCtx::default()
2506            };
2507
2508            // Mode 1: the first object defines the subgroup ID.
2509            assert_eq!(
2510                classify(Site::Object, ActionKind::DropElide, &base),
2511                Support::No(Refusal::WouldRedefineSubgroupId),
2512                "{draft:?}"
2513            );
2514
2515            // Mode 3 is reserved, and says something different about the wire.
2516            let reserved = CapCtx { subgroup_id_mode: Some(3), ..base };
2517            assert_eq!(
2518                classify(Site::Object, ActionKind::DropElide, &reserved),
2519                Support::No(Refusal::ReservedHeaderMode { mode: 3 }),
2520                "{draft:?}"
2521            );
2522
2523            // Later objects on the same stream redefine nothing.
2524            let later = CapCtx { index_in_stream: Some(1), ..base };
2525            assert_eq!(
2526                classify(Site::Object, ActionKind::DropElide, &later),
2527                Support::Yes,
2528                "{draft:?}"
2529            );
2530
2531            // A status object is a boundary marker on every draft.
2532            let status = CapCtx { is_status_object: Some(true), ..later };
2533            assert_eq!(
2534                classify(Site::Object, ActionKind::DropElide, &status),
2535                Support::No(Refusal::WouldDestroyStatusObject),
2536                "{draft:?}"
2537            );
2538        }
2539    }
2540
2541    /// What a reserved mode is answered with, on every compiled draft, from a
2542    /// list written out here rather than taken from
2543    /// [`subgroup_id_mode_must_be_consulted`].
2544    ///
2545    /// The test above sweeps that predicate, which makes it blind in one
2546    /// direction: narrowing the predicate narrows its loop, so coverage
2547    /// disappears without a failure and the drafts that dropped out are simply
2548    /// no longer asked. This match is exhaustive over [`DraftVersion`] and
2549    /// names every draft's answer, so narrowing the predicate contradicts a
2550    /// line here instead, and a fourteenth draft cannot be added without one.
2551    ///
2552    /// Three answers, and each is a different sentence about the wire:
2553    ///
2554    /// - Drafts 07-10 always put the Subgroup ID on the wire, so index 0 is
2555    ///   not special and there is nothing to refuse.
2556    /// - Drafts 11-14 name each carrier with a stream type of its own and
2557    ///   assign every type they define, so a header that determines no
2558    ///   Subgroup ID is a first-object header and nothing else — the mode
2559    ///   field is not theirs to read, and `WouldRedefineSubgroupId` is exactly
2560    ///   what is true of one.
2561    /// - Drafts 15-21 encode the carrier in two bits with a fourth
2562    ///   combination none of them assigns, so a header can determine no
2563    ///   Subgroup ID for either reason and the mode is what separates them.
2564    ///
2565    /// *Ablation (measured):* return to naming only drafts 17, 18 and 19 in
2566    /// `subgroup_id_mode_must_be_consulted`, which is the set it held while the
2567    /// codec still resolved the fourth combination on 15 and 16:
2568    ///
2569    /// ```text
2570    /// assertion `left == right` failed: Draft15
2571    ///   left: No(WouldRedefineSubgroupId)
2572    ///  right: No(ReservedHeaderMode { mode: 3 })
2573    /// ```
2574    ///
2575    /// The test above passes under that same ablation, which is why this one
2576    /// is here.
2577    #[test]
2578    fn a_reserved_mode_is_answered_as_itself_wherever_a_header_can_carry_one() {
2579        for draft in compiled_drafts_where(|_| true) {
2580            let cx = CapCtx {
2581                draft: Some(draft),
2582                stream_kind: Some(DataStreamType::Subgroup),
2583                index_in_stream: Some(0),
2584                subgroup_id_resolved: Some(false),
2585                is_status_object: Some(false),
2586                subgroup_id_mode: Some(RESERVED_SUBGROUP_ID_MODE),
2587                ..CapCtx::default()
2588            };
2589            let want = match draft {
2590                DraftVersion::Draft07
2591                | DraftVersion::Draft08
2592                | DraftVersion::Draft09
2593                | DraftVersion::Draft10 => Support::Yes,
2594                DraftVersion::Draft11
2595                | DraftVersion::Draft12
2596                | DraftVersion::Draft13
2597                | DraftVersion::Draft14 => Support::No(Refusal::WouldRedefineSubgroupId),
2598                DraftVersion::Draft15
2599                | DraftVersion::Draft16
2600                | DraftVersion::Draft17
2601                | DraftVersion::Draft18
2602                | DraftVersion::Draft19
2603                | DraftVersion::Draft20
2604                | DraftVersion::Draft21 => {
2605                    Support::No(Refusal::ReservedHeaderMode { mode: RESERVED_SUBGROUP_ID_MODE })
2606                }
2607            };
2608            assert_eq!(classify(Site::Object, ActionKind::DropElide, &cx), want, "{draft:?}");
2609        }
2610    }
2611
2612    /// A header that determines its own Subgroup ID frees index 0 on **every**
2613    /// draft, first-object carrier or not.
2614    ///
2615    /// This swept only the drafts with no such carrier, taken from the
2616    /// predicate under test, and asserted the one thing that is true of them —
2617    /// which made it two tests' worth of blind spot for one test's worth of
2618    /// claim. Narrowing the predicate narrowed the loop rather than failing a
2619    /// row, and the drafts it added to the loop answered `Yes` anyway, because
2620    /// a resolved Subgroup ID satisfies the guard on every draft that has one.
2621    ///
2622    /// That last sentence is the claim worth making, so the sweep is every
2623    /// draft and the expected answer is one value. The contrast — refused
2624    /// where the carrier exists, allowed where it does not — is
2625    /// [`the_first_object_subgroup_guard_turns_on_the_stream_kind`], which
2626    /// states it per draft.
2627    #[test]
2628    fn a_resolved_subgroup_id_frees_the_first_object_on_every_draft() {
2629        for draft in compiled_drafts_where(|_| true) {
2630            let cx = CapCtx {
2631                draft: Some(draft),
2632                stream_kind: Some(DataStreamType::Subgroup),
2633                index_in_stream: Some(0),
2634                subgroup_id_resolved: Some(true),
2635                is_status_object: Some(false),
2636                ..CapCtx::default()
2637            };
2638            assert_eq!(
2639                classify(Site::Object, ActionKind::DropElide, &cx),
2640                Support::Yes,
2641                "{draft:?}"
2642            );
2643        }
2644    }
2645
2646    /// Index 0 with an unresolved subgroup ID — the exact shape the subgroup
2647    /// guard refuses — is refused on a subgroup stream and allowed on a fetch
2648    /// one, on every draft that addresses both.
2649    ///
2650    /// Both halves in one test because the claim is a contrast rather than
2651    /// two facts: the guard turns on the stream kind, and a fetch cell that
2652    /// happened to answer `Yes` for some other reason would be
2653    /// indistinguishable from one the guard never reached. A fetch object
2654    /// states its own Subgroup ID or states that it has none, so
2655    /// `WouldRedefineSubgroupId` is a sentence that is not true about it.
2656    ///
2657    /// The subgroup half takes which drafts have a first-object carrier from
2658    /// [`a_first_object_carrier_exists`] rather than from the predicate
2659    /// `classify` consults. Reading it from that predicate made this test
2660    /// agree with it whatever it said.
2661    ///
2662    /// *Ablation (measured):* narrow `has_implicit_subgroup_id_mode` to drafts
2663    /// 17, 18 and 19. With the fact taken independently, this now fails on the
2664    /// first draft that lost the guard:
2665    ///
2666    /// ```text
2667    /// assertion `left == right` failed: Draft11 subgroup
2668    ///   left: Yes
2669    ///  right: No(WouldRedefineSubgroupId)
2670    /// ```
2671    #[test]
2672    fn the_first_object_subgroup_guard_turns_on_the_stream_kind() {
2673        for draft in compiled_drafts_where(|_| true) {
2674            let cx = |stream_kind| CapCtx {
2675                draft: Some(draft),
2676                stream_kind: Some(stream_kind),
2677                index_in_stream: Some(0),
2678                subgroup_id_resolved: Some(false),
2679                is_status_object: Some(false),
2680                ..CapCtx::default()
2681            };
2682            assert_eq!(
2683                classify(Site::Object, ActionKind::DropElide, &cx(DataStreamType::Fetch)),
2684                Support::Yes,
2685                "{draft:?} fetch"
2686            );
2687            let subgroup =
2688                classify(Site::Object, ActionKind::DropElide, &cx(DataStreamType::Subgroup));
2689            if a_first_object_carrier_exists(draft) {
2690                assert_eq!(
2691                    subgroup,
2692                    Support::No(Refusal::WouldRedefineSubgroupId),
2693                    "{draft:?} subgroup"
2694                );
2695            } else {
2696                assert_eq!(subgroup, Support::Yes, "{draft:?} subgroup");
2697            }
2698        }
2699    }
2700
2701    /// Eliding a fetch object turns on the object's status and on nothing
2702    /// else — not on its index, and not on the draft.
2703    /// The index half is the one worth stating: a fetch stream is the case
2704    /// where *the first object of the stream* carries no special meaning,
2705    /// because a fetch object's Subgroup ID is its own rather than the
2706    /// header's.
2707    ///
2708    /// Draft-15 is the only draft where the status half can be shown at all
2709    /// — 16 through 20 removed the Object Status field from fetch objects,
2710    /// so nothing there is ever `is_status_object: Some(true)` off the wire.
2711    /// It is swept on every addressable draft anyway, because the guard is
2712    /// draft-neutral and a context this crate cannot produce is still a
2713    /// context the published table answers.
2714    #[test]
2715    fn eliding_a_fetch_object_turns_only_on_its_status() {
2716        for draft in compiled_drafts_where(|_| true) {
2717            for index in [0u64, 1, 7] {
2718                for status in [Some(false), Some(true), None] {
2719                    let cx = CapCtx {
2720                        draft: Some(draft),
2721                        stream_kind: Some(DataStreamType::Fetch),
2722                        index_in_stream: Some(index),
2723                        is_status_object: status,
2724                        ..CapCtx::default()
2725                    };
2726                    let want = match status {
2727                        Some(true) => Support::No(Refusal::WouldDestroyStatusObject),
2728                        Some(false) => Support::Yes,
2729                        None => Support::Conditional(Precondition::NotAStatusObject),
2730                    };
2731                    assert_eq!(
2732                        classify(Site::Object, ActionKind::DropElide, &cx),
2733                        want,
2734                        "{draft:?} index {index} status {status:?}"
2735                    );
2736                }
2737            }
2738        }
2739    }
2740
2741    #[test]
2742    fn datagram_payload_not_delimited_names_its_case() {
2743        let undelimited = |draft, status| CapCtx {
2744            draft: Some(draft),
2745            payload_delimited: Some(false),
2746            is_status_object: status,
2747            ..CapCtx::default()
2748        };
2749        let detail = |cx: CapCtx| match classify(Site::Datagram, ActionKind::ReplacePayload, &cx) {
2750            Support::No(Refusal::PayloadNotDelimited { detail }) => detail,
2751            other => panic!("expected PayloadNotDelimited, got {other:?}"),
2752        };
2753
2754        assert_eq!(
2755            detail(undelimited(DraftVersion::Draft14, Some(false))),
2756            "draft-14 header decode consumes the payload"
2757        );
2758        assert_eq!(
2759            detail(undelimited(DraftVersion::Draft19, Some(true))),
2760            "status datagram has no payload"
2761        );
2762        assert_eq!(
2763            detail(undelimited(DraftVersion::Draft19, None)),
2764            "datagram header did not decode"
2765        );
2766
2767        let delimited = CapCtx {
2768            draft: Some(DraftVersion::Draft19),
2769            payload_delimited: Some(true),
2770            ..CapCtx::default()
2771        };
2772        assert_eq!(classify(Site::Datagram, ActionKind::ReplacePayload, &delimited), Support::Yes);
2773    }
2774
2775    // ── The control column does not split on the draft ──────────────────
2776
2777    /// The control site is honoured on all the drafts, 17-21 included.
2778    ///
2779    /// Those three moved the control plane onto a pair of unidirectional
2780    /// streams, and while the engine still took the first bidirectional
2781    /// stream to be the control stream this column published
2782    /// `Conditional(SiteSeesTheControlStream)` there — the site was shown a
2783    /// request stream and SETUP never reached the hook. `session.rs` now
2784    /// identifies the pair by its stream type, so the whole control plane
2785    /// reaches the site and the split is gone.
2786    ///
2787    /// The draft rows are written out rather than derived, so the claim is
2788    /// made against the draft numbers and not against the function under
2789    /// test. *Ablation:* return anything but `Support::Yes` from
2790    /// `classify_control` for the three drafts named below and every one of
2791    /// their rows goes red.
2792    #[test]
2793    fn the_control_site_is_honoured_on_every_draft() {
2794        const UNI_CONTROL_PLANE: [DraftVersion; 5] = [
2795            DraftVersion::Draft17,
2796            DraftVersion::Draft18,
2797            DraftVersion::Draft19,
2798            DraftVersion::Draft20,
2799            DraftVersion::Draft21,
2800        ];
2801
2802        // The kinds the control site honours — including the two whose
2803        // misreading is expensive.
2804        const HONOURED: [ActionKind; 6] = [
2805            ActionKind::Pass,
2806            ActionKind::Replace,
2807            ActionKind::Delay,
2808            ActionKind::Hold,
2809            ActionKind::DropElide,
2810            ActionKind::CloseSession,
2811        ];
2812
2813        for draft in DRAFTS {
2814            let caps = Capabilities::for_draft(draft);
2815            let uni_control_plane = UNI_CONTROL_PLANE.contains(&draft);
2816
2817            // The one split this column does take is not a draft split at
2818            // all, and it is asserted next door rather than here: see
2819            // [`the_control_site_is_unreachable_on_a_draft_this_build_did_not_compile`].
2820            // Skipped rather than folded in, so this test stays a claim
2821            // about draft numbers and that one stays a claim about the
2822            // build.
2823            if !draft_is_compiled(draft) {
2824                continue;
2825            }
2826
2827            for kind in HONOURED {
2828                let verdict = caps.supports(Site::Control, kind);
2829                assert_eq!(
2830                    verdict,
2831                    Support::Yes,
2832                    "{draft:?} {kind:?} (pair-of-unidirectional control plane: \
2833                     {uni_control_plane})"
2834                );
2835
2836                // Restated structurally: `Unreachable` and `NotAttemptable` are
2837                // the module's two verdicts for *nothing is ever attempted
2838                // here*, and neither is what this site publishes.
2839                assert!(
2840                    !matches!(
2841                        verdict,
2842                        Support::Unreachable { .. } | Support::NotAttemptable { .. }
2843                    ),
2844                    "{draft:?} {kind:?}: the control site is attemptable on every draft"
2845                );
2846            }
2847
2848            // The contrast, on the same draft, so "attemptable" is measured
2849            // against a cell that really is inert rather than asserted in
2850            // isolation: the object site is `Unreachable` on any draft this
2851            // build did not compile.
2852            if !draft_is_compiled(draft) {
2853                assert!(
2854                    matches!(
2855                        caps.supports_on(Site::Object, ActionKind::Pass, DataStreamType::Fetch),
2856                        Support::Unreachable { .. }
2857                    ),
2858                    "{draft:?}: the module does have a verdict for 'never invoked'"
2859                );
2860            }
2861
2862            // The reset-and-truncate refusal is untouched by any of the
2863            // above, on every draft: a request stream is a control-plane
2864            // stream too, so resetting one is still refused.
2865            for kind in [ActionKind::Truncate, ActionKind::ResetStream] {
2866                assert_eq!(
2867                    caps.supports(Site::Control, kind),
2868                    Support::No(Refusal::ControlStreamResetIllegal),
2869                    "{draft:?} {kind:?}"
2870                );
2871            }
2872        }
2873    }
2874
2875    // ── The compiled draft set is a fact the table reads ────────────────
2876
2877    /// The **control** site is unreachable there too, one decoder later.
2878    ///
2879    /// Sibling of
2880    /// [`the_object_site_is_unreachable_on_a_draft_this_build_did_not_compile`]
2881    /// and separate from it on purpose: the two fail in different decoders
2882    /// and a run reports them with different events, so a single verdict
2883    /// covering both would send a reader looking for a `FramerBypass` that
2884    /// no control stream emits. `AnyControlMessage::decode` has no arm for
2885    /// an uncompiled draft, so `ControlStreamParser::feed` refuses every
2886    /// frame on the stream and `ProxyHook::on_control_message` is never
2887    /// offered one.
2888    ///
2889    /// This cell published [`Support::Yes`] until the run had something
2890    /// truthful to point at. It is the shape of documented lie this module
2891    /// exists to prevent, and the reason it survived is worth keeping: the
2892    /// honest verdict needs [`Support::Unreachable`]'s `instead`, and until
2893    /// [`ImpairmentKind::ControlFrameNotDecodable`](crate::event::ImpairmentKind::ControlFrameNotDecodable)
2894    /// existed there was nothing to put there.
2895    ///
2896    /// Vacuous under `--all-features` and load-bearing under a reduced
2897    /// build, exactly like its sibling: `cargo test -p moqtap-proxy
2898    /// --no-default-features --features draft07 --lib capability::` is
2899    /// where thirteen of the rows take the assertion.
2900    ///
2901    /// *Ablation (measured):* delete the `Site::Control` guard from
2902    /// [`classify`]. Green under `--all-features`, and under
2903    /// `--features draft07`:
2904    ///
2905    /// ```text
2906    /// ---- capability::tests::the_control_site_is_unreachable_on_a_draft_this_build_did_not_compile stdout ----
2907    /// assertion `left == right` failed: Draft08
2908    ///   left: Yes
2909    ///  right: Unreachable { refusal: ControlFrameNotDecodable, instead: ControlFrameNotDecodable }
2910    /// ```
2911    ///
2912    /// `Yes` for a site this build cannot reach, on the first of the twelve
2913    /// drafts it left out.
2914    #[test]
2915    fn the_control_site_is_unreachable_on_a_draft_this_build_did_not_compile() {
2916        for draft in DRAFTS {
2917            let caps = Capabilities::for_draft(draft);
2918            let want = if draft_is_compiled(draft) { Support::Yes } else { unreachable_control() };
2919            assert_eq!(caps.supports(Site::Control, ActionKind::Pass), want, "{draft:?}");
2920
2921            // The reset kinds move with the column rather than keeping
2922            // their refusal. A refusal is what the engine would hand a
2923            // hook, and on this draft no hook is ever reached, so
2924            // publishing `ControlStreamResetIllegal` here would describe a
2925            // decision nothing takes. The object site answers the same way
2926            // for the same reason.
2927            assert_eq!(
2928                caps.supports(Site::Control, ActionKind::ResetStream),
2929                if draft_is_compiled(draft) {
2930                    Support::No(Refusal::ControlStreamResetIllegal)
2931                } else {
2932                    unreachable_control()
2933                },
2934                "{draft:?}: the reset refusal is published only where a hook could receive it"
2935            );
2936
2937            // What does *not* move: the kinds no value can carry to this
2938            // site. They are decided before reachability is consulted, so
2939            // a reduced build must not turn them into `Unreachable` as
2940            // collateral.
2941            assert_eq!(
2942                caps.supports(Site::Control, ActionKind::ReplaceObject),
2943                kind_not_here(Site::Control, ActionKind::ReplaceObject),
2944                "{draft:?}: a control frame is not an object on any build"
2945            );
2946        }
2947    }
2948
2949    /// The table must not publish [`Support::Yes`] for a draft this binary
2950    /// cannot frame.
2951    ///
2952    /// Runs in every feature configuration and has teeth in the reduced
2953    /// ones — `cargo test -p moqtap-proxy --no-default-features --features
2954    /// draft07 --lib capability::` is where thirteen of the rows take
2955    /// the `else` branch. It is deliberately not vacuous in the all-drafts
2956    /// build either: there it asserts that every row stayed `Yes`, which is
2957    /// the claim that this fix changed nothing in the shipped default.
2958    ///
2959    /// *Ablation:* drop the `draft_is_compiled` guard from
2960    /// [`object_framing_bypass`]. Green under `--all-features`, red under
2961    /// `--features draft07` on all twelve uncompiled drafts.
2962    ///
2963    /// `ProxySessionConfig::default().draft` is `Draft14` and nothing
2964    /// validates it against the compiled set, so the draft-14 row is the
2965    /// default configuration of a draft07-only binary, not a corner case.
2966    #[test]
2967    fn the_object_site_is_unreachable_on_a_draft_this_build_did_not_compile() {
2968        for draft in DRAFTS {
2969            let caps = Capabilities::for_draft(draft);
2970            for stream_kind in [DataStreamType::Subgroup, DataStreamType::Fetch] {
2971                let verdict = caps.supports_on(Site::Object, ActionKind::Pass, stream_kind);
2972
2973                if draft_is_compiled(draft) {
2974                    assert_eq!(verdict, Support::Yes, "{draft:?} {stream_kind:?}");
2975                } else {
2976                    assert_eq!(
2977                        verdict,
2978                        unreachable_with(BypassReason::DecodeError),
2979                        "{draft:?} {stream_kind:?} is not compiled: the stream header decode \
2980                         returns UnsupportedDraft, the framer latches DecodeError, and no object \
2981                         reaches the hook"
2982                    );
2983                }
2984            }
2985        }
2986    }
2987
2988    /// The sweep above chooses its own expectation with `draft_is_compiled`,
2989    /// so it is only meaningful if that function reports the build rather
2990    /// than a constant: answering `false` everywhere would make the whole
2991    /// object column `Unreachable` and pass, and answering `true` everywhere
2992    /// would make it all `Yes` and pass just as quietly.
2993    ///
2994    /// The invariant is therefore *agreement with the build*, not a
2995    /// non-empty set. "Non-empty" is simply false under
2996    /// `--no-default-features`, which is a supported configuration — the
2997    /// codec compiles with no draft, CI has a row for it, and a consumer
2998    /// vendoring one draft depends on that machinery — so a test asserting
2999    /// it was asserting a defect into a row that has none. Stated as
3000    /// agreement it runs, and bites, in all rows.
3001    ///
3002    /// *Ablation:* replace `draft_is_compiled`'s body with `false` — red in
3003    /// the rows that compile a draft. With `true` — red in the
3004    /// zero-draft row, which the previous wording could not reach at all.
3005    #[test]
3006    fn the_compiled_draft_set_agrees_with_the_enabled_features() {
3007        let compiled: Vec<DraftVersion> =
3008            DRAFTS.into_iter().filter(|d| draft_is_compiled(*d)).collect();
3009        let build_has_a_draft = cfg!(any(
3010            feature = "draft07",
3011            feature = "draft08",
3012            feature = "draft09",
3013            feature = "draft10",
3014            feature = "draft11",
3015            feature = "draft12",
3016            feature = "draft13",
3017            feature = "draft14",
3018            feature = "draft15",
3019            feature = "draft16",
3020            feature = "draft17",
3021            feature = "draft18",
3022            feature = "draft19",
3023            feature = "draft20",
3024            feature = "draft21"
3025        ));
3026        assert_eq!(
3027            !compiled.is_empty(),
3028            build_has_a_draft,
3029            "`draft_is_compiled` reports {compiled:?}, but this build has {} draft feature \
3030             enabled",
3031            if build_has_a_draft { "at least one" } else { "no" }
3032        );
3033    }
3034
3035    /// And the shipped default really is all of them, so the fix above is
3036    /// inert in the configuration the acceptance suite runs under.
3037    #[cfg(feature = "all-drafts")]
3038    #[test]
3039    fn the_default_build_compiles_every_draft() {
3040        for draft in DRAFTS {
3041            assert!(draft_is_compiled(draft), "{draft:?} is missing from `all-drafts`");
3042        }
3043    }
3044
3045    /// A default [`CapCtx`] answers the whole table without panicking, and
3046    /// without ever claiming `Yes` on a fact it was not given.
3047    #[test]
3048    fn a_default_context_is_answerable_at_every_cell() {
3049        for site in SITES {
3050            for kind in KINDS {
3051                let _ = classify(site, kind, &CapCtx::default());
3052            }
3053        }
3054    }
3055}