feat(contract): R10 gates the cap vocabulary — a max count cap (Refs #565) - #673
Merged
Conversation
…#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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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);maxis 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.mjstoday are R1–R9, so this is R10. Nothing is renumbered.What the rule does
Flags a
maxcap on a consumer-input envelope unless the field is onMAX_CAP_ALLOW, plus any*Threshold, which P4 grants no carve-out and which therefore needs no allow-list at all.It reads a bare
maxas well as amax-prefixed one, which is a deliberate departure from the issue's/\bmax[A-Z]/sketch. Two reasons:max-prefixed, … never a baremax" — and its resolved list fixed both kinds:maxAttempts/maxEntries/maxTokensandpaginate.max→pages./\bmax[A-Z]/does not matchBackoffOptions.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 baremax— 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 withunionArms(). That reuse is the precision guarantee, not a convenience: P4 blessesmax*on resolved internals (engine.ts's reconnect policy,ShellDefaults.maxBuffer, themaxBufferCharsparameters), and those sit outside the*Optionsfamily 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:
BackoffOptions.maxServeOptions.maxBodyBytesServeBodyOptions.maxTraceOptions.maxBodyCharsTraceBodyOptions.charsmaxany moreStreamOptions.maxBufferCharsStreamBufferOptions.charsmaxany moreShellBufferOptions.maxThe two
charsfields 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'sCOUNT_MEMBERset literally containschars, 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.
--listbefore--listafterThat 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:
6b66c74^CacheOptions.maxEntries,ReconnectOptions.maxAttempts,CircuitOptions.failureThreshold,DenoKvStoreOptions.maxIncrRetries,RetryOptions.maxMs, + 2 byte caps32b7de2^CircuitOptions.failureThreshold,RetryOptions.maxDelay,DenoKvStoreOptions.maxIncrRetries, + 2 byte caps9ca6a1bCircuitOptions.failureThreshold,RetryOptions.maxMs,DenoKvStoreOptions.maxIncrRetries, + 2 byte caps37d09d2^RetryOptions.maxDelay,DenoKvStoreOptions.maxIncrRetries, + 2 byte capsEvery 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.maxTokensby hand. R10 reports neither, replayed againsta8eca88and9ca6a1b, which carried them. Two separate, pre-existing reasons:LlmOptionswastype LlmOptions = Partial<Omit<StitchConfig,'kind'>> & { … }— atype-literal, which no member rule scans. §7 already records this limit for R8 and R9; R10 inherits it.LlmRequestis an exported interface, but it is not*Options, so the container filter rejects the container — the same blind spot asMockRoute.respondin Ratchet: R6 cannot see atypealias that admits{}(missesMockRoute.respond) #564 item 2.Both are pinned by fixture (P and Q), not asserted. This is why the trailer is
Refs #565, notCloses: the rule guards theinterface-shaped*Optionssurface, 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 scanningtypeliterals should be done once for R8/R9/R10 together, not bolted onto the newest rule.I did not extend the rule to cover
typeliterals for that reason, and because it would catch nothing on today's tree either.*Threshold— includedThe 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.failureThreshold→failures). 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.tsorserve.ts),--listrun, file restored.git statuswas clean of residue before committing.maxRetriesonBackoffOptionsmax)maxRetriesonRetryOptions(isolated)retriesmaxTokenson a real*OptionsfailureThresholdmaximum— prefix must end a camel wordServeHandle)*Likeenvelope (P18 duck-type)*OptionsextendsHiddenBase.maxRetriesBackoffOptions.maxfrom the allow-listServeBodyOptions.maxShellBufferOptions.max*Configfamily in scope (StitchConfig.maxRetries)type-literal envelope (the realLlmOptionsshape)*Optionsinterface (the realLlmRequest)@exampleauthoringmaxRetries: 5@deprecatedmember2. 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:
6b66c74^9ca6a1b37d09d2^32b7de2^79761ce2e4b4b611ac432a8eca88d6cf2bfNo finding lost, none added, no baseline touched —
scripts/contract-violations.baseline.jsonstays 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.mjsanddocs/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 theLlmOptions/LlmRequestmiss. No CHANGELOG entry: tooling, not library behavior, matching #556, #528 and #668.🤖 Generated with Claude Code