Repository navigation
Commit 36fa4ff
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XBiH7mWuVM4qYrH5Ba3EJPE)_
Fixes #28609
Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — the published skills (`skills/objectstack-query`,
`skills/objectstack-api`) and the docs teach only the declared entry
members (`function`, `field`, `alias`, `filter`) and never an extra
entry key, so nothing they teach changes; the refusal itself names the
key and the spelling to send.
## 维护者速读
- **改了什么:** 查询里的聚合条目(`aggregations[]` 的每一项)现在在 spec 源头就关闭:写了 spec
没声明的键(比如拼错的 `bogusKey`,或把条目自己的 `filter` 写成 `where`)会被点名拒绝。REST、运行时分发器和
MCP 的 `aggregate_records` 读同一个 schema,拒绝文字一字不差。
- **为什么改:** 上一张卡(#28550)让 MCP 先拒绝了这种键,而 REST 仍然悄悄丢掉它照常返回
200,同一个条目在两个入口得到两种结论。
- **风险与回滚:** 发过多余条目键的调用方(REST、分发器、进程内)会从 200 变成 400
`VALIDATION_FAILED`;只含声明键的查询不变。回滚即还原 `query.zod.ts` 一处和 MCP 一处。
- **先看:** `packages/spec/src/data/query.zod.ts`(`AggregationNodeSchema`
变成 `strictObject`)、`packages/mcp/src/mcp-http-tools.ts`(删掉 MCP
自己那份条目副本,直接用 spec
节点)、`packages/rest/src/aggregation-entry-undeclared-key-door.test.ts`。
- **两分钟验证:** `pnpm --filter @objectstack/rest exec vitest run
src/aggregation-entry-undeclared-key-door.test.ts`,预期 5 个用例全绿(3 个拒绝、2
个对照)。
- **需要你做的:** 无,席位落地。
## What changes
- **`AggregationNodeSchema` is closed at the producer**
(`packages/spec/src/data/query.zod.ts`). It is now a `strictObject` with
the unknown-key campaign's message (`Unrecognized key(s) on this
aggregation entry: KEY.`), near-miss hints `where` / `filters` /
`conditions` → `filter` and `as` → `alias`, and a history sentence. The
retired `distinct` keeps its `retiredKey()` tombstone and answers that
sentence, never the generic refusal (strictObject keeps tombstones out
of suggestions). Every door that parses the node inherits it: `POST
/api/v1/data/:object/query` on `RestServer` and on the runtime
dispatcher (both parse `FindDataRequestSchema`), and
`EngineAggregateOptionsSchema.aggregations`.
- **MCP folds into it** (`packages/mcp/src/mcp-http-tools.ts`).
`aggregate_records` took each entry as its own `strictToolInput` copy of
the node's four members (#28550), a second closed key list. It now takes
`AggregationNodeSchema` whole, so the refusal is the spec node's own
sentence, the same one REST prints, and `distinct` answers its tombstone
on both doors (MCP used to give it the generic unknown-key refusal).
`filter` still reaches the bridge unchanged. `tools/list` now lists
`distinct` as a schema nothing satisfies (`not: {}`) with its
`[REMOVED]` text, and the member descriptions are the spec's.
- **Strictness ledger**: `data/` strip count 64 → 63 (regenerated), and
the hand prose rows for `query.zod.ts` re-read and updated to say the
node is strict.
- **Changeset** `.changeset/28609-aggregation-entry-strict.md`:
`@objectstack/spec` and `@objectstack/mcp` patch, `fix(spec)!:`,
**BREAKING** banner, both declaration lines, ADR-0087 `not-required
(no-migration-prescription)` (a query aggregation entry is a request
surface, never stored metadata). It carries one sentence superseding the
pending #28550 changeset's "the REST query door is untouched".
## Tests
- `packages/rest/src/aggregation-entry-undeclared-key-door.test.ts`
(new): the real door (`RestServer` → `findData` → `ObjectQL.aggregate` →
sqlite `SqlDriver`). `bogusKey`, entry `where`, entry `as`: 400
`VALIDATION_FAILED`, `fields[]` at `query.aggregations.1` carrying
exactly `AggregationNodeSchema.safeParse(entry)`'s message, driver never
read. Controls: `distinct` keeps its tombstone; declared-only entries
(count, sum, filtered count) answer the same grouped numbers.
- `packages/runtime/src/domains/data-write-request-gate.test.ts`: one
row in `QUERY_REFUSED`, so both doors (RestServer and the dispatcher
catch-all) refuse it and the cross-door parity block asserts they answer
alike.
- `packages/rest/src/request-schema-gate.conformance.test.ts`: one more
violating body on the `/query` route.
- `packages/mcp/src/mcp-http-tools.aggregation-entry.test.ts`: refusals
now contain the spec node's message; `distinct` gives the tombstone
sentence and no "Unrecognized key"; `tools/list` pin updated (`distinct`
present, `not: {}`).
- `packages/spec/src/data/query.test.ts`: new block for the closed node
(slip, four hints, tombstone not suggested, closure through
`QuerySchema` and `EngineAggregateOptionsSchema`, declared-only
control); two stale comments that said the node is non-strict corrected.
## Acceptance notes
- **Premise held.** Measured on base `0b6a52e8` through the REST door:
all three entry slips answered 200 with the grouped numbers.
- **Metadata-protocol measured `none`, not the order's expected
`narrowed`.** AGENTS.md defines the line as the change to what a
metadata author can write. `check:authorable-surface` stayed green (the
node's key list is unchanged; strictness is not on that surface), the
spec's own docblock calls QueryAST a request surface never stored in
metadata, and the claim recorded `none` as expected. So the changeset is
`patch` with the BREAKING banner, the shape AGENTS.md prescribes for a
non-protocol breaking change.
- **Analytics re-judged:** `service-analytics` builds its aggregations
in the `method` dialect for `IDataEngine` and never imports or parses
`AggregationNodeSchema`, so nothing there moves. No open PR touched
`query.zod.ts` or the MCP data tools at branch time or before this PR.
- **`dropped-refinements.baseline.json` did not move** (the order's
census hypothesis): strictness is not a refinement;
`data/AggregationNode` still records only `filter.lazy`.
- **Fixture sweep:** every aggregation-entry literal in the tree (code,
tests, skills, docs) was scanned for keys outside the five declared; the
only hits are deliberate (`func` pins in driver tests that call drivers
directly, `fn` in a plugin-security AST test, the analytics `method`
dialect), none of which goes through this schema.
## Verification
All readings at head `09f089de` unless named (the merge of `origin/main`
plus the ledger regeneration; nothing of this card moved after the
suites below).
<details><summary>Red first, on base 0b6a52e</summary>
`pnpm --filter @objectstack/rest exec vitest run
src/aggregation-entry-undeclared-key-door.test.ts` with the spec
unchanged: 3 failed / 2 passed. `bogusKey`, entry `where` and entry `as`
each answered 200 (`expected undefined to be 400`, the handler wrote the
grouped rows with no status); the `distinct` tombstone and the
declared-only control passed. With the fix: 5/5.
</details>
<details><summary>Ablation (one-shot, not a kept test)</summary>
`node scripts/ablation-replace.mjs` turned the node back into a plain
`z.object(shape)` (anchor hit 1/1), `pnpm --filter @objectstack/spec
build`, `ablation-dist-preflight spec ablationShape` found the marker in
2 built files. Reds: REST door 3 + REST gate table 1, runtime two-door
rows 2, MCP entry file 3, spec block 4 (13 in all); every control stayed
green. Restore leg: blob equal to HEAD, `git diff HEAD` empty, rebuilt,
`--absent` preflight clean over 588 built files.
</details>
<details><summary>Package suites and dogfood</summary>
- `@objectstack/spec`: 869 files, 24570 passed (before the last
spec-test commit; that file then 96/96 on its own).
- `@objectstack/rest`: 364 files, 6915 passed, 332 skipped.
- `@objectstack/runtime` (after building `@objectstack/runtime...`): 504
files, 8189 passed, 19 skipped.
- `@objectstack/mcp`: 48 files, 612 passed.
- Full dogfood (owed by the `(narrowing)` arm), `pnpm --filter
@objectstack/dogfood test` under the lock after building
`@objectstack/dogfood^...`: VERDICT command-exit 0, 369 passed / 3
skipped files, 2981 passed / 28 skipped tests.
</details>
<details><summary>Derived gates</summary>
`node scripts/pm/dispatch-gates.mjs --ran` on the final head: 85 derived
families accounted for, all with exit codes, 0 NOT-MEASURED, every exit
0. Two needed a second run: `check:doc-authoring` first red on an issue
id in the node's customer-facing `history` sentence (removed, then
green), and `check-plugin-teardown-shape --self-test` exit 3 until its
fixture commit was fetched (then green). Also green on the final head:
`check:strictness-ledger`, `check:authorable-surface`,
`check:api-surface` (public API surface unchanged), `check:docs`,
`check:type-check-debt`, `check:dual-build-cjs-loads`,
`check-adr-0087-registration --base origin/main`. Repo-wide lint and the
`test:repo` project are CI's.
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes (CI).
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English (the maintainer summary
block is the lane's required Chinese section).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01FEkQ5m158G1nvmxNYr7Jwq
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5m158G1nvmxNYr7Jwq)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6cbdef1 commit 36fa4ff
10 files changed
Lines changed: 325 additions & 75 deletions
File tree
- .changeset
- docs/audits
- 2026-07-unknown-key-strictness-ledger.counts
- packages
- mcp/src
- rest/src
- runtime/src/domains
- spec/src/data
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
24 | | - | |
| 24 | + | |
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| |||
65 | 65 | | |
66 | 66 | | |
67 | 67 | | |
68 | | - | |
| 68 | + | |
69 | 69 | | |
70 | 70 | | |
71 | 71 | | |
| |||
77 | 77 | | |
78 | 78 | | |
79 | 79 | | |
80 | | - | |
| 80 | + | |
81 | 81 | | |
82 | | - | |
| 82 | + | |
83 | 83 | | |
84 | 84 | | |
85 | 85 | | |
86 | 86 | | |
87 | 87 | | |
88 | | - | |
| 88 | + | |
89 | 89 | | |
90 | 90 | | |
91 | 91 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
719 | 719 | | |
720 | 720 | | |
721 | 721 | | |
722 | | - | |
| 722 | + | |
723 | 723 | | |
724 | 724 | | |
725 | 725 | | |
| |||
1260 | 1260 | | |
1261 | 1261 | | |
1262 | 1262 | | |
1263 | | - | |
| 1263 | + | |
1264 | 1264 | | |
1265 | 1265 | | |
1266 | 1266 | | |
| |||
1287 | 1287 | | |
1288 | 1288 | | |
1289 | 1289 | | |
1290 | | - | |
| 1290 | + | |
| 1291 | + | |
1291 | 1292 | | |
1292 | 1293 | | |
1293 | 1294 | | |
| |||
0 commit comments