docs(protocol): name the 429 backoff member what the runtime actually emits - #16938
Conversation
… emits
`content/docs/protocol/kernel/error-handling.mdx` taught `details.retry_after`
on three error envelopes and instructed clients to read
`data.error.details.retry_after`. No producer emits that member: both 429
emitters build `details: { retryAfterSeconds, resetAt }`
(`packages/runtime/src/endpoint-policy.ts`,
`packages/runtime/src/security/inbound-rate-limit.ts`), and two tests pin the
spelling. A client written from this page reads `undefined` on every real 429
and silently falls back to its own backoff against a service that just asked
it to wait.
Only `details`-bag members move. The `RetryStrategy` enum value `retry_after`
is a different, correct use of the same token and is untouched in all seven of
its sites (`content/docs/api/error-catalog.mdx` x4,
`content/docs/references/api/errors.mdx` x3).
Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
Co-authored-by: Claude <noreply@anthropic.com>
ACCEPT —
|
| file | retry_after on origin/main |
on the PR head | |
|---|---|---|---|
content/docs/api/error-catalog.mdx |
4 | 4 | UNCHANGED ✓ |
content/docs/references/api/errors.mdx |
3 | 3 | UNCHANGED ✓ |
content/docs/protocol/kernel/error-handling.mdx |
4 | 0 | the four wrong ones |
— same file, retryAfterSeconds |
0 | 4 | one-for-one |
⭐ The card knew about four correct enum sites; the dev re-derived seven. The three extra are in content/docs/references/api/errors.mdx, an auto-generated tree — correctly left alone, and correctly not filed as a card, with the correction recorded in the PR's own control instead. A wider blast radius than the card described, and the diff still did not touch it.
Other readings this seat took
| reading | value | instrument |
|---|---|---|
| diff shape | 1 file, +4 / −4 | git diff --stat 9a89a0040 2daa33be7 |
packages/** touched |
0 | git diff --name-only … | grep ^packages/ |
| clause-② pair | exit 0 | check-clause2-carriers.mjs --pair 16938 |
| governed surface | 0 of 1 | check-governed-merges.mjs --test |
| model tier | 163 harness-stamped "model":"claude-opus-5", no other value |
subagent transcript grep |
skip-changeset is right here and it is a different answer from the sibling prose PR #16930 for a measurable reason: that one edits packages/spec/src/**/*.zod.ts, which @objectstack/spec's files[] publishes; this one edits content/docs/**, which no package ships.
⭐ Two premises the dev strengthened rather than inherited
- Triage named one producer; there are two.
packages/runtime/src/endpoint-policy.ts:356andpackages/runtime/src/security/inbound-rate-limit.ts:359both builddetails: { retryAfterSeconds, resetAt }as exhaustive object literals, pinned by two named tests. Two independent emitters agreeing makes the direction stronger than the card claimed, not weaker. - The conditional fourth site was ANSWERED, not assumed — which is exactly what the dispatch asked for and the outcome it said would also be good.
realtime-protocol.mdxhas no emitter at all:websocket.zod.ts:362declaresErrorMessageSchemaflat (code/message/detailsat top level, no nestederrorobject), and no WebSocket rate limiter exists inpackages/**. Itsretry_afteris therefore not adetailsmember ⇒ a second finding, filed as [finding] realtime-protocol.mdx teaches a WebSocket rate-limit error shape that no producer emits and no schema declares #16935 and ⛔ not folded in.
Filed rather than folded — both correct calls
- [finding] realtime-protocol.mdx teaches a WebSocket rate-limit error shape that no producer emits and no schema declares #16935 —
realtime-protocol.mdxteaches a WebSocket rate-limit envelope no producer emits, contradicting the flatErrorMessageSchema. Needs a decision (document the schema vs. treat the page as a spec for an unshipped capability), so filed rather than fixed. - [finding] the 429 details fences in error-handling.mdx teach three members no producer emits, and omit the one it does #16937 — the same 429 fences teach
limit,window,quota_reset,upgrade_url, none of which either producer emits, and omitresetAt, which both emit. Same class as this card, one notch quieter.
Both are bare and unlabelled; grading and routing are the triage seat's.
⚠️ One judgement call, disclosed rather than buried
error-handling.mdx:531 is a 503 SERVICE_UNAVAILABLE example, and no 503 producer on this tree emits a details bag at all — so nothing pins that site in either direction. It was renamed for within-page consistency and because the card scoped it, and carried into #16937's suggested fix. That sits inside this dispatch's own fence (change occurrences that are presented as members of a details bag), so it is in scope — but it is the one line here that is consistency rather than falsification, and it should be read that way.
Gates
39 derived, 39 run, 0 NOT-MEASURED, reconciliation exit 0, all exit 0. Two gates first refused to measure (check:docs-transcript-drift exit 3 for an unbuilt @objectstack/lint; check:skill-examples exit 1 for missing client-react declarations) and were re-run green after building what they read — reported as NOT MEASURED on the first pass rather than as failures, which is the right way round.
eslint .'s population entirely — eslint --format json on it returns one message with ruleId: null, "File ignored because no matching configuration was supplied". The dev reported that rather than presenting a vacuous green as coverage. It also stated the blind spot in turbo ls --affected (blind to tests reading another package's files off disk; the repo-root package is not on the graph at all) instead of reading "0 packages" as proof of no impact.
⛔ No ablation artefact, and the dev said so plainly rather than fabricating one: the diff is four documentation lines with no gate that reads them, so there is no guard to mutate and no red to demonstrate.
Landing
⛔ Not enqueued yet — 30 names seen, 5 still running, 0 non-green. Enqueueing when every name closes completed with success/skipped, aggregated by name.
Generated by Claude Code
Fixes #15855
content/docs/protocol/kernel/error-handling.mdxtaughtdetails.retry_afteron three error envelopes and then instructed clients to readdata.error.details.retry_after. No producer emits that member. This renames the four sites toretryAfterSeconds, which is what the runtime actually puts in the bag, and touches nothing else.Carrying the reason this is p2 rather than "only docs", verbatim from triage:
Clause-②: no
This is a documentation correction with no
packages/**file in the diff. Nothing is added to,removed from, or renamed on a published payload; no schema arm moves and no accept set moves.
The fix pulls three hand-written pages back onto a contract the producer already declares.
The fence this card carried was measured and did not trip: the producer, not the docs, is the
authority, and the producer already spells it
retryAfterSeconds— so no rename lands on apublished
detailsmember and the halt condition never applied.The producer, re-derived on this tree (not taken from the card)
Both 429 emitters build the same
detailsbag, as exhaustive object literals:packages/runtime/src/endpoint-policy.ts:356—details: { retryAfterSeconds: retryAfterSec, resetAt: new Date(decision.resetAt).toISOString() }packages/runtime/src/security/inbound-rate-limit.ts:359— byte-identical bag, the server-level 429The card and triage named one emitter; there are two. Both spell it the same way. Pinned by
packages/runtime/src/endpoint-policy.test.ts:181andpackages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts:513, exactly as triage said.packages/spec/src/api/errors.zod.ts:407declaresretryAfterSecondson the envelope itself, and the retired-key migration18.api__EnhancedApiError__retryAfterrefuses the old spelling — soretryAfterSecondsis the canonical name for a seconds-valued retry member across this whole error surface, not just on the 429 path.Occurrence classification — every
retry_afterincontent/docs, classified before editing⛔ No
sed, no global replace, no regex sweep. Each occurrence was read in context and changed only where it is a member of adetailsbag. Counts areretry_afteroccurrences, measured onorigin/mainat9a89a0040; every line number in the card and in triage was stale and none was reused.content/docs/protocol/kernel/error-handling.mdxdetailsbag member — 3 JSON fences (:423,:531,:655) plus the reader instruction at:739content/docs/api/error-catalog.mdxRetryStrategyenum value (:53,:320,:411,:416)content/docs/references/api/errors.mdxRetryStrategyenum value (:49,:165,:272); also an AUTO-GEN treecontent/docs/protocol/kernel/realtime-protocol.mdxdetailsmember at all — see belowcontent/docs/protocol/kernel/http-protocol.mdxretryAfterSecondsat:1126Untouched-enum control (required deliverable)
The card named four correct enum sites; re-derived from the tree, there are seven — the three in
content/docs/references/api/errors.mdxwere not in the card. All seven are theRetryStrategyenum valueretry_after(no_retry,retry_immediate,retry_backoff,retry_afterinpackages/spec/src/api/errors.zod.ts), which is a correct and different use of the same token.Proof they are untouched, from
git diff --numstatagainst the merge base:git diff --statfor the whole PR:1 file changed, 4 insertions(+), 4 deletions(-).The conditional fourth site: answered, not assumed
Triage required the realtime producer to be read before folding
realtime-protocol.mdxin. It was, and the answer is that realtime has no emitter at all — a stronger reading than "a different emitter":packages/spec/src/api/websocket.zod.ts:362declaresErrorMessageSchemaflat —messageId,type,timestamp,code,message, optionaldetails. The page wraps all of it in a nestederrorobject the spec does not declare.packages/**(0 hits formaxMessagesPerMinute/messageRateLimit/wsRateLimit; positive control: the phraseper minutereturns 7 hits).RATE_LIMITEDhas 5 sites inpackages/**, none of them a realtime emitter (positive control:RATE_LIMIT_EXCEEDEDreturns 18).retry_aftersits besidecodeandmessage, so it is not adetailsmember and is outside this card's fence by construction.⇒ Second finding, filed as its own card: #16935. Not folded in here. The card's own "Suggested fix" pre-authorised exactly this outcome, so nothing of #15855 is left open by it.
Changeset: measured, not asserted
skip-changeset. Two independent readings:package.jsonfiles, none has a root thatcontent/docs/protocol/kernel/error-handling.mdxsits under, and everyfiles[]isdist-scoped. The root package that does containcontent/is@objectstack/spec-monorepo,private: true. Positive control on the same scan:packages/spec/README.mdresolves to@objectstack/spec, whosefiles[]listsREADME.md. ⇒ nothing any package ships moves.maintouching onlycontent/docs/**carried 0.changeset/*files between them.Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 39 families from the diff. All 39 run, all exit 0, reconciled with--ran:Two of them first returned a refusal-to-measure and were re-run green after building what they read, rather than being recorded as failures:
pnpm check:docs-transcript-driftPREREQUISITE NOT MET—@objectstack/lintnot builtpnpm --filter '@objectstack/lint...' run buildpnpm --filter @objectstack/spec run check:skill-examplespackages/client-react/distholds no declarationspnpm --filter '@objectstack/client-react...' --filter '@objectstack/client...' run buildBuilds ran through the shared verify lock (
VERDICT command-exit 0on both).Not owed, and why:
turbo ls --affected --filter='...[origin/main]'returns 0 packages, so there is no build closure and no packagetest/typecheckto run. That set comes from the package dependency graph, so it is blind to tests that read another package's files off disk, and the repo-root package is not inturbo lsat all — those are not on the graph, which is not the same as "cannot move".pnpm lintnarrowing, declared: the changed file is outside eslint's population entirely —eslint --no-inline-config --format jsonon it returnserrorCount 0with the single messageFile ignored because no matching configuration was supplied(ruleIdnull), read from eslint's own config resolution rather than guessed. Andeslint.config.mjs:328states the repo "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file", so this diff cannot move the verdict on any untouched file. CI runs the full sweep.Carried from triage so nobody reads the fix as enforcement:
detailsisz.unknown()on the ADR-0112 envelope, so no schema constrains its members, and no gate compares a documenteddetailskey against what any producer emits. These are JSON fences, not TypeScript, so the type-check does not reach them. The docs are now correct and still unguarded.验收备注
Out-of-scope findings, filed rather than swept in (dedup first — one targeted
search_issuesafter REST/search/*returned 403 by design; the control card #15855 came back, and a second query reached closed cards, so both readings are real):realtime-protocol.mdxteaches a WebSocket rate-limit error shape no producer emits and no schema declares. Needs a decision (document the flat schema, or treat the page as a spec for an unshipped capability), so it is filed, not fixed.limit,window,quota_resetandupgrade_url, which no producer emits, and omitresetAt, which it does. Same class as this card, one notch quieter (no retry-storm), which is why it is separate rather than raised to this card's priority. Deliberately left for it: after this PR, the backoff member is right and the rest of the bag is still unbacked.Noted, not filed:
content/docs/protocol/kernel/error-handling.mdx:531is a 503SERVICE_UNAVAILABLEexample, and no 503 producer on this tree emits adetailsbag at all — so nothing pins that site in either direction. It is renamed here for within-page consistency and because the card scoped it, and the observation is carried into #16937's suggested fix, which is the card that will next touch these fences.Generated by Claude Code