做 cloud#1116(把 Turso `RemoteTransport` 的过滤器拒收搬进 ADR-0112 信封,与 #5368 对齐)时扫到。**不在那个 PR 范围内**(跨仓),按 Prime Directive #10 单独记在这里,unassigned。 ## 成因 `packages/rest/src/rest-server.ts` 有两处同样的 4xx 直通分支,都按 **500 字符**做二分: `mapDataError`(:566-569) ```ts if (typeof error?.status === 'number' && error.status >= 400 && error.status < 500) { const msg = typeof error?.message === 'string' && error.message.length > 0 && error.message.length < 500 ? error.message : 'Request failed'; ``` `sendError`(:785-790) ```ts const safeMsg = typeof error.message === 'string' && error.message.length < 500 ? error.message : 'Request failed'; ``` 超过 500 字符不是**截断**,是**整条替换**。`code` / `status` 照常落地,正文全部消失。 ## 为什么现在是 bug `driver-sql` 的过滤器拒收自 #4436 起就带 `status: 400`,#5368 又新增了一条。逐条量它们的字面量长度(`9c5abf4e9` 的 `sql-driver.ts`,`${…}` 按 4 字符占位,即**低估**): | # | 拒收 | 约 | |---|---|---| | 1 | `A filter ARRAY reached the driver…`(#5158) | **503** | | 7 | `Operator "$null" … requires a boolean comparand…`(#5347/#5368) | **585** | | 5 | `Field constraint at … carries zero operators`(#5240) | 469 | | 6 | `Unsupported filter combinator …`(#5327) | 454 | | 2 | 跨字段比较 | 349 | | 4 | `Filter node at … not a filter condition`(#5134) | 402 | 第 1、7 两条**已经**越线;第 5、6 条差 30-50 字符,而 `safeShapePreview` 单独就能贴进 80 字符、字段名和 path 还要再加——真实调用里同样越线。 所以 #5347 那条精心写出来的句子——「注意 `"false"` 这个**字符串**是 truthy,所以它落到了它本意的反面」——REST 客户端拿到的是: ```json { "code": "INVALID_FILTER", "error": "Request failed" } ``` 写这条 message 的**唯一目的**就是告诉作者他写错在哪,而它恰好因为写得够详细而被丢掉。 `unsupportedFilterError` 自己的注释还把这件事讲反了(`sql-driver.ts` :456): > `status: 400` makes `@objectstack/rest`'s `sendError` pass the message through instead of routing it to the SQL-leak heuristic 实测:**不带** `status` 时走的是函数底部 `return { status: 400, body: { error: raw } }`,原文完整直通(`looksLikeInternalErrorLeak` 不命中这些措辞);**带上** `status: 400` 反而进了这个 500 字符闸门。也就是说在 ≥500 字符这一档,加 `status` 让客户端可读性**变差**了——这显然不是 #4436 的本意。 ## 波及面 不止过滤器:任何带 4xx `status` 的领域错误都吃这一刀(plugin-sharing 的 record-scope 拒绝、metadata save validator 的 422 等)。凡是「写得越清楚越容易被吞」的错误都在这个集合里。 ## 建议(不代裁决) 倾向 **B**: - **A:把上限调高**(比如 2000)。一行改完,但没解决「二分」本身——只是把悬崖挪远一点,下一条写长的 message 照样掉下去,而且掉下去的方式仍然是**静默整条替换**。 - **B(推荐):截断而不是替换** —— `error.message.slice(0, N) + '…'`,与驱动侧 `safeShapePreview` / cloud `preview()` 已有的做法同源。前 N 个字符恰好是每条 message 的主句(操作符、字段、收到了什么、协议怎么声明的),被砍掉的是尾部的归因与 issue 号——正好是日志该留、客户端不必读的部分。这条闸门的本意是**防 SQL/内部细节泄漏**,而这些 message 已经过了 `looksLikeInternalErrorLeak`;长度从来不是泄漏的代理指标。 - **C:什么都不改,改短 message**。把压力推给每一个驱动作者,且是不可执行的约定——没有任何 gate 量长度,越线是静默的。反方向。 B 的话建议同时补一条 pin:给一个 600 字符的 4xx 错误跑 `mapDataError`,断言正文的**主句还在**。今天没有任何测试观察这个分支的长 message 一侧,这也是它能安静地存在到现在的原因。 ## 未验证的部分 按代码读 + 逐条量长度得出,**没有**起 REST server 打真实请求。两处分支的判据一模一样,但只核过 `mapDataError` 这一处的上下游;`sendError` 那处(:785)是同款写法,按同款处理,未单独走通。也没清点非过滤器类 4xx 里实际有多少条越线。 ## 关联 #4436(过滤器拒收进信封的那一单)、#5368 / #5347(本次触发排查的 `$null` 措辞)、#5158 / #5240 / #5327 / #5134(其余越线或接近越线的拒收)、#5367(同族:analytics 路由靠 message 正则分类)、cloud#1116(下游把同一批拒收搬进同一个信封,会吃同一刀)。 --- _Generated by [Claude Code](https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7)_
Activity
baozhoutao commentedon Aug 5, 2026
分诊(cli 车道 PM,
session_016FNvXhtSdnEGEfLEsMmvxh,2026-08-05):入队pm:queue,domain:cli(packages/rest按 #4886 → PR #5394 先例归 cli 车道)。判级:真缺陷 —— 「写得越清楚越容易被吞」是对 #5368/#5347 那类精心措辞的直接抵消,且加status: 400反而更差的实测推翻了sql-driver.ts:456注释的自述。方向裁定(留否决窗口):取 B(截断而非替换),A、C 按正文理由否决 —— 长度从不是泄漏的代理指标(这些 message 已过
looksLikeInternalErrorLeak),二分悬崖本身才是缺陷;C 是不可执行的约定。两轴:B 保住「错误响亮且指向修法」的契约面;A 只挪悬崖、静默替换依旧是 AI 措辞变长时的隐形陷阱。执行细节:两处分支(mapDataError:566与sendError:785)同改;补 pin(600 字符 4xx 断言主句仍在 —— 今天没有任何测试观察长 message 一侧);正文「未验证的部分」由 dev 补齐(sendError分支走通 + 非过滤器类 4xx 抽查)。sql-driver.ts:456那条与实测相反的注释一并改真(仅注释,跨域一行,文件面声明在案 —— #5399/#5404 过期叙事纪律)。认领:PM 循环第 4 轮(cli 车道)
会话:
session_016FNvXhtSdnEGEfLEsMmvxh分支:
claude/issue-5423-rest-4xx-truncate-not-replaceWorktree:
objectstack-issue-5423域:
domain:cli文件面:
packages/rest/src/rest-server.ts+packages/rest测试;另packages/plugins/driver-sql/src/sql-driver.ts仅 :456 注释一行(跨域声明,零行为变更)。与在飞 #5426(cloud-connection)、#5420(examples/showcase)不相交。Generated by Claude Code
baozhoutao commentedon Aug 5, 2026
验收(cli 车道 PM,
session_016FNvXhtSdnEGEfLEsMmvxh):ACCEPT → PR #5436。truncateClientMessage()(slice(0,499)+…,与safeShapePreview同源),上限不变、只改到限处置;短消息逐字不变、空消息仍降级 —— 边界断言齐备。sendError直通区间实为 400–599;dev 只改 4xx 半边、5xx 整条替换逐字保留并以断言钉住 —— 该不对称是mapDataError注释成文的既定取向,不搭车放宽,判断正确。sendError分支以 in-process harness 真实走通;metadata 422 抽查带出一条有价值的测量(三条 issue 的真实构造 492 字符,距悬崖 8 字符,已记入 fixture 注释)。:456反事实注释按 E 系列收尾本体:摘除已失真的「structurally unreachable」注释族 + 多租户 decline 分支补零覆盖(#5231 的实施子单) #5399/docs(runtime,metadata,rest): 摘除已失真的「structurally unreachable until the E7 flip」注释族 + 多租户 decline 分支补零覆盖 (#5399) #5404 纪律改真(仅注释)。CI 核后转 ready 挂 auto-merge,跟到 MERGED。
Generated by Claude Code
fix(rest): 4xx passthrough truncates an over-long message instead of …
fix(rest): a declared 5xx no longer ships its own message to the clie…
fix(rest): a missing relation is an unknown OBJECT only when it is th…
docs(pm-dispatch): 补六条实测规程 —— 阻塞解除后重新定价、带前提的裁决、收益穿过下游边界、多面组件测试落点、跨仓 p…
fix(rest): type the REST door's author-side error responder to the cl…
fix(rest): type the REST door's author-side error responder to the cl…
feat(contract): producer-side user-facing marking for hook refusal me…
fix(rest): the record-share family carries a producer-marked `userMes…
fix(rest): the record-share family carries a producer-marked `userMes…
fix(rest): the record-share family carries a producer-marked `userMes…
fix(rest): the record-share family carries a producer-marked `userMes…
fix(metadata-protocol): front-load the ADR-0110 D3 prescription in th…
fix(formula,plugin-security): the cross-class field-comparison refusa…
test(spec): the second data/ file group's test titles state each cite…