Skip to content

feat(contract): R10 gates the cap vocabulary — a max count cap (Refs #565) - #673

Merged
rejifald merged 1 commit into
mainfrom
claude/contract-r10-max-count-cap
Aug 6, 2026
Merged

feat(contract): R10 gates the cap vocabulary — a max count cap (Refs #565)#673
rejifald merged 1 commit into
mainfrom
claude/contract-r10-max-count-cap

Conversation

@rejifald

@rejifald rejifald commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Implements #565: the ratchet gets a rule for P4/D2, the one cap vocabulary. A count upper bound is a bare plural noun (attempts, entries, pages, failures, tokens); max is retained only for a continuous magnitude ceiling.

Named R10, not R9

The issue title proposes "R9". R9 already exists — it is the authored half of duration/size conformance (P17/P25), added 2026-08-04. The rules in check-contract.mjs today are R1–R9, so this is R10. Nothing is renumbered.

What the rule does

Flags a max cap on a consumer-input envelope unless the field is on MAX_CAP_ALLOW, plus any *Threshold, which P4 grants no carve-out and which therefore needs no allow-list at all.

It reads a bare max as well as a max-prefixed one, which is a deliberate departure from the issue's /\bmax[A-Z]/ sketch. Two reasons:

  1. P4's own text bans both — "never max-prefixed, … never a bare max" — and its resolved list fixed both kinds: maxAttempts/maxEntries/maxTokens and paginate.maxpages.
  2. /\bmax[A-Z]/ does not match BackoffOptions.max, which the issue itself lists as a field the rule must not flag. More importantly, on today's surface every legitimate magnitude ceiling is a bare max — so a prefix-only rule would leave the allow-list with nothing to verify, and the allow-list is the part the issue says is the point.

The container filter is R6's, extracted to isConsumerEnvelope() so the two cannot drift — the same move #668 made with unionArms(). That reuse is the precision guarantee, not a convenience: P4 blesses max* on resolved internals (engine.ts's reconnect policy, ShellDefaults.maxBuffer, the maxBufferChars parameters), and those sit outside the *Options family by name rather than on a hand-kept skip list. Inherited members are scanned like R6's and reported once at the declaration site.

The allow-list, and a correction to the issue

The issue names four fields a naive rule would wrongly flag. Three of the four no longer exist under those names — the P21/P24 envelope folds landed in the meantime:

issue names today in the allow-list?
BackoffOptions.max unchanged yes — a delay ceiling
ServeOptions.maxBodyBytes ServeBodyOptions.max yes — a byte cap
TraceOptions.maxBodyChars TraceBodyOptions.chars no — not a max any more
StreamOptions.maxBufferChars StreamBufferOptions.chars no — not a max any more
ShellBufferOptions.max yes — a byte cap (the issue missed this one)

The two chars fields are worth dwelling on, because the issue classes them as legitimate magnitude ceilings and by P4's own logic they were not. A count of UTF-16 code units is a count — R9's COUNT_MEMBER set literally contains chars, and P25 makes them the marked, string-free side of the size rule. Both have since been renamed to a bare noun, which is the direction R10 would have pushed. So the allow-list is three entries, all byte-or-duration magnitudes, each carrying its written reason.

Each entry is load-bearing, not decorative — verified by deleting each one and watching the rule fire (fixtures J, L1, L2 below).

Findings diff: what this newly catches

Nothing, today.

findings
--list before 0
--list after 0

That is expected rather than disappointing: the P4 sweep this gates was already done by hand in July, so R10 is a regression guard, not a fix. Said plainly because #668's precedent is to say it.

It is not merely latent, though — history says so. Replayed over the trees that carried them, R10 reproduces the 2026-07 sweep's own P4 list:

tree R10 findings
6b66c74^ CacheOptions.maxEntries, ReconnectOptions.maxAttempts, CircuitOptions.failureThreshold, DenoKvStoreOptions.maxIncrRetries, RetryOptions.maxMs, + 2 byte caps
32b7de2^ CircuitOptions.failureThreshold, RetryOptions.maxDelay, DenoKvStoreOptions.maxIncrRetries, + 2 byte caps
9ca6a1b CircuitOptions.failureThreshold, RetryOptions.maxMs, DenoKvStoreOptions.maxIncrRetries, + 2 byte caps
37d09d2^ RetryOptions.maxDelay, DenoKvStoreOptions.maxIncrRetries, + 2 byte caps

Every one of those renames is in P4's "Resolved (2026-07 sweep)" record. The "byte caps" in each row are ServeOptions.maxBodyBytes / TraceOptions.maxBodyBytes / StreamOptions.maxBufferBytes — the fields that would have needed allow-list entries at the time, exactly as the issue predicted, and that the envelope folds later removed.

What R10 does not catch — including its own motivating case

This is the honest limit, and it matters enough that it is now written into CONTRACT.md §7 rather than left to be rediscovered.

The 2026-07-31 sweep found LlmOptions.maxTokens / LlmRequest.maxTokens by hand. R10 reports neither, replayed against a8eca88 and 9ca6a1b, which carried them. Two separate, pre-existing reasons:

  • LlmOptions was type LlmOptions = Partial<Omit<StitchConfig,'kind'>> & { … } — a type-literal, which no member rule scans. §7 already records this limit for R8 and R9; R10 inherits it.
  • LlmRequest is an exported interface, but it is not *Options, so the container filter rejects the container — the same blind spot as MockRoute.respond in Ratchet: R6 cannot see a type alias that admits {} (misses MockRoute.respond) #564 item 2.

Both are pinned by fixture (P and Q), not asserted. This is why the trailer is Refs #565, not Closes: the rule guards the interface-shaped *Options surface, which is where P4's entire resolved list lived, but it is not a claim that the 2026-07-31 class is now mechanically covered. Closing either half is a decision already deferred elsewhere on that page — widening the container filter is the 23-findings/20-noise experiment #564 exists to prevent, and scanning type literals should be done once for R8/R9/R10 together, not bolted onto the newest rule.

I did not extend the rule to cover type literals for that reason, and because it would catch nothing on today's tree either.

*Threshold — included

The issue lists it as "also worth covering". It is genuinely the same mechanism — same loop, same container filter, and P4 bans it outright, so it needs no allow-list entry and can add no allow-list noise. It costs two lines and it guards a shape this repo really had (CircuitOptions.failureThresholdfailures). Zero findings today.

Verification

No test harness exists for check-contract.mjs, so this was verified three ways.

1. Fixture matrix — each variant written into a real source file (packages/core/src/types.ts or serve.ts), --list run, file restored. git status was clean of residue before committing.

fixture expected result
A. repo as-is 0 0
B. maxRetries on BackoffOptions caught 1 R10 (+1 R8, since it groups with the sibling max)
B2. maxRetries on RetryOptions (isolated) caught 1
C. the prescribed fix — bare plural retries clears 0
D2. maxTokens on a real *Options caught 1
E. failureThreshold caught 1
F. maximum — prefix must end a camel word quiet 0
G. same member on a non-envelope interface (ServeHandle) quiet 0
H. *Like envelope (P18 duck-type) quiet 0
I. member on a non-exported base an *Options extends caught at the base 1, HiddenBase.maxRetries
J. drop BackoffOptions.max from the allow-list fires 1
L1. drop ServeBodyOptions.max fires 1
L2. drop ShellBufferOptions.max fires 1
M. one base, two envelopes extending it dedup → 1 1
N. a different envelope may not borrow another's allow-list key fires 1
O. blessed *Config family in scope (StitchConfig.maxRetries) caught 1
P. type-literal envelope (the real LlmOptions shape) known limit 0 — missed
Q. exported non-*Options interface (the real LlmRequest) known limit 0 — missed
R. a JSDoc @example authoring maxRetries: 5 quiet 0
S. a @deprecated member skipped like R1/R3/R4/R6/R8 0 R10 (R7 fires, correctly)
K. a cap nested inside an object-literal member known limit (depth-0, as R8/R9) 0

2. Existing behavior unchanged — proven against history. A 0→0 diff cannot show that previously-caught violations still report, and this PR touches R6's filter, so the original and patched scripts were run over 24 historical trees and the findings lists diffed with R10's own rows excluded. Every one is identical, including the violation-rich trees:

tree orig patched of which R10 pre-R10 diff
6b66c74^ 70 77 7 identical
9ca6a1b 68 73 5 identical
37d09d2^ 67 71 4 identical
32b7de2^ 63 68 5 identical
79761ce 61 65 4 identical
2e4b4b6 60 64 4 identical
11ac432 33 37 4 identical
a8eca88 6 8 2 identical
d6cf2bf 1 3 2 identical
15 more recent trees 0 identical

No finding lost, none added, no baseline touched — scripts/contract-violations.baseline.json stays at zero and is not modified by this PR.

3. Repo checks — all green: check:contract, check:lint (36 packages), check:types (38 projects), check:types-d, test (exit 0), check:format, check:unknown-keys, check:changelog, check:docs-links. Nothing failed.

Scope

scripts/check-contract.mjs and docs/CONTRACT.md. The doc changes are not optional garnish: P4 gains its enforcement paragraph (matching how P17/P24 name their rules), §7 gains the R10 entry, the §7 line claiming every rule addition so far turned up a pre-existing match is corrected — R10 lands green — and the known-limits bullet now records the LlmOptions/LlmRequest miss. No CHANGELOG entry: tooling, not library behavior, matching #556, #528 and #668.

🤖 Generated with Claude Code

…#565)

P4/D2 says a count upper bound is a bare plural noun (`attempts`, `entries`,
`pages`, `failures`, `tokens`) and that `max` is retained only for a continuous
magnitude ceiling. Nothing in the ratchet enforced it: the 2026-07-31 sweep found
its P4 violations by hand while `check:contract` reported a clean baseline.

R10 flags a `max` cap — bare or prefixed, since the sweep record fixed both
`maxAttempts`-style and `paginate.max` — on a consumer-input envelope, unless the
field is on MAX_CAP_ALLOW, a curated list of verified magnitude ceilings carrying
one written reason each. It also flags any `*Threshold`, which P4 grants no
carve-out. The container filter is R6's, extracted as `isConsumerEnvelope()` so
the two cannot drift, and that reuse is the precision guarantee rather than a
convenience: P4 itself blesses `max*` on resolved internals, and those sit
outside the `*Options` family by name rather than on a hand-kept skip list.

The issue proposed numbering this R9; R9 already exists (P17/P25 duration and
size conformance), so this is R10 and nothing is renumbered.

Green on today's tree — the P4 sweep it gates was already done by hand, so this
is a regression guard, not a fix. Replayed over the trees that carried them, it
reproduces that sweep's own list: CacheOptions.maxEntries,
ReconnectOptions.maxAttempts, CircuitOptions.failureThreshold,
DenoKvStoreOptions.maxIncrRetries, RetryOptions.maxMs/maxDelay.

`Refs`, not `Closes`: R10 does not catch the issue's own motivating case.
`LlmOptions` was a `type … & { … }` literal, which no member rule scans, and
`LlmRequest` is an exported interface that is not `*Options`. Both limits are
pre-existing and now recorded in CONTRACT.md §7 rather than left to be
rediscovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rejifald
rejifald merged commit 44a9fcc into main Aug 6, 2026
12 checks passed
@rejifald
rejifald deleted the claude/contract-r10-max-count-cap branch August 6, 2026 14:32
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