Repository navigation
[finding] the protocol docs teach details.retry_after on a 429 while the runtime emits details.retryAfterSeconds — a client written from the docs reads undefined #15855
Description
Activity
- addeddocumentationImprovements or additions to documentationImprovements or additions to documentation
on Sep 5, 2026 - addedpriority:p2Medium: important, M3Medium: important, M3and removed
on Sep 7, 2026 Triage: lands in
domain:spec(content/docs/protocol/kernel/**); rationale: class (a) under the boundary in the rule — 「示例照抄即失败是 (a)」. These are JSON fences a client is meant to copy, and copying them fails: a client written fromerror-handling.mdxreadsdata.error.details.retry_after, getsundefinedon every real 429, and falls back to its own default backoff.Bug.⭐ Which spelling is right was measured, not argued — that is what makes this gradeable:
packages/runtime/src/endpoint-policy.tsemitsdetails: { retryAfterSeconds, resetAt }, and two tests pin it (endpoint-policy.test.ts,dispatcher-plugin.endpoint-fallback.integration.test.ts). ⇒http-protocol.mdxmatches the runtime; the other three sites do not. The disagreement is between two hand-written pages, and the producer breaks the tie.priority:p2— 坏得安静, and the consequence is not confined to a confused reader. A client that silently ignores server-directed backoff on a rate-limit response retries on its own schedule against a service that just asked it to wait. That is the failure direction rate limiting exists to prevent, it is invisible at the client (no error, just a missing field), and it degrades exactly when load is already high. ⛔ Not p3 despite being "only docs".⚠️ The fence on this card is load-bearing and a mechanical fix will break it: ⛔ do not sweepretry_afterglobally. The same token is a legitimateRetryStrategyenum value —z.enum(['no_retry', 'retry_immediate', 'retry_backoff', 'retry_after'])inpackages/spec/src/api/errors.zod.ts— andcontent/docs/api/error-catalog.mdx:47, 305, 391, 396use it correctly in that sense. ⇒ Only thedetailsmembers are wrong. Asedacross the docs tree would corrupt four correct sites while fixing three wrong ones.⚠️ Verify before folding in the fourth site.realtime-protocol.mdx:1022teaches"retry_after": 5, and the card is right to flag that realtime may have a different emitter. ⇒ Read that producer first: if it also emitsretryAfterSeconds, it belongs in this fix; if it emits something else, it is a second finding and this PR should say so rather than quietly normalising it to match a producer it does not have.⭐ Why nothing caught it, worth carrying into the PR so nobody assumes a gate will hold the fix:
detailsisz.unknown()on the envelope, so no schema constrains its members and no gate compares a documenteddetailskey against what any producer emits;os:check's type-check does not reach these either, because they are JSON fences rather than TypeScript. ⇒ After this lands the docs will be correct and still ungated — ⛔ do not describe the fix as "now enforced".Scope: three sites in
error-handling.mdx(:418,:526,:650) plus the reader instruction at:734, and therealtime-protocol.mdxsite subject to the check above.http-protocol.mdx:1126is already correct — ⛔ do not touch it.⛔ Correctly out of #15677's scope: that card is about the envelope key it renames, this is about the
detailsbag, and the defect pre-exists onorigin/main.⛔ This seat grades and routes only: not claimed, not dispatched, no code.
Generated by Claude Code
Claim:
domain:specexecution seat,/pm-dispatch spec@objectstack.Session:
session_016N6xmWt5hYm94ffVEwGH8x
Branch:claude/issue-15855-retry-after-details-spelling
Worktree:/home/user/objectstack-issue-15855
Domain:domain:spec— triage placed it atcontent/docs/protocol/kernel/**; docs follow the surface they document and this one documents thepackages/specerror envelope. ⛔ This seat does not writedomain:*.
File surface: thedetailsmembers only, incontent/docs/protocol/kernel/error-handling.mdx,content/docs/api/error-catalog.mdx, and — only if the producer check below says so —content/docs/protocol/kernel/realtime-protocol.mdx. ⛔ Nothing inpackages/**and ⛔ nothing incontent/docs/releases/**.
Container & model:claude-opus-5, passed explicitly on the dispatch (SKILL.md:528).Clause-②: no
— this is a documentation correction: three hand-written pages teach adetailsmember spelling the
runtime does not emit, and the fix is to write what the producer actually emits. ⛔ No code changes, no
schema arm moves, no accept set moves, nothing is added to a published payload. 拉回已声明契约 ⇒ 常规档.
⛔ Fence: if the measured answer turns out to be that the runtime should change spelling instead of
the docs, that is a rename on a publisheddetailsmember ⇒ clause-②yes, and the dev stops and
reports rather than doing it.claude-fable-5-1capacity is exhausted, so such a diff could not get the
at-tier review the enqueue gate would demand, and 「契约复核 ⛔ 不适用额度耗尽豁免降档」.Thread-read: the single triage comment (
5570069547, 2026-09-07T11:39Z) read to the end. ⭐ This card carries no clause-② ruling — stated explicitly rather than left to inference.
Serial constraints cleared:content/docs/**is disjoint from everypackages/spec/src/**surface in flight (the prose trio, PR #16920, PR #16922) and frompackages/spec/scripts/**(PR #16908, in the merge queue).⚠️ The prose trio touchespackages/spec/src/api/protocol.zod.ts; this card touches nopackages/**file, so there is no overlap even on the error-envelope area.⛔ Not folded with #16228, dispatched the same round, and the reason is gate ① rather than convenience: that card's fix method is decide between two opposite edits from a reading of ADR-0090, this one's is write down what the producer already emits. Same family by shape, different method ⇒ separate dispatches, separate PRs.
The rulings this dispatch carries, verbatim and non-negotiable
⭐ Which spelling is right is already measured — the producer breaks the tie. Triage:
packages/runtime/src/endpoint-policy.tsemitsdetails: { retryAfterSeconds, resetAt }, and two tests pin it (endpoint-policy.test.ts,dispatcher-plugin.endpoint-fallback.integration.test.ts). ⇒http-protocol.mdxmatches the runtime; the other three sites do not. The disagreement is between two hand-written pages, and the producer breaks the tie.⛔ The load-bearing fence — a mechanical fix will break this card. Triage, verbatim:
⚠️ The fence on this card is load-bearing and a mechanical fix will break it: ⛔ do not sweepretry_afterglobally. The same token is a legitimateRetryStrategyenum value —z.enum(['no_retry', 'retry_immediate', 'retry_backoff', 'retry_after'])inpackages/spec/src/api/errors.zod.ts— andcontent/docs/api/error-catalog.mdx:47, 305, 391, 396use it correctly in that sense. ⇒ Only thedetailsmembers are wrong. Asedacross the docs tree would corrupt four correct sites while fixing three wrong ones.⇒ ⛔ No
sed, no global replace. Change only occurrences that are members of adetailsbag. Re-derive the four correcterror-catalog.mdxsites yourself and show they are untouched in the final diff.⚠️ The fourth site is conditional and must be answered, not assumed. Triage:⚠️ Verify before folding in the fourth site.realtime-protocol.mdx:1022teaches"retry_after": 5, and the card is right to flag that realtime may have a different emitter. ⇒ Read that producer first: if it also emitsretryAfterSeconds, it belongs in this fix; if it emits something else, it is a second finding and this PR should say so rather than quietly [fold it in].⇒ Read the realtime producer and state the reading. Either outcome is a good outcome; ⛔ guessing is not.
Premises to re-measure, ⛔ none handed over as settled
Every line number in the card and in the triage comment was taken on an older
origin/main. ⛔ Take yours from the tree, and give every zero-hit grep a positive control on the same file. Report the before/after occurrence counts per page, and the fourerror-catalog.mdxenum sites as an explicit untouched control.⚠️ Why this is p2 and not "only docs" — carry it into the changeset, because it is the reason the card exists:A client that silently ignores server-directed backoff on a rate-limit response retries on its own schedule against a service that just asked it to wait. That is the failure direction rate limiting exists to prevent, it is invisible at the client (no error, just a missing field), and it degrades exactly when load is already high.
Generated by Claude Code
os-dev-report
{ "issue": 15855, "status": "done", "branch": "claude/issue-15855-retry-after-details-spelling", "pr": "https://github.com/objectstack-ai/objectstack/pull/16938", "premise_still_valid": true, "summary": "Renamed the four `details`-bag sites in content/docs/protocol/kernel/error-handling.mdx from `retry_after` to `retryAfterSeconds` (3 JSON fences at :423/:531/:655 plus the reader instruction at :739), 1 file / 4 lines. Producer re-derived on this tree, not taken from the card: TWO emitters build `details: { retryAfterSeconds, resetAt }` as exhaustive object literals -- packages/runtime/src/endpoint-policy.ts:356 and packages/runtime/src/security/inbound-rate-limit.ts:359 -- pinned by endpoint-policy.test.ts:181 and dispatcher-plugin.endpoint-fallback.integration.test.ts:513; triage named one, there are two, both spelling it the same way. Every line number in the card and in triage was stale and none was reused. The clause-2 halt condition never applied: the producer already spells it retryAfterSeconds, so nothing renames on a published `details` member and no packages/** file is in the diff. The conditional fourth site was ANSWERED: realtime has no emitter at all (websocket.zod.ts:362 declares ErrorMessageSchema FLAT -- code/message/details at top level, no nested `error` object -- and zero websocket message rate limiter exists in packages/**), and its retry_after is not a `details` member, so it is a second finding filed as #16935, not folded in. `Fixes #15855` rather than `Part of` because the card's own Suggested fix pre-authorised that outcome, so no part of the card is left open.", "tests": "GATES: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 39 families; all 39 run, all exit 0; reconciled -- `dispatch-gates --ran: 39 derived famil(ies) accounted for -- 39 run, 0 NOT-MEASURED.` (exit 0). Exit codes captured by redirect-then-capture, never through a pipe. TWO gates first returned refusal-to-measure and were re-run green after building what they read, and are reported as NOT MEASURED on the first pass rather than as failures: `pnpm check:docs-transcript-drift` exit 3 `PREREQUISITE NOT MET -- @objectstack/lint is not built` then exit 0 after `pnpm --filter '@objectstack/lint...' run build`; `pnpm --filter @objectstack/spec run check:skill-examples` exit 1 `packages/client-react/dist holds no .d.ts declarations` then exit 0 after building the client-react and client closures (final line: `258 prose examples type-check across 3 surface(s)`). Both builds ran through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=issue-15855; VERDICT lines: `command-exit 0 held the lock 354s waited 0s` and `command-exit 0 held the lock 376s waited 345s`. The second acquisition exceeded the ~10min foreground cap and was blocked on synchronously in-turn with `tail --pid=23951` rather than left to a watcher. NOT OWED, measured: `turbo ls --affected --filter='...[origin/main]'` returns `0 no packages`, so there is no build closure and no package test/typecheck; blind spot stated -- that set is the package dependency graph, blind to tests reading another package's files off disk, and the repo-root package is not in `turbo ls` at all (not on the graph, which is not the same as cannot move). LINT NARROWING, declared with all three pieces: (1) population read from eslint's own config resolution -- `eslint --no-inline-config --format json` on the changed file returns 1 result, errorCount 0, one message with ruleId null, `File ignored because no matching configuration was supplied`, so the file is outside `eslint .`'s population entirely; (2) file count read from --format json, not guessed; (3) invariance -- eslint.config.mjs:328 states the repo `never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file`, so this diff cannot move the verdict on any untouched file. CI runs the full sweep. No ablation and no reverse-verification artefact in this run: the diff is four documentation lines with no gate that reads them, so there is no guard to mutate and no red to demonstrate -- stated rather than fabricated. Control-char self-scan beyond check:nul-bytes: `grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]'` on the touched file, exit 1 (no hits).", "mcp_calls": "2 -- both `search_issues`, and both are declared channel switches: REST `/search/*` returned 403 `This GitHub API path is not available` (blocked by design, not a rate limit), which is the documented fallback trigger. Positive control on the dedup: the first query returned #15855 itself, so the empty duplicate set is a reading; the second returned two CLOSED cards (#8087, #4224), so closed cards are inside the search's reach as dedup requires. Everything else -- card body, both comments, both new issues, the PR, the label write and every read-back -- went through zero-quota repo-scoped REST after a probe returned http=200.", "open_questions": [], "out_of_scope_findings": [ "filed as #16935: realtime-protocol.mdx:1055 teaches a WebSocket rate-limit error envelope that no producer emits and that contradicts the flat ErrorMessageSchema; needs a decision (document the schema vs treat the page as a spec for an unshipped capability), so filed rather than fixed", "filed as #16937: the same 429 details fences this PR edits teach `limit`, `window`, `quota_reset`, `upgrade_url` -- none emitted by either producer -- and omit `resetAt`, which both emit; same class as this card, one notch quieter, so separate rather than raised to its priority", "noted, not filed: 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; renamed here for within-page consistency and because the card scoped it, and carried into #16937's suggested fix -- #16937 is the successor that will next touch these fences", "noted, not filed: the card named FOUR correct RetryStrategy enum sites; re-derived from the tree there are SEVEN -- content/docs/references/api/errors.mdx:49,165,272 were absent from the card. No card needed: that file is an AUTO-GEN tree (AGENTS.md Documentation Guardrails) and all seven sites are correct; the correction is recorded in the PR's untouched-enum control instead. Successor: none needed." ] }
Generated by Claude Code
Found while classifying
retryAfteroccurrences for #15677 (stack card 2/6 of #14478). Out of that card's scope — it is about thedetailsbag, not the envelope key that card renames — and pre-existing onorigin/main, so it is filed rather than fixed.Two pages teach two different spellings for the same
detailsmemberBoth describe the
429 RATE_LIMIT_EXCEEDEDerror envelope, and both are hand-written protocol docs.origin/maincontent/docs/protocol/kernel/http-protocol.mdx:1126"details": { "retryAfterSeconds": 45, "resetAt": … }content/docs/protocol/kernel/error-handling.mdx:418, 526, 650"details": { "retry_after": 45 }content/docs/protocol/kernel/realtime-protocol.mdx:1022"retry_after": 5error-handling.mdx:734then teaches a client to read it asdata.error.details.retry_after.Which one is right — measured, not guessed
packages/runtime/src/endpoint-policy.tsemits:and two tests pin that spelling (
endpoint-policy.test.ts,dispatcher-plugin.endpoint-fallback.integration.test.ts). Sohttp-protocol.mdxmatches the runtime and the other three pages do not: a client written fromerror-handling.mdxreadsdetails.retry_after, getsundefinedon every real 429, and falls back to its default backoff silently.retry_afterglobally. The same token is also a legitimateRetryStrategyenum value (packages/spec/src/api/errors.zod.ts—z.enum(['no_retry', 'retry_immediate', 'retry_backoff', 'retry_after'])), andcontent/docs/api/error-catalog.mdx:47, 305, 391, 396use it correctly in that sense. Only thedetailsmembers are wrong.Why nothing catches it
detailsisz.unknown()on the envelope, so no schema constrains its members and no gate compares a documenteddetailskey against what any producer emits. Theos:checktype-check does not reach it either: these are JSON fences, not TypeScript.Suggested fix
Move the three
error-handling.mdxsites and therealtime-protocol.mdxsite ontoretryAfterSeconds, matching the runtime, and re-check therealtime-protocol.mdxone against its own producer first — realtime may have a different emitter, in which case it is a second finding rather than the same one.Measured on
origin/mainatcc5b3dd0c(2026-09-05). Related: #14478, #15677.Generated by Claude Code