fix(analytics,rest)!: read-scope 拒收改判 READ_SCOPE_COMPILE_FAILED/500,路由的 message 正则名单完全退休 - #5808
Merged
Merged
Conversation
…PILE_FAILED/500 and the route's message list is fully retired (#5367) `read-scope-sql.ts`'s ten fail-closed refusals were the last error family `/analytics/dataset/query` classified by matching their message text. That verdict (400 DATASET_INVALID) was wrong twice: the inputs are an admin-authored RLS policy and a compiler-generated join alias, never the caller's, and the 400 echoed the refusal message, handing a tenant the field names and comparands of the policy governing them. Per the maintainer's 2026-08-06 ruling (option B on #5367's decision card): - read-scope-sql.ts gains a module-local `readScopeCompileError` -- the twin of filter-normalizer's `invalidFilterError`, and likewise the only way the module refuses. All ten sites declare READ_SCOPE_COMPILE_FAILED / 500. The :104 alias-vs-field split (option C) collapses under B and is pinned as collapsed. - rest-server.ts loses branch (2) entirely. Nothing in that catch reads prose any more; Prime Directive #12's retirement schedule is paid off in full. - The 5xx branch withholds the message of any producer that DECLARES a server fault. Needed rather than inherited: `looksLikeInternalErrorLeak` is a heuristic over SQL/driver phrasing and, measured, returns false for all ten read-scope messages, so retiring the list alone would have moved the policy content from a 400 body into a 500 body. Widening that heuristic would have been more message sniffing; the rule keys on the ADR-0112 envelope instead. Undeclared 5xx keeps #5667's tiering and stays readable. - READ_SCOPE_COMPILE_FAILED registered in ERROR_CODE_LEDGER under @objectstack/service-analytics, typed as RegisteredErrorCode at the site. Visible behaviour change: this family answers 500 instead of 400, with its message withheld from the body and intact in the log.
…E_COMPILE_FAILED ledger code (#5367)
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 113 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…etired `endpoints` (#5674) `analytics-read-scope-refusal-envelope.test.ts` was written from a sibling analytics test before #5674 landed, so its `getDiscovery` double carried `endpoints: {}` -- the dispatcher-only copy of `routes` that #4828 retired under ADR-0049 and that no producer ever emitted. #5674 corrected the 26 existing doubles and added `discovery-double-retired-key.test.ts` to pin the key out of the fixture layer; this file arrived after that sweep and reintroduced it. Corrected to the producer's real shape -- `routes: { data: '', metadata: '' }`, `ApiRoutesSchema` as `DiscoverySchema` requires it -- matching the three sibling analytics doubles verbatim. The key was inert here (nothing in this file drives discovery), which is exactly the reason it kept surviving retirement, so this is the double being made faithful to its producer rather than a guard being satisfied.
os-zhuang
marked this pull request as ready for review
August 6, 2026 06:57
This was referenced Aug 6, 2026
baozhoutao
pushed a commit
that referenced
this pull request
Aug 6, 2026
CI 在 b8433ca 上判红:@objectstack/rest 记 136,实测 140。原因不是量错了 —— `pull_request` 运行编译的是「分支 merge 进当前 main」的树,而 sweep 之后 main 又落地了三个动 packages/rest 的 PR(#5808 / #5821 / #5806)。合并 main 后重量 得 143,tests 56 -> 58,其余 33 条纹丝不动。 这个竞态是引导期的一次性成本,不是常态:本不变式上了 main 之后,引入错误的那个 PR 自己会红 —— 这正是它的目的。写进 MEASURED 的文档块和 rest 的 note,下一个做 全量重测的人不必再自己发现一遍。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01559M8FVm6W6vDLABL3jvdW
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5367
第二个也是最后一个 PR。前一个(PR #5723,已 merge)信封化五族并把正则名单收到只剩
/read-scope-sql/一条;本 PR 按**维护者 2026-08-06 裁决(方案 B)**处理第六族并删掉那最后一条 —— PD #12 的退休时间表至此完整兑现,/analytics/dataset/query的 catch 里不再有任何按措辞分类的代码。前提重验
十处拒收在 origin/main 上仍是裸
throw new Error(...),现位置 104 / 138 / 144 / 190 / 209 / 217 / 262 / 267 / 272 / 284;rest-server.ts:6829的/read-scope-sql/条目原样在。上一个 PR 的五族信封与dataset-refusal.ts已在 main(978fed281)。前提成立。派发单第 2 条写「该 500 进入 #5667 的
looksLikeInternalErrorLeakwithhold 路径」。实测:不会进。 那个谓词是对 SQL/驱动措辞的启发式(sqlite_、SQLSTATE、以语句开头、约束转储),而 read-scope 的全部十一种 message 形态跑下来全部返回 false:也就是说:只删名单会把 RLS 策略内容从 400 的 body 搬到 500 的 body,而不是搬出响应。而裁决把「结构性堵泄漏」列为批准方案 B 的依据之一,所以这个验收标准必须真的成立。
两条路:把启发式教会认
[read-scope-sql]—— 那是更多措辞嗅探,正是本单要消灭的机制;或者让 withhold 依据声明。取后者:生产方声明status >= 500且带code,就是声明了「这是服务端故障」,而服务端故障的细节属于运维日志而非调用方 body。 这是对 ADR-0112 信封的结构规则,不是对散文的猜测,而且未声明的 5xx 仍走 #5667 的分级(自产故障 "no strategy can handle query …" 依旧可读)—— 两侧都有用例钉住。顺带说明这条规则的范围:它是加在本路由 ③ 分支内的三行判断,没有抽成共享谓词。姐妹面
/analytics/query经dispatcher-plugin.errorResponseBase走同一个启发式,因此同样在泄漏;但那是本单之前就存在、且在另一条路由上的既有缺陷,按 PD #10 单独立单(见下),不夹带进本 PR。等第二个边界采用这条规则时再考虑提升到@objectstack/types(与looksLikeInternalErrorLeak同居),现在提升就是没有消费者的推测性面。做了什么
read-scope-sql.ts新增模块内的readScopeCompileError(READ_SCOPE_COMPILE_FAILED/ 500)。放模块内而非共享模块,证据同filter-normalizer.ts的invalidFilterError:十个拒收点全在这一个文件里,共享模块买不到任何东西,而第二种写法会让「⛔ 本模块唯一的拒收方式」这个不变量失效 —— 这个不变量正是 analytics 的 filter 拒收到不了调用方:service 侧多数拒收没有 ADR-0112 信封,REST 面又用 message 正则嗅探,一律答 500 #5352 用七个裸站点换来的教训。:104的 alias/field 分叉随 B 取消:两支同答一个 500,并钉成「已取消」(用例断言两行产出同一 code/status),所以将来若要重新区分必须改那一行 —— 取消是记录在案的决策,不是遗漏。READ_SCOPE_COMPILE_FAILED入ERROR_CODE_LEDGER的@objectstack/service-analytics(ADR-0112 D3),构造器处标注RegisteredErrorCode,未登记即编译错误。这次 union 确实增长(与上个 PR 的DATASET_INVALID不同,那是既有 code),因此content/docs/references/**需要重新生成 —— 已按check:generated --fix只重生这一项,单独一个 commit,diff 纯粹是枚举里多一个成员。INTERNAL:本路由 ③ 分支会把 code 覆写成ANALYTICS_QUERY_FAILED,所以在这条路上 code 不上线;但姐妹面errorResponseBase会把err.code放进error.details.code(The dispatcher puts the HTTP status inerror.codeand parks the real code indetails— pinned in #3687, still unfixed #3842),所以READ_SCOPE_COMPILE_FAILED在那里是机器可读的,加上日志侧的运维分诊 —— 有真实读者,不是装饰。既有用例逐条改判(不批量放宽)
READ_SCOPE_COMPILE_FAILED/ 500,并加一条负向断言not.toBe('DATASET_INVALID')。同段的「内部不变量仍是裸 Error」一例保留且加注:它与 read-scope 的差别现在是可观测的 —— 声明 5xx 的被 withhold,未声明的按 fix(service-analytics,rest): analytics dimension 的源字段闸门 —— 不存在的 dimension 答 400 INVALID_FIELD,dataset 500 不再回显 SQL (#5520) #5667 保持可读。analytics-filter-refusal-envelope.test.ts保留的那一例(靠名单答 400)→ 改判成两例:声明 500 → 500 + body 不含策略字段名;同一条 message 裸抛 → 也是 500 且可读。后者钉住「名单真的没了」,前者钉住「withhold 真的生效」。文件头「什么绝不能变」的第 1 条(名单还在分类)改写为「已退休」。analytics-dataset-dimension-gate.test.ts新增的 ③ 半 → 从 400 改判为 500 +INTERNAL_ERROR_MESSAGE,并把该半的守护目标写清:[17.0-rc2验收] analytics: 不存在的 dimension 500(泄漏 SQL / SQLITE_ERROR)而不是 400 指名字段 —— #4437 只给 measure 加了闸门,dimension 侧对称缺口仍在 #5520 的启发式 withhold 与本单的声明式 withhold 是复合而非互斥。用例名由「4xx branches」改为「classification branches」。WAREHOUSE_UNAVAILABLE/503 那一例(原本只断言 status+code)→ 补expect(res.body.error).toBe(INTERNAL_ERROR_MESSAGE)。它现在是这条声明式规则的通用形态用例:规则适用于任何声明的 5xx,不是一张认得的措辞清单。新增覆盖
packages/services/service-analytics/src/__tests__/read-scope-refusal-envelope.test.ts(28 例)—— 两段式:先钉拒收集合没动(11 个输入 × 十个抛出点,quoteIdent两个 kind 各一行;外加 6 个「必须仍然编译」的 read scope,连编出的 SQL 与 bind 参数一起断言),再钉信封;另有「无半信封」结构断言、「alias/field 同判」断言,以及一条把泄漏当输入的断言 —— message 确实含策略字段名(运维需要),这正是边界必须 withhold 的理由。packages/rest/src/analytics-read-scope-refusal-envelope.test.ts(8 例)—— 五种策略形态端到端穿过真实AnalyticsService(getReadScope就是管理员策略进来的那个缝),每例断言:500 /ANALYTICS_QUERY_FAILED/not.toBe(400)/not.toBe('DATASET_INVALID')/ body 是Internal server error/ body 不含该策略细节 / body 没有 4xx 的message字段 /logError里有完整原文和该细节(「withheld」只有在运维仍拿到全文时才可接受,所以断言而非假设);外加正面控制(可编译的 read scope 仍然 200 且真的加了 scope)、未声明 5xx 仍可读、声明 4xx 仍走信封分支。反向验证(方向先预测,后实测)
方向 A —— 把十处改回裸
throw new Error(...)并重建 service-analytics(路由保持无名单):ANALYTICS_QUERY_FAILED仍成立(名单已删,自然落 ③),但declaredServerFault为假且启发式不匹配,于是 message 被回显;两条 tiering 用例与正面控制保持绿;另外三个 rest 文件不受影响(自造错误)。Tests 15 failed | 31 passed (46),断言原文expected undefined to be 'READ_SCOPE_COMPILE_FAILED'、expected [ undefined, undefined ] to deeply equal [ 'READ_SCOPE_COMPILE_FAILED', 500 ];restTests 5 failed | 40 passed (45),断言原文expected '[read-scope-sql] unsafe field identif…' to be 'Internal server error'。与预测一致。 这条实测本身就是上面那个前提更正的证据:声明一撤,策略内容立刻回到 body。方向 B —— 把
/read-scope-sql/正则加回去(生产方保持信封):Tests 8 failed | 45 passed (53),断言原文expected 400 to be 500,五族文件1 passed。与预测一致,包括那个不对称的反转:名单删除现在由端到端用例直接守护,不再只靠一条伴生断言。两个方向跑完均已
git checkout复原并重新验证全绿。(过程记录一笔:方向 A 复原时我对尚未提交的文件用了git checkout --,把该文件的改动清掉并重做了一遍 —— 后续先提交再做方向 B。)验证
pnpm --filter @objectstack/service-analytics test→Test Files 57 passed (57)/Tests 1039 passed (1039)pnpm --filter @objectstack/rest test→Test Files 56 passed (56)/Tests 791 passed (791)pnpm --filter @objectstack/spec check:generated→ 10/10 up to date(gen:docs已按--fix单项重生并单独 commit)pnpm --filter @objectstack/spec exec tsc --noEmit→ 通过check:nul-bytescheck:route-envelopecheck:error-code-casingcheck:adr-anchorscheck:role-wordcheck:doc-authoringcheck:docs-audit-scopecheck:org-identifiercheck:authz-resolvercheck:service-providerscheck:wildcard-fallthroughcheck:init-service-contractcheck:durability-log-levelcheck:startup-registry-verdictcheck:release-notescheck:node-versioncheck:published-filescheck:engine-double-contractcheck:resume-authority-declaredcheck:slot-lookupcheck:query-options-erasurecheck:type-check-coveragecheck:objectui-changesetcheck:workflow-status-functionscheck:driver-conformancecheck:i18ncheck:i18n首跑九包齐报extract failed — no output;定位为它调用packages/cli/bin/run.js而本 worktree 未建 CLI dist(九包一致失败即该签名),建完@objectstack/cli...后9 package(s) — all bundles in sync。本 PR 不含任何 translation 改动。node scripts/check-nul-bytes.mjs→ OK(5681 文件);另按纪律grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f]'自扫本 PR 全部改动文件 → cleannpx eslint覆盖本 PR 全部改动文件 → 无输出check:type-check-coveragePASS;两个 DEBT 包直接量tsc --noEmit:@objectstack/rest2 errors(与 ledger 记的 2 一致)、@objectstack/service-analytics7 errors —— 均与改动前同数,且无一条落在本 PR 的文件上origin/main(1624f4ad2)后按 §9/§10 refresh build state 并重跑上述全部范围外发现(已按 #4949 先搜重)
/analytics/query仍把 RLS 策略字段名回显给调用方 —— read-scope 拒收的泄漏在姐妹面上没堵,#5367 只堵了 dataset 路由 #5811 —— 姐妹面/analytics/query经errorResponseBase用同一个启发式,因此同一批 read-scope 拒收在那条路由上仍然把 RLS 策略字段名回显给调用方(11/11 实测 ECHOED)。本 PR 的声明式 withhold 只加在 dataset 路由的 ③ 分支;那条路由是本单之前就有的既有缺陷,按 PD chore: version packages #10 单独立单,并在单里提议把这条规则提升到@objectstack/types与looksLikeInternalErrorLeak同居。gen:schemarmSync 整个json-schema/会顺手抹掉gen:openapi的产物,rest 的 openapi 路由测试随后 503 假红——check:generated原地跑 build-schemas 也触发 #5371 ——pnpm --filter @objectstack/spec check:generated跑绿的同时删掉packages/spec/json-schema/openapi.json,随后@objectstack/rest有 8 例 openapi 用例假红成 503。本轮又复现两次;gen:openapi重跑即恢复,与本 PR 无关。packages/spec的任何一次build/gen:schema都会把packages/spec/authorable-surface.base.json的baseRev重锚到当前 merge base(该文件的 docstring 说明这是它的设计行为)。本 PR 每次都把它git checkout还原 —— 那不是本任务的改动,CI 全新 checkout 也不受影响。