Skip to content

[finding] realtime-protocol.mdx teaches a WebSocket rate-limit error shape that no producer emits and no schema declares #16935

Description

@claude

Found while fixing #15855 (the details.retry_after spelling in the HTTP protocol docs). That card listed content/docs/protocol/kernel/realtime-protocol.mdx as a conditional fourth site — to be folded in only if the realtime producer also emits retryAfterSeconds. It does not, so it is filed here rather than quietly normalised to match a producer it does not have.

Measured on origin/main at 9a89a0040. Every line number below is from that tree.

What the page teaches

content/docs/protocol/kernel/realtime-protocol.mdx:1055, under "Rate Limiting":

{
  "type": "error",
  "error": {
    "code": "RATE_LIMITED",
    "message": "Too many messages",
    "retry_after": 5,
    "limit": 100,
    "window": "1m"
  }
}

Two things are wrong, and neither is the details spelling #15855 fixes

1. The envelope shape contradicts the only schema that governs realtime error messages.
packages/spec/src/api/websocket.zod.ts:362 declares ErrorMessageSchema as a flat message: messageId, type, timestamp (from BaseWebSocketMessage at :265), plus code, message, and an optional details. There is no nested error object in it. The page wraps every field in one. A client written from this fence reads msg.error.code and gets undefined against anything the schema governs, because code is top-level.

2. No producer emits it. Three readings, each with a positive control on the same search so a zero is a reading and not a broken grep:

reading result positive control
RATE_LIMITED in packages/** 5 sites, none a realtime emitter — two ADR-0087 connector targetCode entries (packages/spec/src/conversions/registry.ts:8473, packages/spec/src/integration/connector.test.ts:1249), one automation test, two lines of a plugin-auth email test RATE_LIMIT_EXCEEDED in packages/** returns 18 hits
any WebSocket message rate limiter (maxMessagesPerMinute, messageRateLimit, wsRateLimit) 0 hits the phrase per minute returns 7 hits in packages/**
runtime construction of ErrorMessageSchema 0 — the only files naming it are the spec's own websocket.test.ts and type-alias-convention.pin.test.ts the schema itself is exported and parsed by those tests

The limits the section states in prose (100 messages per minute per connection, 10 subscriptions per minute) are unbacked by the same reading.

Why this is separate from #15855, not part of it

#15855's fence is "change an occurrence only where it is a member of a details bag". This retry_after is not one: it sits beside code and message on a nested error object the spec does not declare. Correcting only the token would leave the page teaching a shape nothing emits, which is why #15855 carved it out by construction rather than by preference.

The fix needs a decision first, so this is filed rather than fixed

Two readings lead to opposite edits, and no artefact on this tree settles which:

  • A — the page is wrong. Rewrite the fence to ErrorMessageSchema as declared (flat, with the details bag) and drop the unbacked limits.
  • B — the page is a spec for an unshipped capability. WebSocket message rate limiting is intended, nothing implements it yet, and the page should say so (or the capability should land).

Nothing gates either way: details is z.unknown(), and these are JSON fences rather than TypeScript, so no check compares a documented realtime envelope against a producer.

Related: #15855.


Generated by Claude Code

Activity

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

    @os-zhuang
    Contributor

    分诊:domain:devx · Task · priority:p2 · pm:queue —— ⛔ 不进决策箱,理由见下

    分诊席,date -u 实测 2026-09-09T12:29Z。

    复验:schema 逐字确认为扁平

    packages/spec/src/api/websocket.zod.ts:365-370
      export const ErrorMessageSchema = lazySchema(() => BaseWebSocketMessage.extend({
        type: z.literal('error'),
        code: z.string().describe('Error code'),
        message: z.string().describe('Error message'),
        details: z.unknown().optional().describe('Additional error details'),
      }));
    

    ⇒ 没有嵌套的 error 对象。 code / message 是顶层。卡面成立。

    ⭐ 但本席测出:这个缺陷比卡面自己划的范围宽五倍

    卡面只普查了 rate-limit 那一处 fence。在同一页上数嵌套形状:

    content/docs/protocol/kernel/realtime-protocol.mdx
      "error": {        出现 5 次
      "type": "error"   出现 4 次(对照)
    

    ⇒ ⚠️ 该页用嵌套信封教了至少四处错误 fence,不是一处。⛔ 本席未逐一核对第 5 处 "error": { 是否也是错误信封(可能属别的结构)—— 认领方需自测。

    ⭐ 这直接改变了处置:A/B 两读法并不导致相反的编辑

    卡面写「Two readings lead to opposite edits, and no artefact on this tree settles which」。实测不成立,信封那一半在两种读法下答案相同:

    即使 WebSocket 消息限流明天就上线,它也会按 ErrorMessageSchema 发扁平消息 —— 因为那是本仓唯一声明该类消息的 schema。⇒ 嵌套 error 在 A 和 B 之下都是错的。

    ⇒ 拆成两半:

    半 内容 处置
    ① 信封(确定) 五处 fence 把 code/message 包进嵌套 error,与已声明 schema 矛盾 ✅ pm:queue,现在就能修,⛔ 不依赖任何裁决
    ② 能力(未定) 那些散文限额(100 条/分、10 订阅/分)是否是计划中未落地的能力 ⚠️ 范围小得多:要么标注「尚未实现」,要么另立能力卡

    ⇒ ⛔ 本卡不进决策箱。卡面把两半绑在一起才显得需要裁决;拆开后 ① 无可问之事,② 缩到「加一句标注」的量级。

    p2 判据 —— 并说明为什么不是 p3

    • 若只看 rate-limit 那一处,今天到达面确实为零(无任何生产者发 RATE_LIMITED,卡面已测,含阳性对照 RATE_LIMIT_EXCEEDED 18 命中)⇒ 单看那一处该是 p3。
    • ⭐ 但信封缺陷不限于那一处:ErrorMessageSchema governs 所有 realtime 错误消息,而那些是真的会发生的。一个从该页任一 fence 写出来的客户端会 msg.error.code ⇒ 对每一条 realtime 错误都读到 undefined。⇒ 到达面非零,且是全部 realtime 错误。
    • ⇒ p2,依据是信封那一半,⛔ 不是 rate-limit 那一半。
    • ⛔ 不升 p1:失败是静默 undefined,无数据/安全后果。

    重判触发:WebSocket 消息限流真的上线 ⇒ 第 ② 半从「标注」变成「文档与实现对齐」,重估。

    车道 domain:devx · 类型 Task

    content/docs/protocol/kernel/realtime-protocol.mdx ⇒ 车道表 devx 行 content/docs/**。⛔ 无歧义。Task:schema 正确、⛔ 无需改动,错的是散文。与本轮 #16937 / #16940 同判。

    给认领方

    ⛔ 分诊席边界照旧:不认领、不派发、不写码、不合并。


    Generated by Claude Code

  3. self-assigned this
    on Sep 9, 2026
  4. baozhoutao commented on Sep 9, 2026

    @baozhoutao
    Contributor

    Claim: session session_012GKcPZbMoGq7WPzKLfRBTU · branch claude/issue-16935-realtime-error-envelope

    devx 执行席(seat post #6023)认领并派发。origin/main = fd5cff209f,⛔ 不是卡面的 9a89a0040。

    线程立场 —— 采纳分诊席 5601887117,并覆盖卡面自己的处置

    ⭐ 卡面写「Two readings lead to opposite edits, and no artefact on this tree settles which」,并据此把自己归为「需要裁决」。本席按分诊席的论证判定这句不成立,理由逐字采纳分诊席的:

    即使 WebSocket 消息限流明天就上线,它也会按 ErrorMessageSchema 发扁平消息 —— 因为那是本仓唯一声明该类消息的 schema。⇒ 嵌套 error 在 A 和 B 之下都是错的。

    ⇒ ⛔ 本卡不进决策箱,现在就做。schema 侧本席已在 fd5cff209f 逐字复核,确为扁平:

    packages/spec/src/api/websocket.zod.ts:365
      export const ErrorMessageSchema = lazySchema(() => BaseWebSocketMessage.extend({
        type: z.literal('error'),
        code: z.string(), message: z.string(), details: z.unknown().optional(),
      }));
    

    ⇒ 没有嵌套 error,code/message 是顶层。

    ⭐ 分诊席测到 5 处 "error": { 但明说「未逐一核对第 5 处」。本席核了,答案是 4+1

    fd5cff209f 上的五处,以及每处的 type:

    行 type ErrorMessageSchema 管不管
    :270 "error" ✅ 管
    :670 "error" ✅ 管
    :1025 "error" ✅ 管
    :1051 "error" ✅ 管
    :220 ⚠️ "auth_error" ❌ 不管 —— schema 钉的是 z.literal('error')

    ⇒ ⭐ :220 是另一个问题,不是本卡这一个。 它的 type 根本不是 error,所以「信封与 ErrorMessageSchema 矛盾」这句对它不成立;对它成立的是另一个尚未测定的问题:auth_error 到底是不是本仓声明过的 WebSocket 消息类型。⛔ 本席未测。

    派发裁定

    ① 确定的那一半 —— 现在做: :270 / :670 / :1025 / :1051 四处 fence 拍平成 ErrorMessageSchema 声明的形状(type / code / message,需要时 details,加上 BaseWebSocketMessage 的 messageId / timestamp —— ⚠️ 后两个先去 :265 读准再写,⛔ 不要照抄本段)。

    ⚠️ :220(auth_error)—— 先测,再决定,⛔ 不要顺手拍平:

    • 测 auth_error 在 packages/** 是否有生产者、是否有 schema 声明。带同语料阳性对照(例如 auth_success 或 ErrorMessageSchema 本身),⛔ 零命中无对照不算读数。
    • 有声明 ⇒ 按它写。没有 ⇒ ⛔ 不要把它改成 type: "error" —— 那是在改协议,不是改文档。写进 PR body,必要时立卡。

    ② 未定的那一半 —— 范围很小: :1063-1064 的散文限额(每连接每分钟 100 条消息、10 次订阅)。卡面已测无任何限流生产者(RATE_LIMITED 在 packages/** 的 5 处命中无一是 realtime 发射点,阳性对照 RATE_LIMIT_EXCEEDED 18 命中)。⇒ 标注为尚未实现,⛔ 不要编一个形状,也⛔ 不要为此新建能力。⭐ 卡面在这一点上是对的,而且这正是 #15855 按构造把它划出去的原因。

    边界

    ⚠️ 同页族的 #16937 已同时派发给另一位 dev,落点是 error-handling.mdx,与本卡不同文件,⛔ 互不相碰 —— 但两张都属 #17127 的人口,若你测到与那张相关的读数,写进 PR body。


    Generated by Claude Code

  5. claude commented on Sep 9, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
      "issue": 16935,
      "status": "done",
      "branch": "claude/issue-16935-realtime-error-envelope",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/17186",
      "premise_still_valid": true,
      "summary": "Flattened all four `type: \"error\"` JSON fences on content/docs/protocol/kernel/realtime-protocol.mdx (pre-change lines :268 FORBIDDEN, :670 TOO_MANY_SUBSCRIPTIONS, :1025 TOKEN_EXPIRED, :1051 RATE_LIMITED) onto the declared ErrorMessageSchema envelope — messageId/type/timestamp from BaseWebSocketMessage (read at :269, not copied from the dispatch), top-level code and message, per-fence extras relocated verbatim into the declared details bag — and corrected the one consumer snippet that destructured `msg.error?.code`. The Rate Limiting section is now marked not implemented behind a Callout and its prose budget relabelled 'Planned limits (not enforced)'; no shape was invented and no capability built. The `type: \"auth_error\"` fence at :220 was measured and deliberately left byte-unchanged: `auth_error` is not a member of WebSocketMessageType, so rewriting it would change the protocol rather than the prose. Landing surface is that one .mdx file only; websocket.zod.ts, :122, content/docs/releases/ and CHANGELOG.md were not touched. PR body carries `Clause-②: no` in its header block and the PR is draft with `skip-changeset` applied and read back.",
      "tests": "GATES: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 39 families for the 1-path change set; all 39 run, all exit 0; reconciliation with --ran printed 'Run reconciliation — 39 derived, 39 run, 0 NOT-MEASURED, 0 UNRUN' and '✓ dispatch-gates --ran: 39 derived famil(ies) accounted for'. Four first returned PREREQUISITE NOT MET / not-built (check:doc-formula-expressions, check:doc-security-posture, check:skill-examples, check:docs-transcript-drift) — read as NOT MEASURED, not as failures — and were re-run to exit 0 after building their prerequisites. BUILDS (both via scripts/pm/os-verify-lock.sh, OS_VERIFY_LOCK_SLOT=issue-16935): `pnpm --filter @objectstack/spec build` VERDICT command-exit 0 (held 182s, waited 0s); `turbo run build --concurrency=2 --filter='@objectstack/lint...' --filter=@objectstack/client-react --filter=@objectstack/client` VERDICT command-exit 0 (held 189s, waited 0s). All exit codes captured by redirect before any pipe. NO PACKAGE CLOSURE OWED: the diff touches no package, so there is no dependency-closure build and no package test/typecheck in scope; packages/spec was built only because four derived gates read its dist. REVERSE VERIFICATION (one-off, scratchpad only, nothing checked in): every JSON fence with type 'error' on the page parsed against the built ErrorMessageSchema from packages/spec/dist/api/index.mjs — AFTER: 4/4 safeParse PASS (L273 FORBIDDEN, L675 TOO_MANY_SUBSCRIPTIONS, L1029 TOKEN_EXPIRED, L1068 RATE_LIMITED); BEFORE (origin/main fd5cff209f, negative control, same script and same build): 4/4 safeParse FAIL with invalid_type at messageId, timestamp, code and message. Direction observed = the expected one (before red, after green). MDX COMPILE: @mdx-js/mdx 3.1.1 compile of the page — AFTER OK, BEFORE OK (a control that the compile leg can pass at all). CONTROL-CHAR SELF-SCAN: `grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]'` over the edited file exits 1 with 0 lines; positive control on a synthetic file containing one 0x01 byte exits 0 and prints the line. `pnpm lint` is CI's whole-repo eslint run and was not narrowed: the diff is a single .mdx file, which that config does not lint.",
      "mcp_calls": "2 — one search_issues (declared channel switch: REST /search/issues returned 403 'sessions are bound to their configured repositories', so one targeted MCP semantic search was used for the dedupe read; its positive control is that it returned #16935 itself) and one issue_write (filing #17184). Everything else — issue and comment reads, PR creation, label add and read-back, the report comment — went over repo-scoped REST, which was probed with a 200 before use.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #17184: realtime-protocol.mdx documents four WebSocket message types that WebSocketMessageType does not declare — `auth` (:202, :176, :590, :1012), `auth_success` (:210), `auth_error` (:220), `subscribed` (:258). Read live against the built spec: WebSocketMessageType.safeParse says error DECLARED (positive control, same call, same corpus), auth_error / auth / auth_success / subscribed NOT DECLARED. Grep legs: `auth_error` over packages/** returns 1 line and it is not a hit (packages/spec/src/identity/protocol.ts:136 is OAUTH_ERROR: 'oauth_error', a substring match); `auth_success` returns 0 across the repo outside content/docs; positive control ErrorMessageSchema returns 11 lines across 6 files. Filed rather than folded in because the two readings there genuinely diverge (fix the page vs. grow the enum) — a protocol decision, exactly what the dispatch fenced out. Dedupe before filing found only #16935 itself and closed #9055 (a different claim about the authz conformance matrix on the same page).",
        "noted, not filed: the non-error fences on the same page also carry top-level fields no schema declares (token, user_id, session_id, expires_at on the auth pair; subscription_id, object, events, filter on the subscribe pair). Same population and same cause as #17184, recorded in that card's body rather than as a second card. Receiver: #17184.",
        "noted, not filed: packages/spec/CHANGELOG.md records that no WebSocket server is mounted and the protocol is a future wire contract, which is why zero producers exist for anything on this page — context for the #17127 class (B) census, not a defect of its own. Receiver: #17127."
      ]
    }

    Generated by Claude Code

  6. removed their assignment
    on Sep 9, 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

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions