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