Skip to content

test(subscriptions): gate the three TickDecoder::RESPONSE_MESSAGE_IDS consts - #739

Merged
wboayue merged 2 commits into
mainfrom
gate-tick-decoder-response-message-ids
Aug 8, 2026
Merged

test(subscriptions): gate the three TickDecoder::RESPONSE_MESSAGE_IDS consts#739
wboayue merged 2 commits into
mainfrom
gate-tick-decoder-response-message-ids

Conversation

@wboayue

@wboayue wboayue commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Closes the TickDecoder gap #738 left open, tracked as a follow-up in plans/claude-md-knowledge-graph.md.

The gap

#738 extended RESPONSE_MESSAGE_IDS to TickDecoder so all three drivers share one skip filter, but the gate did not follow: collect_stream_decoder_impls matched impl StreamDecoder< only, leaving the three tick consts unchecked in both directions. Over-declaring one routes a foreign frame's bytes into proto::HistoricalTicks* with no test failing.

Reproduced before fixing — TickDecoder<TickBidAsk> declaring [HistoricalTickBidAsk, UserInfo] passed all 1364 sync tests. It now fails by name, as does the opposite direction (declaring &[] while decode still handles the type).

The precondition was the job

The gate could not simply be pointed at TickDecoder. With no backstop, all three decode impls dive straight into require_proto() and answer UnexpectedWireFormat to every probe — so the check would have read them as claiming an arm for all 88 scanned discriminants and asserted nothing.

Each impl now narrows with message.expect_type(..)? first, the single-arm backstop form the scanner and both wsh impls already use. #738 learned the same fact from the opposite end: it deleted two of those narrows as "a third copy of the same fact" and the gate went red within the hour. A backstop is not defensive coding — it is what makes a decoder's arm set observable from outside.

Shape

  • check_decoder is shared by both traits, with the differing decode signatures (StreamDecoder takes a context and &mut; TickDecoder takes & and returns a batch) erased by a closure at the two call sites. The failure taxonomy stays in one copy rather than growing a second that drifts.
  • test_decoder_roster_is_complete counts per trait, so adding a TickDecoder while deleting a StreamDecoder cannot net out to a passing total.

No user-visible change, so no CHANGELOG entry: the driver filters on the const before calling decode, making the new narrow unreachable in production.

Rule graph

docs/rules/wire/proto-only-decoding.md said out loud that the consts were ungated. Per the maintenance protocol that is a missing gate rather than a documentation problem — the claim is rewritten rather than appended to, and #739 is recorded as a precedent alongside #738's counter-example.

Verification

cargo fmt; clippy ×3 configs; rustdoc trio; just test (all legs); cargo build --examples ×2; both integration crates; just rules-check.

wboayue added 2 commits August 8, 2026 10:47
… consts

#738 gave the tick driver the const and the shared `is_undeclared` filter, but
`collect_stream_decoder_impls` matched `impl StreamDecoder<` only, so the three
`TickDecoder` consts were unchecked in both directions. Reproduced first:
`TickDecoder<TickBidAsk>` declaring `[HistoricalTickBidAsk, UserInfo]` passed all
1364 sync tests.

The precondition was the job. With no backstop, all three `decode` impls dive
straight into `require_proto()` and answer `UnexpectedWireFormat` to every probe,
so the check would have read them as handling all 88 scanned discriminants and
asserted nothing. Each now narrows with `expect_type` first — the single-arm form
the scanner and wsh impls already use.

The two traits share `check_decoder`, with the differing `decode` signatures
erased by a closure at the two call sites, so the failure taxonomy stays in one
copy. `test_decoder_roster_is_complete` counts per trait, so adding a
`TickDecoder` while deleting a `StreamDecoder` cannot net out to a passing total.

No user-visible change: the driver filters on the const before calling `decode`,
so the new narrow is unreachable in production.
/simplify on #739. Four findings applied.

The roster length was hand-declared and compared only against the tree, so it
never inspected `check_all`: add a decoder, bump the constant, forget the
`check_stream::<Foo>` line, and the counts agreed while `Foo` was never probed —
exactly the rot the test claims to prevent. The `check_*` calls now tally
themselves, so the roster is the thing under test and the two constants are gone.
Verified: dropping one `check_all` line now fails, and used to pass.

`collect_impls` walked all of src/ once per trait, reading 1.8 MB to look for a
different 19-byte prefix each time. It takes every header at once now.

The walk itself was a near-identical copy of `collect_sites` in
one_shot_pairing_tests.rs — 11 of 18 lines, including the load-bearing
`tests.rs` / `*_tests.rs` skip filter, stated twice with no link between them.
Both now call `test_utils::source_scan::visit_production_sources`, which is where
"production source" is defined once. Also switches `is_dir()` to `file_type()` to
drop a stat per entry.

`check_decoder` was at four parameters, 130 lines above a comment explaining that
a different function stays at three to respect the budget; it returns its
failures now instead of taking an out-param.

Trims the backstop rationale, which had reached six statements across three
files, down to the rule node plus one-line pointers. The node gains the argument
against the two designs a reviewer would otherwise re-derive: why the const stays
a slice, and why the narrow stays a hardcoded literal rather than reading
`Self::RESPONSE_MESSAGE_IDS` (that would make the cross-check circular).
@wboayue
wboayue merged commit 7592879 into main Aug 8, 2026
4 checks passed
@wboayue
wboayue deleted the gate-tick-decoder-response-message-ids branch August 8, 2026 18:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant