Skip to content

docs(protocol): name the 429 backoff member what the runtime actually emits - #16938

Merged
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-15855-retry-after-details-spelling
Sep 8, 2026
Merged

docs(protocol): name the 429 backoff member what the runtime actually emits#16938
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-15855-retry-after-details-spelling

Conversation

@claude

@claude claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #15855

content/docs/protocol/kernel/error-handling.mdx taught details.retry_after on three error envelopes and then instructed clients to read data.error.details.retry_after. No producer emits that member. This renames the four sites to retryAfterSeconds, 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:

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.

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 a
published details member and the halt condition never applied.

The producer, re-derived on this tree (not taken from the card)

Both 429 emitters build the same details bag, as exhaustive object literals:

  • packages/runtime/src/endpoint-policy.ts:356details: { retryAfterSeconds: retryAfterSec, resetAt: new Date(decision.resetAt).toISOString() }
  • packages/runtime/src/security/inbound-rate-limit.ts:359 — byte-identical bag, the server-level 429

The card and triage named one emitter; there are two. Both spell it the same way. Pinned by packages/runtime/src/endpoint-policy.test.ts:181 and packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts:513, exactly as triage said. packages/spec/src/api/errors.zod.ts:407 declares retryAfterSeconds on the envelope itself, and the retired-key migration 18.api__EnhancedApiError__retryAfter refuses the old spelling — so retryAfterSeconds is the canonical name for a seconds-valued retry member across this whole error surface, not just on the 429 path.

Occurrence classification — every retry_after in content/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 a details bag. Counts are retry_after occurrences, measured on origin/main at 9a89a0040; every line number in the card and in triage was stale and none was reused.

page before after class verdict
content/docs/protocol/kernel/error-handling.mdx 4 0 details bag member — 3 JSON fences (:423, :531, :655) plus the reader instruction at :739 changed
content/docs/api/error-catalog.mdx 4 4 RetryStrategy enum value (:53, :320, :411, :416) untouched
content/docs/references/api/errors.mdx 3 3 RetryStrategy enum value (:49, :165, :272); also an AUTO-GEN tree untouched
content/docs/protocol/kernel/realtime-protocol.mdx 1 1 not a details member at all — see below untouched
content/docs/protocol/kernel/http-protocol.mdx 0 0 already retryAfterSeconds at :1126 untouched
total 12 8 4 lines changed, 1 file

Untouched-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.mdx were not in the card. All seven are the RetryStrategy enum value retry_after (no_retry, retry_immediate, retry_backoff, retry_after in packages/spec/src/api/errors.zod.ts), which is a correct and different use of the same token.

Proof they are untouched, from git diff --numstat against the merge base:

content/docs/api/error-catalog.mdx                  0 diff lines
content/docs/references/api/errors.mdx              0 diff lines
content/docs/protocol/kernel/realtime-protocol.mdx  0 diff lines
content/docs/protocol/kernel/http-protocol.mdx      0 diff lines

git diff --stat for 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.mdx in. 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:362 declares ErrorMessageSchema flatmessageId, type, timestamp, code, message, optional details. The page wraps all of it in a nested error object the spec does not declare.
  • No WebSocket message rate limiter exists in packages/** (0 hits for maxMessagesPerMinute / messageRateLimit / wsRateLimit; positive control: the phrase per minute returns 7 hits).
  • RATE_LIMITED has 5 sites in packages/**, none of them a realtime emitter (positive control: RATE_LIMIT_EXCEEDED returns 18).
  • Its retry_after sits beside code and message, so it is not a details member 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:

  1. Structural. The changed path is owned by no non-root package — scanning all 70 non-private package.json files, none has a root that content/docs/protocol/kernel/error-handling.mdx sits under, and every files[] is dist-scoped. The root package that does contain content/ is @objectstack/spec-monorepo, private: true. Positive control on the same scan: packages/spec/README.md resolves to @objectstack/spec, whose files[] lists README.md. ⇒ nothing any package ships moves.
  2. Empirical. The last 8 merges to main touching only content/docs/** carried 0 .changeset/* files between them.

Gates

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 39 families from the diff. All 39 run, all exit 0, reconciled with --ran:

dispatch-gates --ran: 39 derived famil(ies) accounted for -- 39 run, 0 NOT-MEASURED.

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:

gate first result after building second result
pnpm check:docs-transcript-drift exit 3, PREREQUISITE NOT MET@objectstack/lint not built pnpm --filter '@objectstack/lint...' run build exit 0
pnpm --filter @objectstack/spec run check:skill-examples exit 1, refused: packages/client-react/dist holds no declarations pnpm --filter '@objectstack/client-react...' --filter '@objectstack/client...' run build exit 0

Builds ran through the shared verify lock (VERDICT command-exit 0 on both).

Not owed, and why: turbo ls --affected --filter='...[origin/main]' returns 0 packages, so there is no build closure and no package test/typecheck to 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 in turbo ls at all — those are not on the graph, which is not the same as "cannot move".

pnpm lint narrowing, declared: the changed file is outside eslint's population entirely — eslint --no-inline-config --format json on it returns errorCount 0 with the single message File ignored because no matching configuration was supplied (ruleId null), read from eslint's own config resolution rather than guessed. And 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.

⚠️ This is still ungated after it lands

Carried from triage so nobody reads the fix as enforcement: details is z.unknown() on the ADR-0112 envelope, so no schema constrains its members, and no gate compares a documented details key 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_issues after REST /search/* returned 403 by design; the control card #15855 came back, and a second query reached closed cards, so both readings are real):

Noted, not filed: content/docs/protocol/kernel/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 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

… 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>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 8, 2026
@github-actions github-actions Bot added size/xs documentation Improvements or additions to documentation labels Sep 8, 2026
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT — domain:spec execution seat, 2026-09-08T17:27Z

Reviewed against head 2daa33be7, base 9a89a0040 (merge base — ⛔ not a two-dot diff).

⭐ The fence this card lived or died on, and it holds

Triage's load-bearing warning was that retry_after is also a legitimate RetryStrategy enum value, and a sed across the docs tree would corrupt correct sites while fixing wrong ones. So the acceptance test is not "did the wrong sites change" but "did the right sites stay still". Measured by this seat on both trees:

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

  1. Triage named one producer; there are two. packages/runtime/src/endpoint-policy.ts:356 and packages/runtime/src/security/inbound-rate-limit.ts:359 both build details: { 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.
  2. 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.mdx has no emitter at all: websocket.zod.ts:362 declares ErrorMessageSchema flat (code / message / details at top level, no nested error object), and no WebSocket rate limiter exists in packages/**. Its retry_after is therefore not a details member ⇒ 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

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.

⚠️ Declared narrowing, and an unusually honest one: the changed file is outside 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

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 8, 2026 17:36
@zhuangjianguo
zhuangjianguo added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit 34a76c4 Sep 8, 2026
37 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-15855-retry-after-details-spelling branch September 8, 2026 17:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xs skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants