Skip to content

fix(service-analytics,rest): analytics dimension 的源字段闸门 —— 不存在的 dimension 答 400 INVALID_FIELD,dataset 500 不再回显 SQL (#5520) - #5667

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5520-dimension-field-gate
Aug 5, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-5520-dimension-field-gate

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5520

前提重验(对 origin/main,含 #5567/PR #5587、#5526/PR #5634 之后的最新落点)

单子的前提成立,且三条复现都在测试双上原样重现:

  • origin/main 上 assertDimensionFields 仍是零命中;ensureCube() 的三处校验全部只调 assertMeasureFields。
  • 裸 cube 路 dimensions: ["bogus_dim"] → 生成 SELECT bogus_dim AS "bogus_dim", COUNT(*) … GROUP BY bogus_dim,驱动答 no such column,错误对象上 code/status 皆为 undefined(所以在 REST 面落到 5xx 兜底)。
  • dataset 路 → 同一条语句,错误 message 与单子里贴的一字不差:SELECT bogus_dim AS "bogus_dim", COUNT(*) AS "account_count" FROM "crm_account" GROUP BY bogus_dim - no such column: bogus_dim。
  • 「真实但未声明的字段仍可分组」也确认为既有契约:dimensions: ["phone"] 在两条路上都是 200,按 phone 分组。闸门必须不误杀它。

改了什么

1. assertDimensionFields —— 对称 #4437 的 measure 闸门

ensureCube() 的三条出口(自动推断、augmented、已声明)现在都在注册 cube 之前紧跟 assertMeasureFields 调用新闸门。拒收信封与 measure 侧完全相同:INVALID_FIELD / 400,带 field / object / param,外加 dimension 字段(对称 measure 侧的 measure);message 指名字段、列出可用 dimension、附上对象已知字段清单。query / generateSql / queryDataset 三个入口都被覆盖,被拒的查询不会在 cube registry 里留下痕迹(#3867 / #4437 定的同一条规矩)。

取舍(逐条给代码证据)

  • 校验的是「承载对象有没有这个列」,不是「cube 声明过没有」。未声明的裸成员按策略自己会用的列名去核对 —— NativeSQLStrategy.resolveDimensionSql 的回退是 dim ? dim.sql : member,ObjectQLStrategy.resolveFieldName 同形。所以 phone 这类未声明真实字段照常放行(有守卫用例,两条路各一条)。
  • timeDimensions 一并覆盖。它和 dimensions 落进同一个 cube.dimensions 袋子(inferCubeFromQuery 两者都写进去)、被同一个 lookupMember 解析、并且实测同样把 date_trunc('month', bogus_at) 送到驱动 —— 是同一个缺陷的同一个 param 变体,不是新面。param 报告是哪一个请求键出错。这一点比单子正文的字面范围略宽,在此显著申报:若 PM 认为该拆单,删掉 members 数组里 timeDimensions 那一行 + 两条用例即可。
  • 成员解析镜像 lookupMember,包括它故意的最后一档:点号成员若匹配不到任何声明键,就是交给 JOIN 机器的关系穿越,闸门不判(source: null)。否则 owner.region 会被诬告成 crm_account 缺列。
  • 三档 stand-down 与 measure 闸门逐条对齐:cube.sql 不是裸对象名(表达式 cube)、getObjectFieldNames 不作答(无 registry 的宿主 / 外部数据源)、源不是裸列。id / created_at / updated_at 无条件放行,与数据面 resolveQueryFields 一致。
  • 建议列表用减法算,不照抄。自动推断路上 cube.dimensions 就是从这条查询铸出来的,照抄会把调用方的错别字当成「可用 dimension」推荐回去 —— 与 measure 闸门两趟扫描的理由相同。
  • measure 先答。两样都写错时先报 measure(既有信封不动),各自都是真实错误。

2. dataset 面回显生成 SQL —— 扩面申报

回显字符串的构造点不在本仓:knex 的错误 message 格式就是「语句 - 成因」,所以 SQL 是驱动 message 自带的。收窄只能在对外信封处做,于是本 PR 动了 packages/rest/src/rest-server.ts 一行 —— 按派发口径显著申报:

- res.status(500).json({ code: 'ANALYTICS_QUERY_FAILED', error: msg.slice(0, 500) });
+ const outward = looksLikeInternalErrorLeak(msg) ? INTERNAL_ERROR_MESSAGE : msg.slice(0, 500);
+ res.status(500).json({ code: 'ANALYTICS_QUERY_FAILED', error: outward });

这不是新规则,而是补上缺失的那次应用:同一个 analytics 的兄弟面 /analytics/query 走 dispatcher-plugin.errorResponseBase,自 #3867 起就对任何 5xx message 应用共享谓词 looksLikeInternalErrorLeak(packages/types/src/error-leak.ts)—— 这正是单子里复现 ① 读到 "Internal server error"、复现 ③ 却吐出整条语句的原因。本 PR 只补这一处应用:

测试

新增两套,共 30 例:

  • packages/services/service-analytics/src/__tests__/dimension-source-field-gate.test.ts(22 例)—— 结构与深度对称 measure-source-field-gate.test.ts:三条 describe 分别是闸门本体、dataset 面、「闸门不许做什么」。含未声明真实字段仍可分组的守卫用例(裸 cube 路 + dataset 路各一)、registry 不被污染、generateSql 同样受闸、表达式 cube / 点号关系 / 无探针 / 探针答不出四档 stand-down、以及「两样都错时先答 measure」。
  • packages/rest/src/analytics-dataset-dimension-gate.test.ts(8 例)—— 调用方视角:bogus dimension 端到端答 400 INVALID_FIELD 且响应体里没有 SQL;正向对照(声明维度 200 + 未声明真实字段 200);500 面收窄的三例(带语句的 message 被 withhold 且仍进日志、方言错误码同样 withhold、普通内部故障保留原文);以及 4xx 两条分支未被扰动。

反向验证(方向先预测再跑)

方向 A —— 拿掉 ensureCube 里三处 assertDimensionFields:预测 service 套件 10 红 / 12 绿(前两个 describe 里断言「拒收」的用例转红;第三个 describe 全绿,因为它们钉的是本改动保留的旧行为;dataset describe 里的「pre-fix 驱动错误确实带着语句」那一例是对照,前后都绿)。实测:Tests 10 failed | 12 passed (22),方向与条数完全吻合。

同一方向在 REST 套件上出现一次预测偏差,如实记录:预测 3 红,首轮只有 2 红 —— 「响应体里没有 SQL」那一例在闸门被拿掉时仍然绿,因为另一半修复(信封收窄)把驱动 message withhold 掉了,body 于是「无泄漏」却并非因为闸门。这正是「绿得不是因为逻辑对」的那类假绿:该用例已改为同时断言 400 INVALID_FIELD + 无 SQL,重跑得到预测的 3 红。偏差与修法都写进了测试文件头注,连同「本文件吃的是 service-analytics 的构建产物,不重新 build 就改 service 什么也证明不了」这条坑。

方向 B —— 把 500 分支还原成 error: msg.slice(0, 500):预测 REST 套件 2 红(两条 withhold 用例)/ 6 绿,service 套件不受影响。实测:Tests 2 failed | 6 passed (8),吻合。

门与全量

命令 结果
pnpm --filter @objectstack/service-analytics test 52 files / 927 tests passed(基线 51/905,新增 1 文件 22 例)
pnpm --filter @objectstack/rest test 50 files / 745 tests passed
pnpm --filter @objectstack/runtime test(另一条 analytics 面) 97 files / 1428 tests passed
pnpm --filter @objectstack/dogfood test(真实 plugin.ts 探针接线) 85 passed / 1 skipped, 514 tests
tsc --noEmit(两包) 各自与基线同数:service-analytics 7、rest 2 —— 新文件贡献 0(没有照抄兄弟文件 .catch 的 4 条 TS2339 债,改用带类型的 rejection() / settle() 辅助)
check:type-check-coverage / check:nul-bytes / check:route-envelope / check:error-code-casing / check:adr-anchors / check:doc-authoring / check:wildcard-fallthrough 全绿
eslint(改动文件) 无输出

已合入 origin/main(aa25a81d3 / 214f67c76,均在 cli/client,与本 diff 无重叠),合并后两包全量重跑仍绿。

消费半径

闸门只在 getObjectFieldNames 接线时生效;全仓该 hook 的接线点只有生产桥 service-analytics/src/plugin.ts 与三个测试文件(measure 闸门、本 PR 两套)。生产桥那条路由 dogfood 套件覆盖(真 kernel + 真引擎),全绿 —— 说明闸门在 showcase / CRM 的真实 dashboard、report 元数据上不误杀。compiled dataset 的 cube.sql 就是 dataset.object(裸对象名),而关系维度的 dim.sql 是点号路径(dataset-compiler 里 sql: d.field 原样透传),前者让闸门对 dataset 生效、后者被点号档跳过 —— 两者都有用例。


Generated by Claude Code

…s fields, and stop the dataset 500 echoing SQL (#5520)

#4437 gave a measure over a non-existent field a `400 INVALID_FIELD` naming the
field — a driver error class must never be the caller's `error.code` for a
caller-shaped mistake (ADR-0112). It covered the measure half only, so the
identical typo one request key over still reached the driver as a `GROUP BY`
column and came back as `500 SQLITE_ERROR`, while the measure control group on
the same route answered a clean 400.

`ensureCube` now runs `assertDimensionFields` alongside `assertMeasureFields` on
all three of its paths, so a dimension whose source column the backing object
does not have is refused before any SQL exists, with the same envelope
(`INVALID_FIELD`/400 + `field`/`object`/`param`) and a message naming the field,
the valid dimensions and the object's known fields. `query`, `generateSql` and
`queryDataset` are all covered; a rejected query leaves the cube registry as it
found it. `timeDimensions` are covered too — same `cube.dimensions` bag, same
`lookupMember`, same 500 — with `param` naming the key that carried it.

Grouping by a REAL field the cube never declared keeps working: the question is
"does the object have this field", never "did the cube declare this dimension".
Expression cubes, dotted relation dimensions and probe-less hosts stand down
exactly as the measure gate stands down.

`POST /analytics/dataset/query` composed its own 5xx body and echoed the message
verbatim, so a knex `<sql> - <cause>` error handed the caller the generated
statement with its physical table and column names. The sibling face never did:
`/analytics/query` exits through `dispatcher-plugin.errorResponseBase`, which
has applied the shared `looksLikeInternalErrorLeak` predicate to every >= 500
message since #3867. That predicate now guards this route's 500 body too —
status, code, the ADR-0112 envelope branch and the transitional message list all
unchanged, and the full text still reaches `logError`.

Fixes #5520

Co-Authored-By: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 5, 2026 11:32pm

Request Review

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/rest, @objectstack/service-analytics.

15 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/connect-mcp.mdx (via @objectstack/rest)
  • content/docs/api/data-api.mdx (via @objectstack/service-analytics)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest)
  • content/docs/api/index.mdx (via @objectstack/rest, @objectstack/service-analytics)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-analytics)
  • content/docs/permissions/authentication.mdx (via @objectstack/rest)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/service-analytics)
  • content/docs/plugins/index.mdx (via @objectstack/rest)
  • content/docs/plugins/packages.mdx (via @objectstack/rest, @objectstack/service-analytics)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/rest)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/rest)
  • content/docs/releases/implementation-status.mdx (via @objectstack/rest, @objectstack/service-analytics)
  • content/docs/releases/v12.mdx (via @objectstack/rest)
  • content/docs/releases/v17.mdx (via @objectstack/rest, @objectstack/service-analytics)
  • content/docs/releases/v9.mdx (via @objectstack/service-analytics)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang marked this pull request as ready for review August 5, 2026 23:43
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 5, 2026
Merged via the queue into main with commit c36abfe Aug 5, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5520-dimension-field-gate branch August 5, 2026 23:55
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 6, 2026
…由的 message 正则名单完全退休 (objectstack-ai#5808)

* fix(analytics,rest)!: read-scope lowering failures are READ_SCOPE_COMPILE_FAILED/500 and the route's message list is fully retired (objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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.

* chore(spec): regenerate content/docs/references for the new READ_SCOPE_COMPILE_FAILED ledger code (objectstack-ai#5367)

* test(rest): the read-scope boundary double spells `routes`, not the retired `endpoints` (objectstack-ai#5674)

`analytics-read-scope-refusal-envelope.test.ts` was written from a sibling
analytics test before objectstack-ai#5674 landed, so its `getDiscovery` double carried
`endpoints: {}` -- the dispatcher-only copy of `routes` that objectstack-ai#4828 retired under
ADR-0049 and that no producer ever emitted. objectstack-ai#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.

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
objectstack-ai#12280)

`POST /api/v1/analytics/dataset/query` collapsed every producer-declared 5xx
onto a hand-built `500 ANALYTICS_QUERY_FAILED`, where `POST /data/:object`
relays the declared status and ADR-0112 code and withholds only the prose
(objectstack-ai#5582). Measured door-to-door on one error object, a declared
`{ status: 503, code: 'SERVICE_UNAVAILABLE' }` answered `503
SERVICE_UNAVAILABLE` on `/data` and `500 ANALYTICS_QUERY_FAILED` here.

`/data` is the reference and does not move. Its 5xx arm is lifted into
`declaredServerFaultAnswer` and read by both doors, the way the 4xx arm
already imports `classifiedRefusalAnswer` — a third local opinion at this
boundary is how the two faces came to disagree. The sibling analytics face
`/analytics/query` already relayed both halves, so this door was the only
one of three overwriting a producer's declaration.

The prose withhold is untouched (objectstack-ai#5352/objectstack-ai#5367/objectstack-ai#5811) and `logError` still runs
before the relay branch, so a declared status cannot buy a producer past the
operator's log. An undeclared fault keeps `500 ANALYTICS_QUERY_FAILED` and
objectstack-ai#5667's tiering.


Claude-Session: https://claude.ai/code/session_01HbG3rGVLjZStHQxHDtzJdJ

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…tcher exit (objectstack-ai#12281) (objectstack-ai#13240)

`errorResponseBase` gated its 5xx message withhold on `declaresServerFault`
(`status >= 500` AND a non-empty string `code`), while `/data` gates on
`declaredHttpStatus` (`status ?? statusCode`, `code` not consulted). Two bands
of declared 5xx were withheld one door over and shipped their prose here: one
carrying no `code` at all, and one merely spelling `statusCode` despite being
fully ADR-0112-compliant.

Maintainer ruling 2026-08-27 on objectstack-ai#12509 (option D): adopt the structural
withhold for every declared 5xx message, aligning to `/data`. The judgement is
inherited rather than re-derived -- the door now reads `serverFaultProvenance`
from `@objectstack/types`, the same function `demotedDeclaredCode` already
reads for the code channel.

The gate is the DECLARED status, never the resolved `httpStatus`, so objectstack-ai#5667's
undeclared-5xx tiering is preserved: a bare `Error` stays legible and still
goes through the `looksLikeInternalErrorLeak` heuristic alone.


Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…s its native error message verbatim (objectstack-ai#18760)

Fixes objectstack-ai#18540

Clause-②: no

A non-sandboxed crash at `/api/v1/actions` shipped its native error
message verbatim. The identical crash through the `/data` door was
already sanitised. **The status was already right; what leaked was the
sentence** — so only `error.message` moves here. No status code, no
`error.code`, no envelope key.

## Premise 1 — reproduced first, on today's `origin/main`

The card's own status note says the filing seat did not verify the
measurement at the tree, and that the first act is to reproduce it.
Driven through the real `HttpDispatcher.handleActions` door at base
`e77a23f02`, with `mapDataError` driven on the same throw as the
control:

| | `/actions` (before) | `/data` |
|---|---|---|
| `new TypeError("Cannot read properties of undefined (reading 'id')")`
| `500` `INTERNAL_ERROR` — message `Cannot read properties of undefined
(reading 'id')` | `500` `INTERNAL_ERROR` — message `Internal server
error` |

Byte-for-byte the envelope the card recorded. `premise_still_valid:
true`.

## Premise 2 — why objectstack-ai#17273's terminal does not catch it

Confirmed at the tree, not copied from the card. The terminal reads:

```ts
const sandboxCrash = typeof inner === 'string' && inner.length > 0 && isNativeErrorName(inner.trim());
```

`inner` is `err.innerMessage`, which only the QuickJS runner populates.
An in-process registered handler never crosses the VM boundary, so
`innerMessage` is absent, `sandboxCrash` is `false`, and the throw falls
through to `unexpectedFault`.

⭐ The shape, carried into the code comment and the test docblock because
it generalises: **a predicate that classifies by HOW a crash arrived is
structurally blind to crashes that did not arrive that way — while
looking exhaustive.**

## Premise 3 — the width, with controls in both directions

Both doors driven on the same ten throws. Seven shapes leaked at
`/actions` and every one of them was **already** sanitised at `/data`:

| throw | `/actions` before | `/data` |
|---|---|---|
| `TypeError` | leaked the native sentence | `Internal server error` |
| `ReferenceError` | `x is not defined` | `Internal server error` |
| `RangeError` | `Maximum call stack size exceeded` | `Internal server
error` |
| `SyntaxError` | `Unexpected token }` | `Internal server error` |
| driver class, prose the heuristic does NOT recognise | `database disk
image is malformed at /srv/data/tenant_42.db` — a server **filesystem
path** | `Internal server error` |
| sandbox timeout | `action timed out after 5000ms` | `Internal server
error` |
| sandbox capability denial | `... capability 'api.read' not granted` |
`Internal server error` |

Controls in the other direction, unchanged by this PR:

| throw | `/actions` | note |
|---|---|---|
| driver class, leaky prose | already `Internal server error` | the 5xx
heuristic already fired |
| plain `Error` — a deliberate rejection | `400` + its own sentence |
the refusal channel, untouched |
| `TypeError` carrying `status: 403` | `403 FORBIDDEN` + `Not allowed` |
a declared answer, untouched |

⇒ the leaking population is **exactly** the `unexpectedFault` branch's
population, at one site. The other dispatcher doors (`/meta`, `/mcp`)
never make this classification at all, so their undeclared-5xx prose is
objectstack-ai#5667's recorded decision rather than this defect — noted below, not
filed.

## The fix, and why it is not the heuristic

The two doors differ **structurally**, not by one missing phrasing:

- `/data` is **default-DENY**: `classifyDataError` ends in an
unconditional `UNCLASSIFIED_FAULT()`. Its `looksLikeInternalErrorLeak`
limb only chooses `DATABASE_ERROR` over `INTERNAL_ERROR` — that limb is
not what sanitises.
- the actions door was **default-ALLOW**: `unexpectedFault` relayed
`err.message` through a 5xx withhold gated on
`looksLikeInternalErrorLeak`, a driver-dump heuristic that reads FALSE
for stack-shaped prose.

`unexpectedFault` **is** this door's unclassified-fault terminal, so it
now answers the terminal's envelope — `INTERNAL_ERROR_MESSAGE`, through
the same `deps.error` seam objectstack-ai#17273's terminal one branch above already
uses.

Both fences held:

- ⛔ `looksLikeInternalErrorLeak` is **not** relaxed or re-pointed at
stack-shaped prose. It guards a different question at every other
boundary.
- ⛔ `DomainHandlerDeps.error`'s seam is **not** widened. A fault's
`userMessage` and its non-string `details.code` do not ride this exit,
exactly as they do not ride `/data`'s — the same accepted difference
objectstack-ai#17273's terminal already carries.

**The status provably does not move.** Reaching this branch already
proves `.status`/`.statusCode` are absent (the branch above serves them)
and that this is not a `ValidationError`. So in `resolveThrownHttpError`
the `declaredStatus` chain is empty, `status` is the `500` fallback, and
`code` is `standardErrorCodeForHttpStatus(500)` = `INTERNAL_ERROR` — the
code this exit emits. The STOP-AND-REPORT condition did not trigger: no
declared status code or envelope shape changes.

## Tests

`packages/runtime/src/domains/actions-fault-vs-rejection.test.ts` — the
file that already drove this exact door:

- the pre-existing `a TypeError from a buggy handler` pin used the
card's exact message and asserted status, `success` and `data` but
**nothing about `message`** — so the live leak sat green underneath it.
"Still 500" is exactly what the defect looked like. That pin now asserts
the sentence too.
- a new section adds both halves: two crashes whose prose the heuristic
does not recognise, and the two controls one property away on either
side (a rejection keeps its `400` and its words; a crash that declared
its own status keeps both).

⚠️ **Tier measured first, in both directions, before and after.**
`packages/runtime` has two vitest projects and `vitest.repo-tests.json`
names exactly two files. The repo's own filter preflight answers:
before, `local` ran the file (17 passed) and `repo` reported `FILTER
SELECTED NOTHING ... lives in the local project`; after, `local` runs 21
and `repo` still selects nothing. The file did not move, and its 17
existing pins did not move with it. The `/data` parity is therefore
asserted in **prose** rather than by importing `@objectstack/rest` into
this file — a cross-package import is what would have moved it.

Measured (`scripts/pm/os-verify-lock.sh`, shared box):

| run | result |
|---|---|
| `pnpm --filter @objectstack/runtime test` (project `local`) | 265
files, **3665 passed**, 1 skipped — exit 0 |
| `pnpm --filter @objectstack/runtime test:repo` | 2 files, **69
passed** — exit 0 (holds `error-envelope.conformance.test.ts`) |
| `pnpm --filter @objectstack/runtime typecheck` | exit 0 |
| `pnpm --filter '@objectstack/runtime^...' build` | exit 0 |
| `pnpm lint` — repo-wide `eslint . --no-inline-config`, full
population, no narrowing | exit 0 |

### Ablation — the pin is shown to go red when the leak is put back

Fix committed first, so a restore point existed. Mutation **proven on
disk before the run**, not from an editor's exit code: the target blob
moved `5aa9ba6dd…` → `a0178be61…`, the leak anchor went 1 → 2 and the
fixed anchor 2 → 1, at a printed offset.

⚠️ Recorded because it is the point of the proof: the **first** attempt
was a silent no-op — a `perl -0777` one-liner died on a quoting error,
changed nothing, and the anchor/blob check refused the run rather than
reporting a green. The reading below is from the second, verified
mutation.

With the leak restored: **3 failed | 18 passed**, the three failures
being exactly the disclosure assertions, carrying the real leaked
strings —

```
AssertionError: expected 'Cannot read properties of undefined (…' to be 'Internal server error'
AssertionError: expected 'database disk image is malformed at /…' to be 'Internal server error'
```

The four negative controls stayed green, which is what distinguishes
this fix from a blanket sweep that would have eaten the refusal channel.
Restore verified **by blob hash** — back to `5aa9ba6dd…`, `git diff
HEAD` empty — ⛔ not by an exit code, under a `trap ... EXIT INT TERM` on
an absolute path.

### Gates

Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`. The first derivation reported a **STALE
TREE** (3 commits behind, and 2 files it derives from had changed);
`origin/main` was merged and the list re-derived from the merged tree —
byte-identical, 59 commands. **57 of 59 exit 0.** Reconciled with every
exit code recorded.

Also run because their `silent` verdict is a fact about a roster, not
about these paths — 5 of them keep that roster in a directory one of
these paths is in: `check:route-ledger-census`,
`check:error-code-casing`, `check:authz-resolver`,
`check:filter-alias-parity`, `check-changeset-fixed`, plus
`check:error-status-conformance` and `check:dispatcher-error-vocabulary`
as the two adjacent to this change. All exit 0 — the first confirms no
status row moved, the second that the `error.code` vocabulary is intact.

⚠️ `pnpm lint` is run although `dispatch-gates.mjs` does not name the
family — this lane's standing blind spot.

**NOT MEASURED, declared rather than counted as a pass:**

- `pnpm check:dual-build-cjs-loads` — `exit 3`, `PREREQUISITE NOT MET
... some package has no dist/`, naming ~36 unbuilt packages and `Run
pnpm build first`. A whole-farm build; CI's `Build Core` + `Lint & Repo
Gates` is where it is measured. ⛔ Not a pass and not a red.
- `pnpm check:type-check-debt` first answered `exit 3` for the same
class, naming one unbuilt dependency at a time. Both were built
(`@objectstack/runtime`, then `@objectstack/driver-mongodb`) and it now
reports `--re-measure: OK — 4 ledger entr(ies) re-measured, 53 raw tsc
error(s) total, none above its recorded number` — so this one **is**
measured.

### Docs-drift rider

Predicate stated **before** reading: *a document states, about the
`/api/v1/actions` door, that an unexpected fault's client-facing message
is or contains the thrown error's own text.* Swept by symbol
(`unexpectedFault`, `errorFromThrown`, `INTERNAL_ERROR`, crash) **and**
by input shape (the wire body, and the leaked sentences themselves).

**NOT FALSIFIED.** `content/docs/api/error-catalog.mdx`'s Action Errors
row declares the status and the envelope — `Handler crashed (TypeError,
driver error, sandbox timeout)` → `500` → `{ success: false, error: {
message, code } }` — never that `message` carries the thrown text; and
its `INTERNAL_ERROR` entry already reads *"An unexpected server-side
error occurred. Report the issue with the requestId and traceId"*, which
is what a withheld sentence means. Controls both ways: the same table's
rejection rows **are** statements about the message channel, were found
by the sweep, and describe branches this PR does not touch; the three
`content/docs/**` hits for the native sentences are authoring-time
validator prose, correctly not implicated.
`content/docs/protocol/kernel/http-protocol.mdx`, which `main` moved
during this change, was re-swept and says nothing about this surface.

One in-repo statement **was** falsified and is corrected here:
`packages/runtime/src/sandbox/capability-denial-is-a-fault.test.ts` said
of a capability denial that *"what the client sees is this text, minus
the prefix"*. Measured — that denial reaches `unexpectedFault`, so after
this change the client sees `Internal server error`. Its assertions are
all about the **thrown** error and are unchanged; only the justification
is corrected to name the log as their subject.

## Changeset

`patch` on `@objectstack/runtime`. ⛔ Not `skip-changeset`: the package
publishes (`files: ["dist", ...]`) and the wire answer of a published
HTTP surface moves. `Clause-②: no` — the diff adds or removes no schema
key, closed-set member, published export or registry row, and an
unexpected fault's message text is not a contract. Verified with the
repo's own `readClause2Line`, ⛔ not a hand-written regex:
`{"kind":"declared","value":"no","arm":null,"line":"Clause-②: no"}`,
re-checked against the newer copy of that reader after the merge.

## Acceptance notes

Out of scope for this card, ⛔ not filed and ⛔ not fixed here:

- **The two doors still disagree on STATUS for a missing-relation
throw.** A `SqliteError('no such table: crm_invoice')` answers `500
INTERNAL_ERROR` at `/actions` and `404 OBJECT_NOT_FOUND` at `/data`,
whose classifier has a missing-relation attribution arm the dispatcher
has no equivalent of. Measured here as a by-product. This is a
**status** difference, and this card is explicitly fenced from moving a
status. Successor: none of the open cards named in the dispatch faces
it.
- **The undeclared-5xx band at the other dispatcher doors.** `/meta` and
`/mcp` hand everything to `errorFromThrown(e, 500)` without ever
classifying a crash, so a `TypeError` there also ships its prose. That
is not this defect: it is objectstack-ai#5667's recorded decision to keep undeclared
5xx legible, guarded only by `looksLikeInternalErrorLeak`, and
`resolveThrownHttpError`'s own docblock records objectstack-ai#12281 as the separate
card for the *declared* limb with its own measurement-first step.
Changing it would reverse a recorded decision. Successor: objectstack-ai#12281 for the
declared half; the undeclared half has no card and is a decision, not an
execution.
- **A phantom-risk note, not a defect.** `deps.error`'s seam carries no
`extra`, so this exit cannot pass `userMessage` even if a fault declared
one. Reachability is near-nil (a fault that also declares user-facing
text), the card fences the widening that would close it, and `/data` has
the same gap — recorded so the next reader does not mistake it for an
oversight.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…eOrLoud`, so a wired-and-failing engine answers 503 on every wiring (objectstack-ai#18805)

Fixes objectstack-ai#18559

Clause-②: no

`objectQLProvider`'s THIRD consumer — `POST /api/v1/batch` — now reaches
the data-engine seam through `wiredEngineOrLoud`, the same helper its
two sibling consumers already use. A wired-and-failing engine answers
`503 SERVICE_UNAVAILABLE` instead of the `500 INTERNAL_ERROR` the
handler's generic outer `catch` used to produce.

## The card's reading REPRODUCES — and narrows

Driven at the real door on a real `RestServer` over a real
`ObjectKernel` (⛔ not inferred from the card's prose, ⛔ not from a unit
double), the 500 is there. But it is reachable on ONE of the two wirings
only, and the card's 503/503/500 table silently mixes wirings:

| wiring, engine wired and FAILING | before | after |
|:--|:--|:--|
| single-kernel — the composition the open core boots | 503
`SERVICE_UNAVAILABLE` | 503 — unchanged |
| multi-kernel — a `kernelManager` is wired | **500 `INTERNAL_ERROR`** |
**503 `SERVICE_UNAVAILABLE`** |

On the single-kernel wiring `computeExecCtx` resolves the engine through
its OWN `wiredEngineOrLoud` branch and raises before the batch handler's
engine line runs, so this door already answered 503. The 500 was
reachable only where that gate's KERNEL branch absorbs by design
(`wiredEngineOrLoud`'s RESIDUE note) and hands the engine question down.
This is the same narrowing PR objectstack-ai#17077 had to make for the sibling
`/meta/object/:name/state/:field` door, and for the same structural
reason.

⇒ **the repair does not choose a new wire answer for this door.** It
removes a wiring-dependent divergence, leaving the answer this door
already gave on the composition the open core boots. §2b of the census
test measures both wirings side by side so that sentence is a reading
rather than an argument.

## Why this is NOT a re-collapse, and NOT a regression

The two facts always differed on the wire (500 against the 501 an absent
engine gets), so the decidable test objectstack-ai#14251 tightened — *"NO consumer
re-collapses a rejection into the `undefined` path"* — was already
satisfied at this consumer. What was wrong is that they differed through
a catch-all that knows nothing about this seam. The adjacent `501
NOT_IMPLEMENTED` arm tests `!ql || typeof ql.transaction !==
'function'`, which a rejection never reaches.

## The pin: fails before, passes after — both readings

`packages/rest/src/objectql-slot-consumer-census.test.ts` recorded the
500 explicitly as *"RECORDED here, not ruled"*. That is the pin that
moved, and the ablation is shown in the PR thread: with the test file at
this branch and `packages/rest/src/rest-server.ts` reverted to the merge
base, the new assertions FAIL; restored, they pass. ⛔ No test was
skipped, disabled or quarantined.

Three things are pinned UNCHANGED, because they are what says no accept
set moved:

- both ABSENCE shapes still answer `501 NOT_IMPLEMENTED`, on BOTH
wirings — no provider wired at all, and a provider that RESOLVES
`undefined` (the seam contract declaring absence rather than failing);
- the fault MESSAGE is still withheld — `Internal server error`,
`INTERNAL_ERROR_MESSAGE`, asserted plus a negative assertion that the
driver's own sentence never reaches the wire;
- a non-`async` provider that throws SYNCHRONOUSLY reaches the same
answer as one that rejects (objectstack-ai#13280).

## Controls, including the one that came back dark

- §1's window predicate is controlled on BOTH axes: it must read 0 for
`emailServiceProvider` + `wiredEngineOrLoud` and still FIRE (1) for
`emailServiceProvider` + `seamOrUndefined`, the helper that slot really
uses. Without the second half "3 of 3" is the only sentence the
instrument can produce.
- ⚠️ **A dark control, declared rather than hidden.** On the
MULTI-KERNEL wiring a batch request carrying a real operation continues
past the engine line into `resolveProtocol`/`loadObjectItems`, which the
harness's auth-only kernel cannot serve — measured, a HEALTHY engine
answers 500 INTERNAL_ERROR there too. A 500 read on that wiring with a
real op is therefore ambiguous between "the seam answered" and "the
harness ran out of kernel". The healthy control uses `{ operations: []
}`, which returns inside the door after the engine line and before the
protocol is touched, so the engine fact is the only variable. §2b adds
the control the multi-kernel harness cannot give: on the single-kernel
wiring a healthy engine SERVES the same real operation with 200.

## The `/actions` precedent does NOT transfer — measured, not assumed

PR objectstack-ai#18760 (card objectstack-ai#18540) was handed over as the precedent for this
family. It is a different defect on a different axis, in a different
package:

- objectstack-ai#18540 is a MESSAGE leak: *"the status is already right here; what
leaks is the sentence."* Its remedy withheld prose. Here the message is
ALREADY withheld — `INTERNAL_ERROR_MESSAGE` is on the wire today — and
what moves is the STATUS.
- Its mechanism (`classifyDataError`, `looksLikeInternalErrorLeak`,
`errorFromThrown`, `UNCLASSIFIED_FAULT`, `deps.error`) lives in
`packages/runtime/src/domains/actions.ts`. None of it is on this door's
path: `POST /api/v1/batch` is `packages/rest/src/rest-server.ts`
reaching the wire through `handleRouteError` → `resolveErrorResponse`.
- The `errorFromThrown` branch that must survive (an error declaring its
own HTTP status is served with it) is untouched here: nothing in this
diff is in that file, and the branded `AuthzStoreUnavailableError` this
repair raises IS an error declaring its own status — it is served with
it, which is exactly how the 503 reaches the wire.

The precedent that DOES transfer is objectstack-ai#15405 / PR objectstack-ai#17077 — same slot, same
file, same helper, the sibling consumer — which the card itself names as
the pattern to copy.

## Clause ② — declared `no`, from this diff

`Clause-②: no` above is derived from the delivered diff, not inherited:

- **No new error code.** `SERVICE_UNAVAILABLE` is an existing
`StandardErrorCode`, already in
`packages/spec/src/api/error-code-ledger.zod.ts` mapped to 503, and
already emitted by the sibling door for this same fact. `AGENTS.md` and
the PM protocol are explicit that a NEW code is always `yes`; this is
not one.
- **No new export, no new payload key, no new envelope.** The 503 wears
the same flat `{ error, code }` shape the 500 wore.
- **Nothing that was refused becomes served**, and nothing that was
served becomes refused: both sides are faults, and both absence shapes
are pinned unchanged.
- **It is a pull-back to a DECLARED contract** — `wiredEngineOrLoud`'s
own table and `AUTHZ_STORE_UNAVAILABLE_STATUS = 503`. The PM protocol
states that pulling back to an already-declared contract does not touch
clause ②: 「条款②只指已发布契约面,拉回已声明契约不触它」.
- The arm slot is deliberately EMPTY. Neither `(widening)` nor
`(narrowing)` is truthful here: no accept set widens, and nothing that
was accepted is now rejected. `no (widening)` is malformed by
construction, and a fabricated `(narrowing)` would carry a false
BREAKING declaration against a `patch` changeset.

This is the same declaration PR objectstack-ai#17077 landed and its acceptance
confirmed for the identical move at the sibling consumer (404 → 503 on a
published route), on the same three grounds.

⚠️ **Declared conflict, stated rather than quietly resolved.** The
dispatch fence for this card said that if the remedy moves a status code
it should ship as `minor` with a BREAKING banner and an ADR-0087
disposition. Measured against `AGENTS.md`'s own arms, that spelling has
no truthful form here: `minor` + BREAKING is what a declared
`(narrowing)` owes, and this diff narrows nothing. The changeset is
`patch` per `AGENTS.md`: *"A bug fix in a released package takes a
`patch` changeset — never none, and ⛔ never `skip-changeset`"*.
`@objectstack/rest` publishes (`files: ["dist", "README.md",
"CHANGELOG.md"]`, not private), and the changed door is in the published
bundle — so `skip-changeset` is refused on a measurement, not on the
file's look.

## ⚠️ The one thing this PR does not carry: a maintainer's word on THIS
card

Stated plainly because it is the half a reviewer should decide, and this
PR is a draft for exactly that reason.

The card reserves a per-consumer wire ruling: *"A public door changing
its wire answer is the per-consumer wire ruling objectstack-ai#14251 reserves … and
the ruling half is nobody's to assume."* For the sibling card objectstack-ai#15405
that ruling was obtained explicitly — it sat at `pm:awaiting-maintainer`
until the maintainer's batch move, quoted verbatim on that thread with
the criterion 「恢复一条既有修复的不变量,属具名不升级类,⛔ 无产品语义分叉」. **No equivalent
maintainer act exists on objectstack-ai#18559**: triage moved it straight to
`pm:queue`.

What this delivery offers in its place is a reading the card's filer did
not have, and it is why the work was written rather than handed back:
**this door already answers 503 for this fact on the composition the
open core boots.** That reframes the remedy from "pick a wire answer for
a public door" to "remove a divergence between two compositions of one
server". If a reviewer weighs it the other way, the revert is one commit
and the PR is a draft.

⛔ Not widened into the undeclared-5xx band at `/meta` or `/mcp` — that
is objectstack-ai#5667's recorded decision with objectstack-ai#12281 as the card for the declared
limb, and reversing a recorded decision is a decision, not an execution.

## Acceptance notes

- *noted, not filed:* on the MULTI-KERNEL wiring, a batch request whose
ops survive the engine probe answers `500 INTERNAL_ERROR` when the
resolved kernel carries no metadata protocol, because
`resolveProtocol`/`loadObjectItems` fault under the generic outer catch.
Observed only in this file's harness (an `ObjectKernel` with `auth` and
nothing else), which is not a supported deployment shape — a real
multi-kernel host registers the protocol. ⛔ Not a reproducible defect
against a supported wiring, so it is not one of the three filing
classes; recorded because it is what makes a naive 500-based control on
that wiring ambiguous, and the next reader of this harness will meet it.
Successor who would touch this file: whoever extends
`objectql-slot-consumer-census.test.ts` for a FOURTH consumer.
- *noted, not filed:* the batch handler calls
`this.resolveExecCtx(environmentId, req)` without the
`.catch(rethrowAuthzStoreUnavailable)` that many sibling call sites in
`rest-server.ts` carry. Measured, it needs none: `computeExecCtx`
re-raises the branded outage from its own `catch` and this handler's
outer `catch` serves it with its declared status — which is how the
single-kernel 503 above reaches the wire. Observation, not a defect, and
⛔ not touched here.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3)_

Co-authored-by: Claude <noreply@anthropic.com>
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/xl tests tooling

Projects

None yet

2 participants