Skip to content

fix(rest): 清扫 REST 组合根的槽查找 —— 16 处类型化,契约由 implements 背书 (#4251 B4) - #5953

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-4251-slot-sweep-b4
Aug 6, 2026
Merged

os-zhuang merged 2 commits into
mainfrom
claude/issue-4251-slot-sweep-b4

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Part B4 of #4251 —— REST 组合根批次。本 PR 不 Close 本单,B5–B11 批次留后续。

棘轮基线 159 → 143 站点 / 34 → 32 文件,两个文件全清并退出祖父名单,无新增 key(baseline key set verified against 9ce0ca9: no files added)。无行为变更。

站点分布(实测 16 处,比派发单的 15 处多 1)

派发范围写的是 rest-api-plugin.ts 14 + external-datasource-routes.ts 1;基线文件记的是 15 + 1,实测也是 16。多出来的那一处是 env-registry:同一行同时命中第四形态(let envRegistry: any; 分体声明)与类型实参形态,两条规则各报一次。

槽 处置 依据
auth / objectql / i18n / analytics / security / metadata 台账契约 ServiceSlotContracts
email / sharing / sharingRules / reports / approvals / external-datasource spec 契约 契约早已存在,且注册进槽的类都写了 implements
env-registry RestEnvRegistry RestServer 构造函数自己声明的形参类型
settings 具名本地 surface,不入 ledger B2 决策:service-settings 可选
default-project 具名窄切片 只声明本文件读的一个字段
服务存在性探测 unknown 槽名是运行期参数

objectql 按 #4404 落地的 IObjectQLEngine(而非 IDataEngine):消费方够到的是完整引擎——batch 路由背后探测的是 ql.transaction。

六个"无契约"的槽其实都有契约,而且是受检的

这批最大的发现不是缺陷而是盘点结果:email / sharing / sharingRules / reports / approvals / external-datasource 在 packages/spec/src/contracts/ 里本来就有契约,并且各自 provider 注册进槽的那个类都声明了 implements(EmailService implements IEmailService、ExternalDatasourceService implements IExternalDatasourceService …)。所以生产侧形状由编译器每次构建校验,本文件只需具名——即 #4404 用一份受检 claim 取代七个未受检 stand-in 的纪律。没有为它们新增 ledger 条目(不动 packages/spec),入账与否留给后续决策。

包装函数返回注解一并收窄

十处查找位于 async (environmentId?) => Promise< any | undefined > 的 provider 内。只改查找会在下一行把契约重新擦掉——正是规则文档里记的 KNOWN RESIDUAL(注解在外层函数上,AST 规则原理上看不见)。每个 provider 的返回类型因此一并收窄为自己那个槽的契约。

本批未发现死探测 —— 如实记录,而不是凑一个产出

这一族历史上每批都挖出真缺陷(#4361 的 getMetaItem 指着一个从来没有该方法的服务、#4321 的 registerInMemory),所以我逐一核对了类型化后消费方做的每一处探测:emailService.send、authService.getApi / isAuthGateActive、svc.queryDataset、ql.transaction、六个 approval 动词、五个 security 方法、五个 federation 方法——全部命中真实成员与真实 arity。

一个值得记的次级结论:external-datasource-routes.ts 的五处 svc?.method 探测在类型化后显出是冗余但正确的——契约的方法都是必需成员,所以只要服务解析成功探测必为真,503 分支只由"服务不存在"到达,而那正是它的用途。不是恒假分支,不改行为。

钉子为什么是运行期测试,而不是类型级断言

packages/rest/tsconfig.json 排除自己的 test 文件,且该包没有 typecheck script(它是 DEBT / TEST_DEBT 台账条目)——所以没有任何 tsc program 编译这些文件。在这里写 @ts-expect-error 或 Assert< Equal< … > > 会永不求值,删掉它每道闸门照样绿:正是 AGENTS.md 禁止、#5286 / #5449 付过代价的 phantom check。

而这次改动真正携带的风险恰好是运行期可测的:provider 是二十参构造函数的第 6..19 个位置参数,形状全都是 (environmentId?) => Promise< unknown >,所以接错槽处处可赋值、编译器一声不响。新测试(rest-api-plugin-slot-lookups.test.ts,5 例)因此钉住:

  1. 每个 provider 取回自己那个槽注册的实例(形状相同,只有实例同一性能证明);
  2. boot 解析的槽名集合精确相等('sharingRules' 手滑成 'sharing-rules' 在这里红,而不是在生产上让一条路由永久 501);
  3. env-registry / default-project 两个 seam 按 RestServer 声明的形态传入;
  4. 存在性探测不碰占位对象;
  5. 全部可选槽为空时逐个 provider 降级为 undefined 且不抛。

反向验证(方向先定后验)

预期方向是红——因为这正是类型抓不到的那一类。把 getService< IReportService >('reports') 改成 'report' 一个字符,5 例中 2 例失败,报文直接点名:

AssertionError: reportsServiceProvider must resolve 'reports': expected undefined to be { __slot: 'reports' }
AssertionError: expected Set{ 'manifest', 'http.server', …(18) } to deeply equal Set{ … }

已还原。

验证

检查 结果
pnpm --filter @objectstack/rest test 61 个测试文件 / 838 passed
tsc --noEmit -p packages/rest/tsconfig.json 2 errors,与 DEBT 台账记录的 errors: 2 精确相等,且都在我没动的 package-routes.ts;两个改动文件 0 error
TEST_DEBT 维度 临时解除 test 排除后测量:新测试文件贡献 0 error(#5827 若落地不会因本 PR 变红)
pnpm check:slot-lookup(强制模式) ✓ 143 / 32,none new,no files added
pnpm eslint(三个改动文件) exit 0
node scripts/check-nul-bytes.mjs OK

合并 main(2 个 commit)后复跑:rest-meta-save-receipt-envelope.test.ts(merge 带进来的 #5265 新测试)先报 2 例失败,根因是本 worktree 的 metadata-protocol dist 过期(该测试经其 dist 走 save-receipt 路径),重建依赖后 838/838 全绿——与本单勘误记录的同源陷阱,不是 main 红也不是本改动所致。

范围外发现

default-project 槽有 3 个生产消费方、0 个生产注册方:rest-api-plugin.ts、runtime/http-dispatcher.ts:618、cloud 侧 objectos-runtime/kernel-resolver.ts:193;而 6 处注释(含 rest-server.ts 两处、http-dispatcher.ts 两处、domain-handler-registry.ts)都把注册方写成 createSingleEnvironmentPlugin,该函数在 objectstack 与 cloud 两个仓库里都不存在,cloud 仅在一处测试里 stub 了这个槽。三个消费方都有后续 fallback、降级路径完整,所以今天没有用户可感的故障——但"单环境默认"这一级解析永远不会触发。本 PR 只按本文件实际读到的字段具名类型化,不改行为;已另行开单,不在本批扩面。

claude added 2 commits August 6, 2026 12:46
#4251 的 B4 批次:REST 组合根的全部槽查找擦除。`rest-api-plugin.ts`(15)与
`external-datasource-routes.ts`(1)改为传入槽的契约类型,棘轮基线
159 → 143 站点 / 34 → 32 文件,两个文件退出祖父名单。无行为变更。

`email` / `sharing` / `sharingRules` / `reports` / `approvals` /
`external-datasource` 六个槽在 packages/spec 里本来就有契约,而各自 provider
注册进槽的那个类都写了 implements(`EmailService implements IEmailService` 等),
所以生产侧的形状由编译器每次构建校验,本文件只需具名 —— 即 #4404 用一份受检
claim 取代七个未受检 stand-in 的纪律。`auth` / `objectql` / `i18n` /
`analytics` / `security` / `metadata` 取自 ServiceSlotContracts 台账;`objectql`
是 IObjectQLEngine 而非 IDataEngine,因为消费方够到的是完整引擎。

十处查找位于 `Promise<any | undefined>` 的 provider 内,只改查找会在下一行把
契约重新擦掉(规则看不见的包装返回注解残留),故 provider 返回类型一并收窄。

三个无契约的槽分别用三种方式如实表达:`env-registry` 用 RestServer 构造函数
自己声明的 RestEnvRegistry(实参因此受检);`settings` 按 B2 决策给具名本地
surface(service-settings 可选,REST 层不能对它产生依赖),返回类型复用公开的
ResolvedSettingValue;`default-project` 只声明本文件读的那一个字段。服务存在性
探测的槽名是运行期参数,用 unknown —— 它只问槽里有没有东西,从不碰形状。

本批未发现死探测,如实记录而非暗示:逐一核对了 emailService.send、
authService.getApi/isAuthGateActive、svc.queryDataset、ql.transaction、六个
approval 动词、五个 security 方法与五个 federation 方法,全部命中真实成员与
真实 arity。

新增的钉子是运行期测试,这是刻意选择:packages/rest 的 tsconfig 排除 test 文件
且该包无 typecheck script,没有任何 tsc program 编译它们,写类型级断言会是
#5286 / #5449 付过代价的 phantom check。真正的风险是接线 —— provider 是二十参
构造函数的第 6..19 个位置参数、形状完全相同,接错槽处处可赋值且编译器看不见,
所以测试驱动每个 provider 并断言它取回自己那个槽注册的实例,钉住 boot 解析的
槽名集合,以及全部可选槽为空时的降级路径。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE
@vercel

vercel Bot commented Aug 6, 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 6, 2026 12:54pm

Request Review

@github-actions github-actions Bot added the size/m label Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest.

11 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/error-handling-server.mdx (via @objectstack/rest)
  • content/docs/api/index.mdx (via @objectstack/rest)
  • content/docs/permissions/authentication.mdx (via @objectstack/rest)
  • content/docs/plugins/index.mdx (via @objectstack/rest)
  • content/docs/plugins/packages.mdx (via @objectstack/rest)
  • 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)
  • content/docs/releases/v12.mdx (via @objectstack/rest)
  • content/docs/releases/v17.mdx (via @objectstack/rest)

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.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 6, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 6, 2026 13:02
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit 1216dcc Aug 6, 2026
25 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-4251-slot-sweep-b4 branch August 6, 2026 13:08
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…g the slot-lookup pin's figures (objectstack-ai#17812)

Fixes objectstack-ai#17716

Clause-②: no

## What was wrong

`packages/rest/src/rest-api-plugin-slot-lookups.test.ts` pins the REST
composition root's
positional wiring. Its `PROVIDERS` table stopped one argument short of
the constructor,
exactly at `tenancyServiceProvider` — the parameter objectstack-ai#15256 appended LAST
on purpose,
because inserting one mid-list *"would silently re-bind every positional
argument after
it"*. That is the exact failure this pin exists to catch, and it did not
cover the
parameter that reasoning protected.

The header also stated two figures in the present tense with no date: a
spelled-out slot
count, and an "arguments A..B of an N-argument constructor" span.

## The hole, measured rather than argued

Control: bind `authServiceProvider` at the LAST constructor position in
`rest-api-plugin.ts`, where `tenancyServiceProvider` belongs. Nothing
else moves, so the
only argument a row for that index could observe is the one that is
wrong. Same mutated
blob in both legs (`a44973312542d74da442b4aae51908e9240fd5c4`); the only
variable is the
test file.

| leg | tree | result |
|:--|:--|:--|
| A | `PROVIDERS` without the row (pre-fix) | `Tests 5 passed (5)` —
**green against a deliberately mis-bound positional** |
| B | `PROVIDERS` with the row (this PR) | `Tests 2 failed \| 4 passed
(6)` |

Leg B's two failures:

```
AssertionError: tenancyServiceProvider must resolve 'tenancy':
  expected { __slot: 'auth' } to be { __slot: 'tenancy' }
AssertionError: expected Set{ 'manifest', 'http.server', …(17) }
  to deeply equal Set{ 'manifest', 'http.server', …(18) }
```

The second one is the answer to the laziness question the card and the
triage ruling both
flagged. **The row does enter the compared lookup set, so the last case
needs one more row
and not a different comparison — the hard fork does not fire.** Laziness
was never why the
provider was invisible: `objectQLProvider` at index 7 has the identical
kernel-first /
sync-fallback shape and has always been covered. It was invisible
because it was absent
from the table, so nothing ever drove it and the slot was never asked
for.

`packages/rest/src/rest-api-plugin.ts` is read-only for this card. That
mutation was
temporary, trap-guarded, and verified restored byte-identical to HEAD
(`restored blob == HEAD blob`, `git diff HEAD` empty) after each leg. It
is not in this
diff.

## The new pin is itself driven

Leg C ablates the new row out of `PROVIDERS` on this PR's tree and runs
again:

```
× accounts for every positional argument from the first provider to the last
Tests  1 failed | 5 passed (6)

AssertionError: a positional argument in the provider span is covered by neither
PROVIDERS nor NON_PROVIDERS_IN_SPAN — add a row (provider) or an entry (not a
provider): expected [ 20 ] to deeply equal []
```

Exactly one case reds, and it is the new one — the other five stay
green, which is the
card's defect reproduced in isolation. That case is the machine that
would have caught
objectstack-ai#15256's append.

## Both figures derived, neither re-typed

Derived with the TypeScript compiler API over `rest-server.ts` and
`rest-api-plugin.ts`.
⛔ Not a bracket-depth counter: an arrow token and a generic type
argument close a naive depth counter
early, which is the instrument the triage ruling recorded as broken.

| figure | derived on this tree | the header said |
|:--|:--|:--|
| `RestServer` declared parameters | **21** | "N-argument" was accurate
at B4, stale by one since |
| provider-shaped arrow-to-Promise parameters | **13**, at indices `6 7
8 9 10 11 12 13 14 15 17 19 20` | — |
| provider span | **6..20** | `6..19`, so the appended provider fell
outside it |
| distinct slots `rest-api-plugin.ts` resolves | **20** | a count
matching none of them |
| arguments `new RestServer(...)` passes | **21** — equal to the
declared count | — |

The 20 distinct slots: `analytics approvals auth default-project email
env-registry
http.server i18n kernel-manager kernel-resolver manifest metadata
objectql protocol reports
security settings sharing sharingRules tenancy`. Three
`ctx.getService(name)` call sites
are pass-throughs (the kernel-resolver bridge and the presence probe)
and resolve no fixed
slot; `objectql` and `tenancy` are each reached twice (a
`kernel.getServiceAsync` leg and a
`ctx.getService` leg) and count once.

### The slot count was never right — it was not a figure that drifted

Both figures were written by one commit, `1216dcc73` (objectstack-ai#4251 B4, objectstack-ai#5953).
Re-running the same
derivation against that commit's tree:

- declared parameters **20**, provider span **6..19** ⇒ the span figures
were **correct the
  day they were written** and went stale when the constructor grew.
- distinct slots resolved by `rest-api-plugin.ts`: **20**, not the count
the header claimed.
That commit's diff retypes **14** lookups in this file, and the file
then held **21**
`getService` call sites carrying a type argument (20 non-`any`). The
header's figure
  matches none of those, then or now.

So it was not "already right", and it was not drift either: it was a
number with no
derivation behind it from the first commit. That is worth its own
sentence, because the two
failure modes are indistinguishable to a reader — which is precisely the
argument for not
writing either figure as a numeral again.

### Preferred outcome: not a corrected constant

Per the ruling's order (compute > pin > hand-write with a date), both
figures are removed
from the prose rather than corrected:

- The span is now pinned by `accounts for every positional argument from
the first provider
to the last`, which derives the span from the captured call
(`args.length`) and requires
  every index in it to be covered by `PROVIDERS` or excused in the new
`NON_PROVIDERS_IN_SPAN` ledger. An argument appended to `RestServer`
lands inside the span
  by construction and forces a decision.
- The slot count is now the set `BOOT_SLOTS ∪ {default-project} ∪
PROVIDERS`, which the
existing `asks for exactly the slots it declares` case already compares
against the real
  `asked` list. Its size is the derived 20.

A recorded failed instrument, so nobody repeats it: a runtime
`RestServer.length` pin is
**not** available. `config: RestServerConfig = {}` is parameter index 2,
and
`Function.prototype.length` counts only the parameters before the first
defaulted one — it
reports **2**, not 21.

## Nothing was weakened

`git diff` for this PR removes exactly seven lines, all of them docblock
prose carrying the
two stale figures. No `expect`, no assertion argument, no existing case
body and no table
row is touched; every other change is an addition. The two claims the
ruling named read
byte-identical after:

- *"every provider resolves the slot it is NAMED for"*
- *"the exact set of slot names the boot asks for"*

## Verification

| what | result |
|:--|:--|
| `pnpm --filter @objectstack/rest test` | exit 0 — `Test Files 190
passed (190)`, `Tests 3182 passed \| 1 skipped (3183)` |
| `pnpm --filter @objectstack/rest typecheck` | exit 0 —
`check:test-typecheck: OK … 0 file(s) / 0 error(s) / 0 pinned
signature(s)` |
| `pnpm --filter '@objectstack/rest^...' build` | exit 0 |
| `pnpm lint` (full repo, `eslint . --no-inline-config`) | exit 0 — run
whole, not narrowed |
| derived gate families (`scripts/pm/dispatch-gates.mjs --commands`) |
52 derived, **50 exit 0**, 2 NOT MEASURED |
| `pnpm check:slot-lookup` | exit 0 — `106 unswept site(s) in 25
file(s), none new` |

The two NOT MEASURED are `check:dual-build-cjs-loads` and
`check:type-check-debt`, both
exit **3** = `PREREQUISITE NOT MET`: each needs the whole workspace
built, which is a
farm-scale run CI owns. Neither is a red, and neither can be moved by a
change confined to
one `.test.ts` file. Reconciled with `--ran`: *"52 derived famil(ies)
accounted for — 50
run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)"*.

All numbers above were taken at `034629082`, the final commit.

## No changeset — measured, not assumed

`@objectstack/rest` is published (`"private"` absent) and ships
`files[]: ["dist", "README.md", "CHANGELOG.md"]`. The one changed path
is
`packages/rest/src/rest-api-plugin-slot-lookups.test.ts`; `src/` is not
in `files[]`, and
CI's *No compiled test files in any dist* step holds the other
direction.

Grepped over all three `files[]` paths after `pnpm --filter
@objectstack/rest build`:

| probe | symbol | hits |
|:--|:--|:--|
| negative | `NON_PROVIDERS_IN_SPAN` | 0 |
| negative | `accounts for every positional argument` | 0 |
| negative | `rest-api-plugin-slot-lookups` | 0 |
| positive control | `createRestApiPlugin` | 6 |
| positive control | `tenancyServiceProvider` | 4 |
| positive control | `RestServer` | 6 |

The positive controls prove the grep reaches the shipped bytes; the
negatives are therefore
absences, not a broken probe. Nothing published moves ⇒
`skip-changeset`.

## Acceptance notes

- **A bounded residual in the new pin, stated so it is not mistaken for
coverage it does
not have.** The span is derived from the arguments the composition root
actually
*passes*. A parameter appended to `RestServer` that the composition root
never passes
leaves `args.length` unchanged and the span pin green. That is a
different defect (a
parameter wired nowhere), the declared scope of the new case says
exactly what it covers,
and closing it would mean parsing `rest-server.ts` from inside a unit
test. Noted, not
  filed.
- `packages/rest/CHANGELOG.md` quotes the old docblock verbatim in two
released entries
(the B4 entry and its re-publication). Those are RELEASE-OWNED and are
accurate records
of what the code said at that release, not factual errors in the
entries. Left untouched.
- `rest-api-plugin.ts` and `rest-server.ts` are unchanged in this diff.
objectstack-ai#14251, which holds
  `rest-api-plugin.ts`, is unaffected. The in-flight surface of objectstack-ai#17672
  (`packages/rest/src/index.ts`, `query-multiplicity.ts`,
`packages/runtime/src/domains/packages.ts`) is disjoint from this
one-file diff.

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

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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants