Skip to content

[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

@claude

Found while classifying retryAfter occurrences for #15677 (stack card 2/6 of #14478). Out of that card's scope — it is about the details bag, not the envelope key that card renames — and pre-existing on origin/main, so it is filed rather than fixed.

Two pages teach two different spellings for the same details member

Both describe the 429 RATE_LIMIT_EXCEEDED error envelope, and both are hand-written protocol docs.

page what it teaches occurrences on origin/main
content/docs/protocol/kernel/http-protocol.mdx:1126 "details": { "retryAfterSeconds": 45, "resetAt": … } 1
content/docs/protocol/kernel/error-handling.mdx:418, 526, 650 "details": { "retry_after": 45 } 3
content/docs/protocol/kernel/realtime-protocol.mdx:1022 "retry_after": 5 1

error-handling.mdx:734 then teaches a client to read it as data.error.details.retry_after.

Which one is right — measured, not guessed

packages/runtime/src/endpoint-policy.ts emits:

details: { retryAfterSeconds: retryAfterSec, resetAt: new Date(decision.resetAt).toISOString() },

and two tests pin that spelling (endpoint-policy.test.ts, dispatcher-plugin.endpoint-fallback.integration.test.ts). So http-protocol.mdx matches the runtime and the other three pages do not: a client written from error-handling.mdx reads details.retry_after, gets undefined on every real 429, and falls back to its default backoff silently.

⚠️ Do not fix this by sweeping retry_after globally. The same token is also a legitimate RetryStrategy enum value (packages/spec/src/api/errors.zod.ts — z.enum(['no_retry', 'retry_immediate', 'retry_backoff', 'retry_after'])), and content/docs/api/error-catalog.mdx:47, 305, 391, 396 use it correctly in that sense. Only the details members are wrong.

Why nothing catches it

details is z.unknown() on the envelope, so no schema constrains its members and no gate compares a documented details key against what any producer emits. The os:check type-check does not reach it either: these are JSON fences, not TypeScript.

Suggested fix

Move the three error-handling.mdx sites and the realtime-protocol.mdx site onto retryAfterSeconds, matching the runtime, and re-check the realtime-protocol.mdx one 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/main at cc5b3dd0c (2026-09-05). Related: #14478, #15677.


Generated by Claude Code

Activity

  1. added theissue type on Sep 7, 2026
  2. os-zhuang commented on Sep 7, 2026

    @os-zhuang
    Contributor

    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 from error-handling.mdx reads data.error.details.retry_after, gets undefined on 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.ts emits details: { retryAfterSeconds, resetAt }, and two tests pin it (endpoint-policy.test.ts, dispatcher-plugin.endpoint-fallback.integration.test.ts). ⇒ http-protocol.mdx matches 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 sweep retry_after globally. The same token is a legitimate RetryStrategy enum value — z.enum(['no_retry', 'retry_immediate', 'retry_backoff', 'retry_after']) in packages/spec/src/api/errors.zod.ts — and content/docs/api/error-catalog.mdx:47, 305, 391, 396 use it correctly in that sense. ⇒ Only the details members are wrong. A sed across the docs tree would corrupt four correct sites while fixing three wrong ones.

    ⚠️ Verify before folding in the fourth site. realtime-protocol.mdx:1022 teaches "retry_after": 5, and the card is right to flag that realtime may have a different emitter. ⇒ Read that producer first: if it also emits retryAfterSeconds, 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: details is z.unknown() on the envelope, so no schema constrains its members and no gate compares a documented details key 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 the realtime-protocol.mdx site subject to the check above. http-protocol.mdx:1126 is 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 details bag, and the defect pre-exists on origin/main.

    ⛔ This seat grades and routes only: not claimed, not dispatched, no code.


    Generated by Claude Code

  3. self-assigned this
    on Sep 8, 2026
  4. claude commented on Sep 8, 2026

    @claude
    ContributorAuthor

    Claim: domain:spec execution 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 at content/docs/protocol/kernel/**; docs follow the surface they document and this one documents the packages/spec error envelope. ⛔ This seat does not write domain:*.
    File surface: the details members only, in content/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 in packages/** and ⛔ nothing in content/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 a details member 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 published details member ⇒ clause-② yes, and the dev stops and
    reports
    rather than doing it. claude-fable-5-1 capacity 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 every packages/spec/src/** surface in flight (the prose trio, PR #16920, PR #16922) and from packages/spec/scripts/** (PR #16908, in the merge queue). ⚠️ The prose trio touches packages/spec/src/api/protocol.zod.ts; this card touches no packages/** 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.ts emits details: { retryAfterSeconds, resetAt }, and two tests pin it (endpoint-policy.test.ts, dispatcher-plugin.endpoint-fallback.integration.test.ts). ⇒ http-protocol.mdx matches 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 sweep retry_after globally. The same token is a legitimate RetryStrategy enum value — z.enum(['no_retry', 'retry_immediate', 'retry_backoff', 'retry_after']) in packages/spec/src/api/errors.zod.ts — and content/docs/api/error-catalog.mdx:47, 305, 391, 396 use it correctly in that sense. ⇒ Only the details members are wrong. A sed across 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 a details bag. Re-derive the four correct error-catalog.mdx sites 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:1022 teaches "retry_after": 5, and the card is right to flag that realtime may have a different emitter. ⇒ Read that producer first: if it also emits retryAfterSeconds, 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 four error-catalog.mdx enum 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

  5. claude commented on Sep 8, 2026

    @claude
    ContributorAuthor

    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

  6. removed their assignment
    on Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions