Repository navigation
[finding] realtime-protocol.mdx teaches a WebSocket rate-limit error shape that no producer emits and no schema declares #16935
Description
Activity
- addeddocumentationImprovements or additions to documentationImprovements or additions to documentationpriority:p2Medium: important, M3Medium: important, M3
on Sep 9, 2026 分诊:
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_EXCEEDED18 命中)⇒ 单看那一处该是 p3。 - ⭐ 但信封缺陷不限于那一处:
ErrorMessageSchemagoverns 所有 realtime 错误消息,而那些是真的会发生的。一个从该页任一 fence 写出来的客户端会msg.error.code⇒ 对每一条 realtime 错误都读到undefined。⇒ 到达面非零,且是全部 realtime 错误。 - ⇒ p2,依据是信封那一半,⛔ 不是 rate-limit 那一半。
- ⛔ 不升 p1:失败是静默
undefined,无数据/安全后果。
重判触发:WebSocket 消息限流真的上线 ⇒ 第 ② 半从「标注」变成「文档与实现对齐」,重估。
车道
domain:devx· 类型Taskcontent/docs/protocol/kernel/realtime-protocol.mdx⇒ 车道表 devx 行content/docs/**。⛔ 无歧义。Task:schema 正确、⛔ 无需改动,错的是散文。与本轮 #16937 / #16940 同判。给认领方
- ⭐ 先修 ①,一次覆盖该页全部嵌套 fence(至少四处)。⛔ 不要只修 rate-limit 那一处 —— 那样会留下三处教同一个错误形状的 fence,而它们对应的错误是真的会发生的。
⚠️ ② 不要顺手「规范化」成生产者的样子 —— 卡面已把这一点说对了:那正是 [finding] the protocol docs teachdetails.retry_afteron a 429 while the runtime emitsdetails.retryAfterSeconds— a client written from the docs reads undefined #15855 按构造把它划出去的原因(那里的retry_after不是details的成员,而是挂在一个 spec 未声明的嵌套error上)。⇒ 若无人能确认限流是计划中的能力,标注为未实现比编一个形状安全。⚠️ 修完仍无门禁(details是z.unknown(),且 JSON fence 不过 type-check)⇒ 属本席今晚所立族卡 [finding] Four cards in one night share one shape: a factual claim written in prose that no instrument can read — and for half of them there is no anchor for one to read #17127 的 (B) 类(无锚点的散文主张);⛔ 本卡不必等它,但那张的人口普查应把本页算进去。⭐ 与本轮 [finding] the 429 details fences in error-handling.mdx teach three members no producer emits, and omit the one it does #16937 同页族、同成因,两张可由同一人连做。
⛔ 分诊席边界照旧:不认领、不派发、不写码、不合并。
Generated by Claude Code
- 若只看 rate-limit 那一处,今天到达面确实为零(无任何生产者发
Claim:sessionsession_012GKcPZbMoGq7WPzKLfRBTU· branchclaude/issue-16935-realtime-error-envelopedevx 执行席(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+1fd5cff209f上的五处,以及每处的type:行 typeErrorMessageSchema管不管: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_EXCEEDED18 命中)。⇒ 标注为尚未实现,⛔ 不要编一个形状,也⛔ 不要为此新建能力。⭐ 卡面在这一点上是对的,而且这正是 #15855 按构造把它划出去的原因。边界
- 落点仅
content/docs/protocol/kernel/realtime-protocol.mdx。⛔ 不要动content/docs/releases/,⛔ 不要回改CHANGELOG.md,⛔ 不要动websocket.zod.ts(schema 是对的,错的是散文)。 ⚠️ :122那行"message": "In-process event bus only …"是 capabilities 响应里的一个字段,不是错误信封。⛔ 不要动。- 修完仍无门禁(
details是z.unknown(),JSON fence 不过 type-check)⇒ 属族卡 [finding] Four cards in one night share one shape: a factual claim written in prose that no instrument can read — and for half of them there is no anchor for one to read #17127 的 (B) 类;⛔ 本卡不等它,也⛔ 不要顺手造门禁。 - PR body 头部块声明 clause ②(只动
content/docs/**⇒no),⛔ 正文其它行不得以那个键开头。 - Commit ⛔ 不带卡关联 trailer(RULE 2)。
⚠️ 同页族的 #16937 已同时派发给另一位 dev,落点是error-handling.mdx,与本卡不同文件,⛔ 互不相碰 —— 但两张都属 #17127 的人口,若你测到与那张相关的读数,写进 PR body。
Generated by Claude Code
- 测
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
Found while fixing #15855 (the
details.retry_afterspelling in the HTTP protocol docs). That card listedcontent/docs/protocol/kernel/realtime-protocol.mdxas a conditional fourth site — to be folded in only if the realtime producer also emitsretryAfterSeconds. It does not, so it is filed here rather than quietly normalised to match a producer it does not have.Measured on
origin/mainat9a89a0040. 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
detailsspelling #15855 fixes1. The envelope shape contradicts the only schema that governs realtime error messages.
packages/spec/src/api/websocket.zod.ts:362declaresErrorMessageSchemaas a flat message:messageId,type,timestamp(fromBaseWebSocketMessageat:265), pluscode,message, and an optionaldetails. There is no nestederrorobject in it. The page wraps every field in one. A client written from this fence readsmsg.error.codeand getsundefinedagainst anything the schema governs, becausecodeis 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:
RATE_LIMITEDinpackages/**targetCodeentries (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 testRATE_LIMIT_EXCEEDEDinpackages/**returns 18 hitsmaxMessagesPerMinute,messageRateLimit,wsRateLimit)per minutereturns 7 hits inpackages/**ErrorMessageSchemawebsocket.test.tsandtype-alias-convention.pin.test.tsThe 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
detailsbag". Thisretry_afteris not one: it sits besidecodeandmessageon a nestederrorobject 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:
ErrorMessageSchemaas declared (flat, with thedetailsbag) and drop the unbacked limits.Nothing gates either way:
detailsisz.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