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