Repository navigation
feat(spec): declare the query transport dialect as the flattened spelling of the QueryAST - #18704
Conversation
…e source `FindDataRequestSchema.query` declared the canonical QueryAST while the shipped `findData` door also folded a transport dialect no schema named (`$filter` / `$top` / `$skip` / `$orderby` / `$select` / `$expand` and the plural `filters`). Two dialects, one slot, one of them declared. `@objectstack/spec/data` now declares the transport spelling once — `QueryTransportParamsSchema` plus `QUERY_TRANSPORT_ALIAS_SLOTS` / `QUERY_TRANSPORT_DOLLAR_ALIASES` / `QUERY_TRANSPORT_DOLLAR_PARAMS` — and `@objectstack/metadata-protocol` folds by that export instead of its own `WIRE_QUERY_ALIAS_SLOTS` / `WIRE_DOLLAR_ALIASES`. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
… hoist Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
…he changeset Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 77df0adf3e3fe4a05e6975beb5ecc65de16c0ded && git checkout 77df0adf3e3fe4a05e6975beb5ecc65de16c0ded
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 62b114f8b8a2d22d8f4a2a6569150ad476493651 53591e48be2af08a3e41c50d9f4b955102595200 && git checkout -B drift-repro 62b114f8b8a2d22d8f4a2a6569150ad476493651 && git merge --no-ff 53591e48be2af08a3e41c50d9f4b955102595200
node scripts/docs-audit/affected-docs.mjs --json 62b114f8b8a2d22d8f4a2a6569150ad476493651
|
…w types `QueryWithTransport` is the author state and `QueryWithTransportParsed` the parsed one; `QueryTransportParamsParsed` names the parsed transport bag. `check:spec-parsed-alias` named both, and the test-side engine mock is typed with the parameters it is really called with. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
A `z.union` on the slot put zod's own `invalid_union` issue at the head of the issue list for every malformed query body, and the REST ingress reports `fields[0]` off that list — `query.aggregations.0.function` became a bare `query`. Extending `QuerySchema` with the transport members instead leaves every canonical member's issue path exactly where it was. The transport members now carry every value shape the door serves on that slot, including the body-form filter AST array and an explicit null withdrawal, and the fold lowers the legacy shapes onto the canonical slot. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsMethod. Own clone and worktree at the head sha in the scratchpad (shared checkout untouched, never used for a gate); complete tree; 1. One semantics or two — FAIL at the value level (keys are 1:1, values are not). Keys: every declared spelling folds to exactly one canonical slot; Values, measured with
So the transport arm admits value grammars the canonical arm of the SAME schema refuses. Concretely at the POST door: a body Fix, actionable without a question: make the fold TOTAL. Every value shape a transport member admits must either be lowered to the canonical member's declared shape inside 2. 3. Single source and the byte-equal pin — PASS. 4. Ruling item 4 — PASS, measured not asserted. 5. Parity gate re-pointed — PASS. 6. Other claims checked. ② Semver level
③ Boundary flags
Not measured: CI on this head (seat's read); the full Implemented-by: VERDICT: FAIL Generated by Claude Code |
…er emit Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
…ue rule Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
席位处置 —— 两条「偏离复核字面」本席都判成立,外加一条 CI 诊断与排队位置
⭐ 先说最重要的:施工席偏离了复核的字面,而它偏离得对复核是本席派的、跑在档位上的,但复核的处方不凌驾于既有维护者裁决之上。施工席没有默默绕开,而是实测了两条处方的后果并把冲突交回来。两条本席都判 A(如实现)。 问题一 —— 把 ObjectQL 数组声明在哪。 复核原文要求「declare them on the canonical member in ⇒ 照复核的字面做,会重开一个维护者已经否决的选项,并弄红一条专为拦截这件事而存在的钉子。 ⛔ 席位没有资格那样做,复核也没有。 判 A:数组声明在传输感知的槽位上(filter 槽的全部四种拼法,含 canonical 问题二 —— 用不用 复核指定了 判 A:同一次 AST 解析放在 transform 里面执行并返回其结果。⭐ 复核真正要求的是「输出由构造得来,不是由断言得来」—— 在 transform 里解析同样满足它,只是层级低一层。⛔ C(把 32 个键记为退役)是不诚实的:作者仍然写得出它们。 B(修 🔴 CI 诊断:仍红 3 条,且是本 PR 的head 根因本席已定位到具体命令:
"test": "vitest run --project local",
"typecheck": "tsc --noEmit && pnpm check:test-typecheck"⇒ 两个缺口叠在一个包上:跑了测试没跑类型检查,而且跑的测试还只是
载体与排队
一条本席认可的额外修复施工席就地修了 Generated by Claude Code |
…ery-transport-params
…p the id out of customer prose Two CI reds, both this branch's own: - `packages/rest` `typecheck` was never run: its `test` script covers only the `local` vitest project, and the type check is a separate script. The test layer held four errors, every one of them the declared `FindDataRequest['query'].where` INPUT union gaining the `FilterArray` arm the door already serves. Narrowed at the three read sites -- by REFUSING the other arm, not by casting past it -- and pinned the JSON-encoded filter string as the refusal the transport table deliberately makes it. - `check:doc-authoring` refused an internal issue id inside `.describe()` prose, which is printed at the customer and resolves to nothing there. Moved to an adjacent `//` comment; regenerated the two reference pages the sentence projects into. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
席位处置 —— 三条红全灭且本席在主记录上复核过,载体劈裂已补,达档 delta 复核在飞
✅ CI:三条红全灭 —— 读数取自主记录,⛔ 不是转述施工席的自述head
⇒ 上一轮 ⭐ 其中一条值得单记:那不是 main 带来的,是本分支自己的作者失误 —— 一个测试把 JSON 编码的 filter 字符串赋给 🔧 本席补了一处载体劈裂,并记下施工席的处理是对的
施工席把它记成 out-of-scope 并写明「⛔ Not mine to hang, strip or clear. Successor: the domain:spec seat, before enqueue.」—— ⭐ 这是对的:载体是席位的笔,施工席伸手才是错的。 本席已在卡 #16066 上补挂( 🔴 欠一次达档 delta 复核 —— 已在飞,且本席把两个问题封死为不可重开上一次达档复核记录在 派发令里本席把
⇒ 一条要求重开其中任一项的复核发现,是在开一个维护者裁决与仓内钉子都已经拒绝的口子。⛔ 本席不会照办。要验的是它们还在:32 个传输键还在、钉子还绿、没有 pipe 被引入。
两条本席收下但不算读数的过程记录
⏳ 排队位置⛔ 本轮不派新卡。派发前读 Generated by Claude Code |
Contract reviewServed-tier: Delta review of ① Derived judgments1. The os-regen merge dropped nothing — PASS, measured. Merge 2. 3. The JSON-string 4. The 5. The two closed questions are intact at this head — PASS, measured. (a) 6. The value table re-measured at head from the built dist — matches the PR body's eleven rows, and is mode-invariant ( 7. The delta also NARROWS the served accept set of 8. Other readings. ② Semver level
③ Boundary flags
NOT MEASURED: eslint with a hitting control (the seven-file run exited 0 but so did two deliberately bad control files — CI Implemented-by: VERDICT: FAIL Generated by Claude Code |
…eplacements
The refusal paragraph claimed "each one the door could serve is still
served". Measured false: `FindDataRequestSchema.query` was `QuerySchema`
(strip mode), the route forwards the ORIGINAL body, and the door read and
served five shapes the new slot now refuses at the ingress:
{$orderby: 'name desc'} -> orderBy [{name, desc}]
{sort: '-created_at'} -> orderBy [{created_at, desc}]
{$orderby: ['name']} -> orderBy [{name, asc}]
{$filter: '{"status":"open"}'} -> where {status: 'open'}
the same JSON string on `filters` / `filter`
A changeset becomes the CHANGELOG, so the sentence that ships to consumers
now names each one with its FROM -> TO, keeps the true half (none of these
value shapes was ever declared), and states that the GET querystring path
still serves them -- a reader meeting the 400 otherwise reads it as the
platform refusing the spelling everywhere.
Prose only; no code, no test, no gate moves. The `$top: 'abc'` / `$top: ''`
bullet is unchanged, and `Clause-②: yes (widening)` stands: the slot's
declared contract was `QuerySchema`, which never declared these shapes.
Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
席位处置 —— FAIL 收下,分级由本席裁定为不破坏,补丁轮只修措辞;外加本席自己一条读数的更正
⭐ CI 全绿而复核给 FAIL —— 这正是复核存在的理由本 head 上 33 success / 2 预期 skip / 0 failure,七条必查全绿,且复核把本 PR 的每一条技术主张都判 PASS:os-regen 合并一侧未丢(18/18 与 133/133 逐文件 blob 相等,且阳性对照证明 main 确实动过 7 条 regen 路径、全部按 main 的 blob 带过)、 FAIL 只为一句话。 而那句话会发给消费者。 🔴 缺陷:本 diff 收窄了一条路由已服务的接受集,而 changeset 说了反话实测四腿:路由处理器在合并基与 head blob 逐字节相同、且在
而
前半句为真,加粗那半句对上面五种形态为假。 ⭐ 另有两种形态也从 200 翻到 400,而且翻得对: ⚖️ 分级:本席裁 不破坏,依据是章程自己的机械边界测试复核把「要不要挂 BREAKING 横幅与 ADR-0087 处置」交回本席。本席裁:不挂。 依据逐字:
合并基上那个槽位声明的是
🔴 一条本席 ⛔ 不裁的:GET/POST 不对称
|
…ery-transport-params
…ts and re-site FindDataRequest `@objectstack/spec#build` exits 1 on this branch: the dropped-refinement ratchet that landed on `main` sees two published schemas this PR introduces -- `data/QueryTransportParams` and `data/QueryWithTransport` -- dropping four refinement sites each with no ledger line, and sees `api/FindDataRequest`'s one recorded site replaced by four under the new `query` slot. Declares them. The refinements are untouched: this is a visibility ratchet and the published JSON Schema is WIDER than the Zod type either way. Every site path and both header totals are copied from the gate's own output at this head (`748 refinement site(s) across 245 published schema(s)` / `0 ... DID reach the file, 3 had no JSON form`), not computed from the file. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
⭐ 本席的假设被证伪了 —— 让绿变红的不是那个 changeset,是基线动了
本席猜错的那一条,原样写出来本席在派发令里写下的假设是: 它测了,而假设是错的。 三条读数:
⇒ 跨过一个纯 markdown 提交把绿翻成红的,是基线移动:21:14 那次的合并 ref 是对着没有 #18729 的 main 算的,00:36 那次是对着有 #18729 的 main 算的。那个 +15/−1 的 changeset 不是成因,也没有撞掉任何缓存。
这不是仓库缺陷,而且本仓已经institutional 地关掉了它施工席顺手验了唯一一种「它本可以是缓存缺陷」的可能: 而 AGENTS.md 的多 agent 纪律第 7 条要求经合并队列落地,队列会把每个条目重新构建为并到当前 main 之上并在那里重跑必查集 ⇒ 一个在过时基线的合并 ref 上拿到的绿,落不进去。⇒ 无需立卡。 ⭐ 但值得下一个读者知道:一条必查检查的绿,是关于「你的 head 并到某个时刻的 main」的读数,不是关于你 head 的读数。 基线动了,同一个 head 可以从绿变红,而你的 diff 一个字节都没变。 本轮实际落了什么台账三行按门禁自己在本 head 上的输出申报(⛔ 不是照抄派发令、⛔ 不是对文件做算术): ⛔ 没有任何 现读 head Generated by Claude Code |
Contract reviewServed-tier: 140/140 ⭐ Tier verified by the seat from the reviewing round's harness-stamped per-message served-model field: 140 assistant messages carry one, 140 of 140 at tier, 0 off-tier. ⛔ Not the dispatch parameter, which is a request and not a reading. ① Derived judgments1. The three ledger lines and the header — PASS, re-derived from the gate's own print. Site strings cross-checked three ways: (a) the ⭐ Mode-invariance proven rather than assumed: the walker probe under 2. No ⭐ And the published width itself was diffed, not argued. The reviewer built the prior head in its own worktree and compared the two generated 3. The merge dropped no side — PASS, per path. Merge-base 4. The turbo-cache falsification HOLDS — and the gap this seat could not close is now closed. Step durations read from the jobs API, not prose: the passing run's build step 339 s with turbo ⭐ The failing run's checkout line was read this time. A 20000-line tail returned the whole log (2207 lines) and line 128 reads that HEAD is the merge of the branch head into
5. Suites, per touched package, each script separately, all at head. 6. CI at this head. 46 check runs over 35 unique names; latest-per-name 31 success / 4 skipped / 0 failure; the seven required contexts all success. 7. Carried forward and proven unmoved: seven named files are blob-identical between the prior FAIL record's head and this head; only the changeset differs, as the prose round intended, and it now carries the five-shape FROM-TO table, the true half, and the GET-still-serves sentence. ② Semver levelUnmoved. ③ Boundary flags
NOT MEASURED: the passing Build Core run's printed checkout line (tail-only reader; blob host refused); eslint with a hitting control (CI's lint job is the lint record); the GET querystring path end to end; objectui's runtime; a run at the merge-base; Implemented-by: VERDICT: PASS Generated by Claude Code |
Resolves the one conflict by hand: packages/spec/dropped-refinements.baseline.json is deliberately left out of .gitattributes' merge=os-regen routing and has no gen: script, so its two sides are unioned entry by entry. The two sides are disjoint: this branch adds api/PackageInstallBody; main (#18704) adds data/QueryTransportParams and data/QueryWithTransport and moves the site list of api/FindDataRequest. No schema key moved on both sides, so no site list had to be chosen between. The header is derived from the merged body and adjudicated by packages/spec/scripts/build-schemas.ts in the follow-up regeneration commit. content/docs/references/index.mdx was routed to the merge driver, which resolved it to this branch's bytes and silently dropped main's side (measured: the merged blob equalled the branch blob exactly). main's side is restored here so the regeneration in the follow-up commit runs on a known-good base. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
…heir disjoint accept sets (objectstack-ai#19018) Fixes objectstack-ai#18977 Clause-②: no — no accept set moves, and no export is added, removed or renamed. The diff is two docblocks in published source, one new pin test, and the changeset. Measured, not asserted: `check:generated` reports all 16 generated artifacts up to date, `check:api-surface`, `check:api-surface-declarations`, `check:authorable-surface` and `check:docs` included. ## The card `$orderby` is declared twice in `packages/spec`, and the two declarations are **complementary refusals** — each accepts exactly what the other rejects — with no cross-reference in either direction. Re-measured on this branch's base (`43f4766889`) with `safeParse` against a fresh build of both schemas: | `$orderby` value | `ODataQuerySchema` (`src/api/odata.zod.ts`) | `QueryTransportParamsSchema` = `DataEngineSortSchema` (`src/data/data-engine.zod.ts`) | |:---|:---|:---| | `'name desc'` / `'-created_at'` | accepted | REFUSED | | `['name desc', 'email asc']` | accepted | REFUSED | | `[{field, order}]` | REFUSED | accepted | | the `asc`/`desc` record map | REFUSED | accepted | | the `1`/`-1` record map | REFUSED | accepted | The premise holds exactly as filed. ## The reading the card flagged as not re-derived, re-derived here The card said — explicitly as the filer's reading — that `ODataQuerySchema`'s only in-repo consumer is the `buildUrl` helper in its own file. **Instrument**: `git grep -n ODataQuerySchema` and `git grep -n '\bODataQuery\b'` over this worktree at `43f476688`. **Unit**: files naming the symbol. **Result**: the declaration is consumed by `OData.buildUrl` at the foot of its own file, by its own unit test `src/api/odata.test.ts`, and by `src/type-alias-convention.pin.test.ts` (a generic pin that names every schema in the module). Everything else is a generated artefact — `api-surface*`, `authorable-surface*`, `declaration-map`, `export-origins`, `json-schema.manifest` — or the generated reference page. **Zero** routes, ingress paths or normalizers. **Lit control, same instrument, same tree**: the same grep over `FindDataRequestSchema` lands on `packages/rest/src/rest-server.ts:9019`, the `POST /data/:object/query` handler that `safeParse`s its body against it; over `QueryTransportParamsSchema` it lands on `packages/rest/src/rest-server-canonical-query-ast.test.ts`. So the instrument does find consumers outside `packages/spec` when there are any — the zero is a reading, not a dead instrument. ⇒ **`ODataQuerySchema` grades no runtime door.** The declaration that grades a query bag is `QueryTransportParamsSchema`, reached from `FindDataRequestSchema.query` through `QueryWithTransportSchema`. ## Context the card predates: objectstack-ai#18704 already settled which spelling is canonical `0b788da89` declared the query transport dialect as the flattened spelling of the QueryAST, and its own body names the OData sort expression among the shapes that now answer `400` at the ingress. The reason is in the source, verbatim: 「⛔ Three shapes are deliberately NOT declared, because lowering them means PARSING — and a second parser beside the door's is how one rule gets two implementations that disagree」. So option C on the card — widening `DataEngineSortSchema` to accept the string forms — is the thing that commit refused, and option B — widening the OData schema — moves a published accept set. Both are maintainer questions, not this PR. **And the string forms are not unserved**, which is the part neither declaration says. `normalizeSortNodes` (`packages/metadata-protocol/src/protocol.ts`) is the one shared ingress normalizer behind `GET /data/:object`, the export route and in-process `findData`, and it reads `'name desc'`, `'-created_at'` and the `string[]` form. Measured at the exact input shape `rest-server.ts` builds: | `POST /data/:object/query` body | `FindDataRequestSchema.safeParse` | |:---|:---| | `{"$orderby": "name desc"}` | `400 VALIDATION_FAILED` at `query.$orderby` | | `{"$orderby": ["name desc"]}` | `400 VALIDATION_FAILED` at `query.$orderby` | | `{"$orderby": {"created_at": "desc"}}` | 200, folds to `orderBy: [{field, order}]` | | `{"sort": "-created_at"}` | `400 VALIDATION_FAILED` at `query.sort` | The same querystring on the GET route works. The difference is the **door**, and neither door is `ODataQuerySchema`. ## What this PR changes Option A on the card, and nothing else — the reader's half of the defect: 1. **`src/api/odata.zod.ts`** — the docblock above `ODataQuerySchema` now says it grades no runtime door, names `QueryTransportParamsSchema` as the declaration that does, carries the complementary-refusal table, says why the gap is a decision rather than a defect, and says what actually parses the string forms. The `$orderby` member carries the same pointer at the point of use. 2. **`src/data/data-engine.zod.ts`** — the reciprocal pointer, inside the paragraph that states the refusal. It names `ODataQuerySchema.$orderby` as the second declaration, records that it grades nothing, and records the cost already paid: objectui#9554 was filed, triaged, graded and dispatched against a shipped `object-grid` producer that had been sending the canonical shape all along. 3. **`src/api/odata-orderby-dual-declaration.test.ts`** — 25 cases, the mechanical half of the cross-reference: each side's accept set, their disjointness (with the lit control that neither set is empty, since two schemas that accept nothing are also disjoint), and which of the two `FindDataRequestSchema.query` is graded by. ⛔ No `.describe()`, no Zod type, no export and no authorable key is touched. Every `safeParse` verdict on both declarations is the same before and after. ## Reverse verification — the pin is capable of failing One-off, committed first, mutated on disk through `scripts/ablation-replace.mjs` (anchor `1 -> 0`, blob `b25169449f69 -> 25040216bf74`), restored under a `trap`. No dist preflight was owed: the pin imports `./odata.zod` relatively, so it resolves to source and no build stands between the mutation and the verdict. - **Mutation**: add the `asc`/`desc` record arm to `ODataQuerySchema.$orderby`, so the two accept sets overlap on one value. - **Predicted direction**: turn red — the OData side's refusal case and both disjointness cases. - **Observed**: `3 failed | 22 passed (25)` — `refuses the asc/desc record map`, `no declared $orderby value parses under both` (`expected [ 'the asc/desc record map' ] to deeply equal []`) and `every declared $orderby value parses under exactly one of them` (`expected [ 1, 1, 1, 1, 2, 1, 1 ]`). - **Restore proven by state, not by exit code**: `blob after restore b251694 == blob at HEAD b251694`, `git diff HEAD` empty. ## Evidence, at `c7e22addb` - `pnpm --filter @objectstack/spec build` — exit 0, 34/34 declaration files emitted. - `pnpm --filter @objectstack/spec check:generated` — exit 0, **all 16 generated artifacts up to date**. - `pnpm --filter @objectstack/spec typecheck` — exit 0; the test layer compiles under `tsconfig.test.json` and `test-typecheck-debt.json` is unmoved at 54 files / 259 errors / 144 pinned signatures. - `pnpm --filter @objectstack/spec test` (project `local`) — **490 files / 14234 tests passed**, exit 0. - New pin alone: 25 passed. - Every heavy run went through `scripts/pm/os-verify-lock.sh`; the verdicts above are its `VERDICT command-exit` lines, not bare `$?`. ## Acceptance notes Two findings outside this card's scope. ⛔ Not filed by me and ⛔ not repaired here; they are in the report for the dispatching seat. 1. **`content/docs/api/data-api.mdx` teaches two `POST /data/:object/query` sort spellings that the route refuses.** It says sorts accept `{"orderBy": [{"field": "created_at", "order": "desc"}]}`, `{"orderBy": ["-created_at"]}` or `{"orderBy": {"created_at": "desc"}}`, "all equivalent". Measured at the shape `rest-server.ts` builds: the first is 200; `{"orderBy": ["-created_at"]}` is `400 VALIDATION_FAILED` at `query.orderBy.0` (`expected object, received string`) and `{"orderBy": {"created_at": "desc"}}` is `400 VALIDATION_FAILED` at `query.orderBy` (`expected array, received object`). Canonical `orderBy` is `z.array(SortNodeSchema)`; the record map and the shorthand array are transport-slot values, so they have to arrive on `$orderby` / `sort`. 2. **The `@example Programmatic Use` in `src/api/odata.zod.ts`'s file-level docblock parses to `{}`.** It writes `select` / `filter` / `orderby` / `top` / `skip` / `expand` / `count` — unprefixed — against the type `ODataQuery`, whose every key carries a `$`. `ODataQuerySchema.safeParse` on that bag verbatim succeeds and returns `{}`: every key is stripped. The block ships to `content/docs/references/api/odata.mdx`, so it is a published example. Left alone here on purpose: the file-level docblock is the one part of this file that feeds the generated reference page, and this lane fenced `content/docs/references/**` for the round. --- _Generated by [Claude Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…it has no transactions, and the engine gates on the declaration (objectstack-ai#18890) Fixes objectstack-ai#18063 Clause-②: yes Governing text: maintainer decision batch **objectstack-ai#148 item 3, letter B**, 「同意」 2026-09-17T14:26Z (issue comment 5716042163). It supersedes batch objectstack-ai#133's route C. Quoted verbatim and untranslated, because the spelling delegation is the part this PR had to execute: > `packages/spec`: the driver contract gains a way for a transport to **declare 「no transactions」** (the dev picks the smallest spelling the existing capability/contract surface already has — a capability bit is preferred over a new key), and the engine's transaction gating reads the declaration instead of method presence (`driver.zod.ts:266` re-keyed). **Notation.** This body writes generic types bracket-free — `Promise[Knex.Transaction]` means the declaration `SqlDriver` publishes. That is a spelling choice against body sanitization, not a different type. --- ## What landed 1. **`packages/spec`** — `DriverCapabilities` gains one live bit, `transactionsUnsupported`, plus the predicate that reads it, `driverSupportsTransactions()`, exported from `@objectstack/spec/data`. 2. **`packages/objectql` and `packages/core`** —⚠️ **corrected by the seat: all FOUR transaction gates**, not three. They read that predicate instead of `typeof driver.beginTransaction`: `ObjectQL.transaction`, `ScopedContext.transaction`, `ScopedContext.txDriver()` behind the discrete begin/commit/rollback trio, and **`engineCanRollBack`** — the ADR-0119 D4 gate that metadata-protocol's atomic `batchData` / `updateManyData` / `deleteManyData` and `runMigrationJournal` share. The degrade warning now says WHICH of the two reasons it fired for. ⭐ **The fourth gate was found by the at-tier review and is why this count changed.** With only three re-keyed, the gates DISAGREED: `driverSupportsTransactions` said false while `engineCanRollBack` still read method presence and said true — and the D1 degrade swallowed the driver's refusal. Measured on the real chain: an atomic `batchData` returned a 「rollback」 with **one record persisted** and `begins = 0`, against a lit control (same double, bit removed) that threw 501 with 0 rows; the migration runner ran to `completed` and wrote the `chunk_done` marker its own header says 「would not mean committed」. ⇒ this PR briefly re-opened the 「rollback does not roll back」 defect one layer up. Fixed, with a pin that fails without it — and⚠️ the pre-existing pin stayed GREEN under that ablation, which is why it never caught this. 3. **`driver-sql`** — `SqlDriver.supports` spells `transactionsUnsupported: false`. `SqlDriver.beginTransaction()` keeps its narrow `Promise[Knex.Transaction]`; nothing in the base was widened. 4. **`driver-turso`** — the remote face declares the bit; `TursoDriver.beginTransaction()` publishes the inherited declaration instead of `Promise[any]`; `RemoteTransport` loses its three decorative transaction members. --- ## The spelling, priced — because the ruling's preferred spelling points at a tombstone `driver.zod.ts:266` is the prescription line of a **retired-key tombstone**: `transactions` was removed in `@objectstack/spec` 17.0.0 under ADR-0049 enforce-or-remove, and `savepoints` / `isolationLevels` beside it are the same retired family. Three spellings were priced before one was chosen. **(a) Revive the name `transactions`.** Rejected, and the cost is measurable rather than aesthetic: - It needs **`packages/spec/src/migrations/registry.ts`** edited — the D3 entry `driver-capabilities-inert-bits-removed` names `data.DriverCapabilities.transactions` in its `surface` list and states the count ("of 34 declared bits, THREE have a decision-making reader … THIRTY-ONE were written by every driver and read by nothing") in its `reason` and `acceptanceCriteria`. That file is MIXED and deliberately not routed to the os-regen merge driver, so it is the one file in this area that a merge cannot resolve mechanically. **The chosen spelling touches it zero times** (verified: `git diff origin/main..HEAD` does not name it). - It inverts the record's own convention. Every optional bit here means `false` when absent; a revived `transactions` must mean "yes, transactions" when absent, or every existing driver silently loses them on upgrade. That is a tri-state boolean in a record where nothing else is one. - It converts a documented refusal into silent acceptance of a value whose **meaning changed underneath it**. The old bit claimed "I support transactions" and nothing read it; the new declaration must express "I have none", and it is load-bearing. An old inert value becoming load-bearing with the opposite sense is the ADR-0104 silent-strip class one level up — the class the tombstones exist to prevent. - It makes the tombstone's own published text false. That text ("no code in any repository ever read it, so its value never changed which code path ran") is what an upgrading author actually reads. - It also costs the pins that hold the retired set: `RETIRED_BITS` in `driver.test.ts`, the prescription case, the 31-tombstone counts in two docblocks. **(b) A new key outside `supports`** — dispreferred by the ruling by name, and larger: a second place to look for one fact. **(c) A new inverse-polarity bit on the live `DriverCapabilities` record.** **Chosen.** It keeps `absence = false`, leaves the tombstone true and refusing, touches neither MIXED file, and costs one key plus its reader. ⭐ **The ruling's stated preference and the tombstone do not conflict, and that is the finding worth stating plainly.** "A capability bit is preferred over a new key" asks for a bit on the existing `DriverCapabilities` record — it does not ask for the retired NAME back. Spelling (c) satisfies the preference in full while the tombstone stays exactly as published. No seat question is escalated here because there is no fork to escalate. ### Why adding a bit SATISFIES enforce-or-remove rather than reversing it The 17.0.0 audit removed thirty-one bits for one stated reason: **no code anywhere read them.** It kept the three where method presence provably cannot carry the signal. Ruling B's entire content is the creation of the missing reader. The bit arrives **with** the engine dispatch that consumes it, in the same change — the honest order the ADR asks for — and the record's own docblock now states that bar for the next author. ### Why method presence could not carry this `TursoDriver extends SqlDriver`, whose `beginTransaction()` opens a real knex transaction, so the inherited method reported the libSQL REMOTE transport as transactional. It is not: `RemoteTransport`'s data methods take **no `options` argument at all**, so a handle cannot reach the statement that would have to join it. **A subclass cannot opt out of a door it did not open.** This is the exact mirror of `batchSchemaSync`, which exists because a subclass can inherit `syncSchemasBatch` from a base whose transport batches while its own cannot — and which the engine likewise ANDs with method presence. --- ## Premise check: part of this card was consumed while it sat in the box Reported rather than quietly absorbed, because it changes what clause 3 still owed. `62bce5c297d` — "refuse transactions on the remote face instead of silently dropping them (PR objectstack-ai#18717)", 2026-09-17T17:13:06Z, four hours after the ruling — landed the **driver-level** loud refusal for card objectstack-ai#18616: `TursoDriver` refuses `beginTransaction()` / `commit()` / `rollback()` and any `options.transaction` on the remote arm. That card is already `completed`; this PR does not re-open or re-decide it, and deliberately does not add a second, engine-level `options.transaction` refusal beside the driver's — a second mechanism for zero additional drivers is the shape this whole card is about. What that leaves for this PR is the half nothing had built, and it is load-bearing: ⭐ **The refusal's own remedy was unreachable.** `refuseRemoteTransaction`'s message tells callers to "take the non-transactional path deliberately: `engine.transaction()` without `require: true` on a datasource whose driver has no transactions runs the callback with no rollback and says so (ADR-0119 D1)". With the gate reading method presence, that path could never be taken for this driver — the method is there, so the engine opened a transaction and the callback got a 501 out of `beginTransaction()` instead of the declared degrade. **The message made a promise only this change can keep.** Two more premise readings, both against `origin/main`: - `RemoteTransport`'s three transaction members were **unreachable** once the driver refused: `remoteTransport.beginTransaction|commit|rollback` — **0** call sites repo-wide, against a lit control of **7** lines calling other members of the same field. They are deleted here. - `TursoDriver.beginTransaction()`'s `any` dissolves without paying either price objectstack-ai#17690 priced. `refuseRemoteTransaction` returns `never`, so the remote branch is assignable to any declared return type and the only arm that still returns is `super.beginTransaction()`. The override republishes the base's type. **It is spelled `ReturnType[SqlDriver['beginTransaction']]` and not the knex type directly, because `knex` is not a dependency of `driver-turso`** (`check:undeclared-dep-imports`; the same constraint the doors suite's `KnexSlice` works around) — and deriving it from the base is the stronger pin.⚠️ **objectstack-ai#17690 could not be read** — it, objectstack-ai#17876 and objectstack-ai#17878 all answer 404 (the `os-musk` account is deactivated). Lit control: objectstack-ai#18063 and objectstack-ai#18116 read fine through the same instrument in the same round, so the 404s are a reading. Clause 4 of the ruling — "`SqlDriver.beginTransaction` keeps its narrow `Promise[Knex.Transaction]` (the honest narrowing objectstack-ai#17690 protected)" — is therefore treated as **the governing restatement**. No claim is made here about objectstack-ai#17690's original text. --- ## Behaviour change for a caller On a datasource whose driver declares the bit, `engine.transaction()` takes the DECLARED non-transactional path (ADR-0119 D1) instead of opening a transaction it cannot honour: - without `require`: the callback runs with no transaction, `owned: false`, and the degrade warns **once per datasource** — naming the declaration, not a missing method, because sending an operator to look for a method this class publishes wastes the report; - with `require: true`: `TransactionUnsupportedError` **before the callback writes anything**; - `ScopedContext.transaction` answers identically, and the discrete trio's `begin` returns `null`. Every one of those is the answer a driver with no `beginTransaction` already received. Nothing that worked stops working — which is why the changesets are **minor**: the remote transport never honoured a transaction, so no working behaviour is withdrawn (the ruling's own stated ground, and the ground on which `RemoteTransport`'s three published members are removed at minor). --- ## Verification All readings on the merged tree, `0c6eeea0f6f`, against `origin/main` `0b31d90fb37`. Every exit code captured by redirect, never through a pipe. | run | result | |:--|:--| | build closure (`turbo run build`, driver-turso + objectql closures) | **exit 0** — 15/15 tasks | | typecheck — spec, objectql, driver-sql, driver-turso | **exit 0** — 17/17 tasks | | `@objectstack/spec check:generated` | **exit 0** — all 15 artifacts current | | `spec` `src/data/driver.test.ts` | **58 passed** | | `objectql` — 6 transaction suites | **66 passed** | | `driver-turso` `pnpm test` (whole package) | **1282 passed / 55 files** | | `driver-sql` `pnpm test` (whole package) | **2627 passed, 168 skipped** | | `pnpm lint` (`eslint . --no-inline-config`, whole repo) | **exit 0** — complete population, no narrowing claimed | | 24 further gate families run locally | **all exit 0** | `pnpm check:type-check-debt` returned **exit 3, `PREREQUISITE NOT MET`** — it refuses to measure without the whole workspace built, which is a farm-scale build. Recorded as **NOT MEASURED**, ⛔ not as a pass and ⛔ not as a red. The rest of the derived gate roster is CI's run. ### Reverse verification — two legs, both dist-aware **Leg A — the spec predicate** (`objectql` resolves `@objectstack/spec` through `exports`, i.e. `dist/`, per the `KNOWN_UNALIASED_TEST_IMPORTS` ledger, so the mutation had to reach `dist/` to mean anything): | | reading | |:--|:--| | `driver.zod.ts` blob at HEAD | `63ab873394848c025325ac234e8bd62b361c9ce0` | | blob after mutation (declaration clause deleted) | `481e373c37ec774e03b6c127b6ceb6d8527c03d1` — differs, so it reached disk | | rebuild, then `ablation-dist-preflight @objectstack/spec … --absent` | **exit 0** — the guard is gone from `dist/` | | `engine-transaction-declared-unsupported.test.ts` | **8 failed / 8** | | `spec` `driver.test.ts` | **1 failed / 58** — only the predicate case, as predicted | | restore (`git checkout HEAD -- …`), `git diff HEAD` | empty; blob back to `63ab873…` | | rebuild, preflight (present) | **exit 0** | | re-run | **8 passed / 8** | **Leg B — the driver declaration** (same-package source resolution, no build in the path): | | reading | |:--|:--| | `turso-driver.ts` blob at HEAD | `bb55757e6025d55d58babbbe0c090eefa4afa001` | | blob after mutation (`transactionsUnsupported: false`) | `2bfb64a95287d3145dea4d36fd75cad23868f3e1` — reached disk; injected marker observed on disk | | declaration suite + capability census pin | **2 failed / 102** | | restore | blob identical to HEAD, `git diff HEAD` empty | | re-run | **102 passed / 102** | Predicted direction was RED, and RED is what both legs produced. Both scripts carried `trap … EXIT INT TERM` with absolute paths; both restores are proven by blob identity and an empty `git diff HEAD`, not by an exit code.⚠️ One instrument error, reported rather than dropped: leg B's two `*_SRC_COUNT` echo lines were mis-quoted inside a quoted heredoc, so `grep` read the pattern's tail as extra filenames and printed a prefixed `0`. Those two lines are **void**, not readings. The on-disk proof does not rest on them — it rests on the anchor assertion (the pre-mutation text had to occur exactly once or the script aborted), the injected marker counted on disk, and the two blob hashes. ### Merge `origin/main` was merged after PR objectstack-ai#18704 landed, through `scripts/pm/os-regen-merge.sh` — merge committed first, regeneration afterwards, never during (a `gen:schema` run in MERGE state rolls the authorable-surface anchor back to the old fork point). Three os-regen artifacts were regenerated from the merged tree. Asserted afterwards: **zero** lines present in `origin/main`'s `api-surface/data.json`, `authorable-surface/data.json` or `export-origins/data.json` are absent from the regenerated files, with the lit control firing (the single addition is `driverSupportsTransactions (function)`). Neither MIXED file — `dropped-refinements.baseline.json`, `packages/spec/src/migrations/registry.ts` — is touched by this branch at all. --- ## Acceptance notes Out-of-scope observations, noted and deliberately not acted on here: - `packages/spec/src/data/driver.zod.ts` still carries the retired `savepoints` and `isolationLevels` beside `transactions`; both remain correctly retired under this change and neither gained a reader. Noted, not filed. - `packages/objectql/src/engine.ts` has a third comment (near the `batchData` observability path) that cites `warnTransactionUnsupported` as its model; it is prose, still accurate, and left alone. Noted, not filed. - The `turso-driver-doors-declared-types.test.ts` header still quotes a TS2416 coordinate (`src/turso-driver.ts(1662,18)`) that has drifted by landings. The receipt's substance reproduces; only the coordinate is stale, and it is kept verbatim as the historical error text. Noted, not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #16066
Clause-②: yes (widening)
Authored by Claude Code, session https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
The
findDataslot declared one query dialect and accepted two.FindDataRequestSchema.querywasQuerySchema— the canonical QueryAST — while the shipped door also folded$filter/$top/$skip/$orderby/$select/$expand, the pluralfiltersand the bare aliasesfilter/select/sort/skip/populate, from a table that lived module-private inside@objectstack/metadata-protocoland whose own comment called them "the wire-only spellings no schema declares". Two dialects, one slot, one of them declared: unverifiable at build time, unrejected at runtime.Director seat ruling, decision batch #129 item 4 (2026-09-13, comment 5651810333). Maintainer, verbatim: 「你是项目总监,9306 这种为什么你不直接决策呢?而且不是说要以协议为准吗?还需要立卡问 spec 多次一举啊。这个也需要更新skills。其他同意。」 — 「其他同意」 covers this card.
What landed
1.
packages/specdeclares the transport dialect, once. Insrc/data/data-engine.zod.ts, besideRPC_QUERY_ALIAS_SLOTS— "the ONE place the alias to canonical mapping is declared":QueryTransportParamsSchema+QueryTransportParams/QueryTransportParamsParsedQUERY_TRANSPORT_ALIAS_SLOTSRPC_QUERY_ALIAS_SLOTSextended with the transport-only spellings (filters/$filterontowhere,$expandontoexpand)QUERY_TRANSPORT_DOLLAR_ALIASES$-to-bare pairs that fold in two hops ($topontotopontolimit)QUERY_TRANSPORT_DOLLAR_PARAMS$set a boundary quotes when it refuses an undeclared oneQueryWithTransportSchema+QueryWithTransport/QueryWithTransportParsedcountflag9 exports added, 0 removed (
api-surface/data.json).topis the one alias the tables name that the transport schema does NOT declare:BaseQuerySchemaalready carries it besidelimit, and re-declaring it would widen a canonical member rather than a transport one.2.
FindDataRequestSchema.queryis that slot.z.inputadmits the canonical AST, the transport spelling, or a bag carrying both.z.outputisQueryAST & { count?: boolean }, and it is CONSTRUCTED rather than asserted: the fold's result is parsed by the AST schema and that parse's result is what leaves the transform. Prime Directive #12 is kept the way the ruling specifies — the transport form is the FLATTENED SPELLING of the same AST with a 1:1 alias table, never a second semantics — soQuerySchemaitself is untouched and still drops a$key as unknown. The hint table inmetadata-protocol(QUERY_PARAM_NEAR_MISS) is untouched and still accepts nothing as input.3.
metadata-protocolfolds by the spec export.WIRE_QUERY_ALIAS_SLOTS/WIRE_DOLLAR_ALIASESare gone; theUNSUPPORTED_QUERY_PARAMrefusal now quotesQUERY_TRANSPORT_DOLLAR_PARAMSinstead of a hand-copied sentence.4.
scripts/check-filter-alias-parity.mjsfollows the hoist. Its own header named this hoist as the open option and said the script "can be deleted rather than migrated" once BOTH sides derive. Only one side does:packages/rest'sFILTER_SLOT_QUERY_PARAMSstill namesfilters/$filterliterally, so the drift the gate exists for is still reachable and the gate was re-pointed, not deleted — its readers now read the spec file. Self-test green (7 batteries), gate green over the real tree:4 transport spelling(s) of the 'where' slot, identical on both sides.The dropped-refinement ledger, declared
domain:specseat after the fix round —os-dev.md:56reserves this body to the PR-open write, so the round named the wording and the seat writes it.This PR introduces two NEW published schemas that drop a refinement, and moves an existing entry:
data/QueryTransportParamsdata/QueryWithTransportapi/FindDataRequestquery.where.lazyto the fourquery.in.*the new query slot producesHeader totals move 243 / 737 to 245 / 748, taken from
gen:schema's own printed line — ⛔ not retyped and ⛔ not computed by arithmetic over the file.refinementSitesThatDidProject(0) andrefinementSitesWithNoJsonFormToCompare(3) are unmoved.⛔ No
.refine()or.superRefine()was touched, added or weakened. The ledger is a visibility ratchet; the published JSON Schema stays exactly as wide as it was. The gate's own words: 「The refinement itself is correct; ⛔ do not delete or weaken it to make this line go away.」⭐ Why this only appeared now, and it was not this PR going wrong. The gate landed on
mainvia #18729 (a49e8ae963, 2026-09-17T20:51:30Z) — twenty minutes AFTER this branch last merged main atfa423f55b0e(20:31:14Z). The ratchet is absent from the branch tree at every head it has had, so no build of this head could ever have run it. CI builds the merge ref, not the branch head, so the run before this one was computed against amainwithout the gate and the run after against amainwith it. The failing run proved it itself: it printed- query.where.lazyforapi/FindDataRequest, a site that exists only in main's copy of a file this branch head does not contain. ⇒ what flipped the checks red across a markdown-only commit was the BASE moving, not the diff. Full measurement in the seat's comment on this PR.Evidence at
53591e48be2a: specbuild0;test(projectlocal) 488 files / 14113 tests;test:repo(projectrepo) 31 files / 537 tests;typecheck0;check:generated0 (all 15 artifacts up to date);check:authorable-surface0. The ledger pinscripts/dropped-refinements.test.tswas run on its own — 27 tests, exit 0, including 「names at least one site per entry, and its header totals match its body」.One semantics is a claim about VALUES, and that is what this round fixes
The first two rounds made the KEYS 1:1 and left the VALUES apart: the transport arm admitted value grammars the canonical arm of the same schema refused, so one slot had two acceptance grammars selected by spelling — and the output type was a cast the transform never honoured. Measured at
5c072bbe63withQueryWithTransportSchema.safeParse, and re-measured at this head with the same probe:5c072bbe63{$orderby: 'name'}{orderBy: 'name'}{orderBy: 'name'}{$top: 'abc'}{limit: 'abc'}limitnaming'$top'{$top: ''}{limit: ''}{$top: '50'}{limit: 50}{limit: 50}{limit: '50'}{$filter: 'not json'}{where: 'not json'}{$filter: ['status','=','open']}{where: ['status','=','open']}{where: {status: 'open'}}{where: ['status','=','open']}{where: {status: 'open'}}{$count: true}countundeclared on the outputcountdeclared on the output{where: {a:1}, $filter: {b:2}}$filterwherenaming'$filter'Every value shape a transport member admits now either LOWERS to the canonical member's declared shape or FAILS the parse. What lowers: a stringly-typed
$top/$skip, a comma list on$select/$searchFields/$expand, a{field: direction}sort record, a relation-name list onpopulate,'true'/'false'on$count, and the input-onlyFilterArraysugar on every spelling of the filter slot —whereincluded.The
FilterArrayroute is a deliberate deviation from the review's letter, and #5158 is why. The review prescribed declaring the ObjectQL array on the canonical member inquery.zod.ts. That is rejected option A of maintainer ruling C on #5158 — 「widenwhereto accept the array dialect, so every driver and transport maintains two compilers forever」 — andpackages/spec/src/data/filter-array-declaration.test.tspins the negative half explicitly: "a querywheredoes NOT accept the array dialect … a future 'helpful' widening of the protocol face turns this red". So the array is declared on the TRANSPORT-AWARE SLOT, on every spelling of the filter slot including canonicalwhere, and lowered throughparseFilterAST— ruling C's own sink. Both halves are measured: the four spellings agree (§3of the spec suite), andQuerySchema.wherestill refuses the array (that file is green, 13 cases).What is REFUSED at the parse, because lowering it would mean parsing the spec must not do:
$top/$skip— the card's own defect class:$top: 'abc'passed POST validation and reached the engine aslimit: null, an UNBOUNDED read under a200;$top: ''aslimit: 0;$filterstring;$orderby/sort('name desc','-created_at',['name']) — the record andSortNode[]forms are unaffected. This is NOT the only refusal that takes a body from200to400. Five shapes were read off the forwarded ORIGINAL body and SERVED CORRECTLY before this change, and now answer400 VALIDATION_FAILEDat the ingress:{$orderby: 'name desc'},{sort: '-created_at'},{$orderby: ['name']},{$filter: '{"status":"open"}'}, and that same JSON string onfilters/filter. Two more went200to400for the opposite reason —$top: 'abc'reached the engine as an unbounded read and$top: ''aslimit: 0, a wrong answer under a200. The changeset carries each of the five with its FROM to TO. The door parses those strings and the spec must not own a second parser for them, and refusing is what makessortand canonicalorderByaccept one set without widening the canonical arm. The GET querystring path and in-processfindDataare unchanged — neither parses through this schema;[condA, 'and', condB].isFilterASTrefuses it,parseFilterASTlowers it to nothing, and the engine already answered400for it naming the prefix form.rest-server-repeated-filter-param.test.ts§3 pinned that body as forwarded with a MOCKEDfindData, so nothing downstream ever ran; that case now uses the prefix form['and', condA, condB]and a new case pins the infix one as refused at the ingress;$countthat is neither the boolean nor'true'/'false';aliasConflictIssue, quoting the spelling the caller actually wrote ($orderby, notorderBy), exactly asfoldRpcQueryOptionsdoes in the same file. The previous shape left the conflict UNFOLDED, which is how$filterstayed on a parsed output that claimed to be an AST.None of these narrows a DECLARED surface: no such value shape was ever declared. What five of them DO narrow is what the door SERVES — see the changeset, which carries the FROM to TO for each. The rest only narrow how far an unservable body travels before it is refused.
One cast remains, and it restates the INPUT only.
QuerySchemais annotated as az.ZodTypecarryingQueryASTandQueryInputas its two type arguments, for its recursion, so.extendon it is reachable only through a cast that erases both type arguments; without restating the input,z.inputof this slot would admit$sortand a query with noobject. The OUTPUT type is inferred from the transform's return type, which is the return type of the AST schema's ownsafeParse— nothing asserts what this schema emits.§5of the spec suite pins both halves at the type level..transform(fold).pipe(…). Same construction, measured consequence:packages/spec/scripts/build-schemas.tspublishes a schema's OUTPUT shape whenever that shape has a JSON form and falls back to the INPUT shape only when it does not. Adding the pipe gives the output a JSON form, sodata/QueryWithTransport.jsonstarts publishing the canonical AST and all 32 transport keys DISAPPEAR fromauthorable-surface/data.json— refused by that file's own deletion gate, withgen:schemaexiting 1. The authorable surface of this slot is the transport vocabulary, so the parse happens one level in.Ruling item 4 — the two things the card never measured
getData/ the*Manysiblings do NOT carry the same split, so there is nothing to declare the same way:getData(request)takesid/select/expanddirectly and has noqueryslot at all; its body readsrequest.select/request.expandand folds nothing (protocol.tsasync getData).UpdateManyRequestSchematakesrecords[],DeleteManyRequestSchematakesids[](packages/spec/src/api/batch.zod.ts). Neither carries a query.No caller outside
packages/restspeaks a spelling the table does not name. Census overpackages+examples+apps, excludingpackages/rest, tests,distand the driver packages (whose$skip/$limit/$matchare MongoDB aggregation-pipeline stages, a different namespace): the transport$keys in use are$top,$filter,$skip,$select,$orderby,$expand,$count,$search,$searchFields— all nine named by the table.An undeclared
$spelling was, and stays, refused loudly:400 UNSUPPORTED_QUERY_PARAM, pinned in §4 of the new suite and at the type level.The generated-docs regression is closed
expandis RECURSIVE, soz.toJSONSchemahoists it into$defsand renders the property as a bare$ref— which carries no siblingdescription. This slot publishes its INPUT shape (it is a transform), and in that direction the row rendered with an EMPTY description cell. Re-describing the member on the extended shape, with the text READ fromQuerySchemarather than re-typed, puts it back. Measured withgrep -c '^| \*\*expand\*\* .*| |$', exit code read from$?after a redirect:origin/main5c072bbe63content/docs/references/api/protocol.mdxcontent/docs/references/data/data-engine.mdxThe
data-engine.mdxreading is the same command's hitting control for theprotocol.mdxzero.The byte-equality pin, and it goes red
packages/metadata-protocol/src/protocol.query-transport-dialect.test.ts§1 holds the two exported tables, and the refusal sentence, against the values the module-private ones RESOLVED TO onorigin/mainat6dfa3ea772— a frozen BEFORE reading, transcribed from that tree, not a restatement to be kept in sync. §2 drives every alias the tables declare through the REAL normalizer and asserts the option bagengine.findreceives equals the canonical spelling's, with the COUNT call and the response envelope in the comparison and with an explicit guard that the canonical leg SERVED, so no pair can agree by both being refused. Both ablation legs and their restore are recorded in the round that landed it; 24/24 green at this head.This round's ablation, one-shot, on the fold itself.
packages/spec/src/data/query-transport.test.tsimports./data-engine.zodRELATIVELY, so its verdict is a function of source, not ofdist— no rebuild mediates it:5c072bbe63bodywhich is not a number.1 → 0 occurrences in the source; blobf388a53f→e46141954git checkout HEAD -- PATHf388a53f,git diff HEADon the target exits 0Tests
packages/spec/src/data/query-transport.test.ts— 58 cases. §1 derives the declared key set from the two tables MINUSQuerySchema's own shape (socountsurvives the subtraction for a stated reason), §2 every alias to its canonical slot, §3 the totality of the fold — every assertion reads the OUTPUT, §4QuerySchemastill dropping$filter/$top, §5 the declared input and output types. The previous §3 asserted.successalone, which is exactly how every non-AST output above went unmeasured.packages/metadata-protocol/src/protocol.query-transport-dialect.test.ts— 24/24, plusprotocol.query-param-arity/protocol.orderby-vocabulary/protocol.malformed-filter/protocol.count-opt-out: 102 passed / 5 files.packages/rest—rest-server-canonical-query-ast/rest-server-repeated-filter-param/list-view-grouping-query-door/rest-server-closed-query-params/rest-server-query-multiplicity: 25 + 147 across the set, green after the §3 update above.packages/spec/src/data/filter-array-declaration.test.ts— 13/13, the [engine] driver-sql 编译 spec 未声明的「数组 where 方言」—— 与 Turso remote 的拒收分叉,需一次定调(接纳进 spec 或响亮弃用) #5158 negative half included.Full suites and the lint union, all at this branch's head
e695f895c8(the final commit), each exit code captured from$?after a redirect:pnpm --filter @objectstack/spec typecheckpnpm --filter @objectstack/spec testpnpm --filter @objectstack/metadata-protocol testpnpm --filter @objectstack/rest testeslint . --no-inline-config --format jsonGates run locally, each exit code captured from
$?after a redirect, never through a pipe:check:generated(15/15),check:docs,check:api-surface,check:export-origins,check:declaration-map,check:strictness-ledger,check:authorable-surface,check:filter-alias-parity,check:nul-bytes,check:spec-parsed-alias,check:published-files,check:pm-widening-tells,check:query-options-erasure,check:objectql-double-limit,check:where-matcher,check:parse-guard,check:test-source-alias,check:cross-package-test-inputs,check:type-check-coverage,check:closing-keyword-parity,check:ci-filter-parity,check:pm-dispatch-gates,check-changeset-no-major --base origin/main,check-adr-0087-registration --base origin/main— all exit 0.scripts/pm/check-clause2-carriers.mjs --pair 18704exits 4:needs:contract-reviewis owed a re-hang because the head moved past the review that cleared it. Reported, not acted on — hanging or clearing a review gate is a seat's act.NOT MEASURED, stated rather than implied:
check:type-check-debtreturned itsPREREQUISITE NOT METexit 3 (the wholepackages/*closure is not built in this checkout) — nothing was measured, and it is neither a pass nor a finding. The remaining families of the 127dispatch-gatesderives for this change set are CI's run; that derivation also warns the tree is behindorigin/mainand that 13 of the files it derives from moved in that range, so CI will run families this list does not name. The GET querystring path end to end, and objectui's runtime, were not exercised.Acceptance notes
Noted, not filed — nothing here is a reproducible defect, a contract violation, or a metadata-authoring trap:
ODataQuerySchema(packages/spec/src/api/odata.zod.ts) declares$formatand$apply, which thefindDatadoor refuses withUNSUPPORTED_QUERY_PARAM. It is a separate surface and no caller routes it into this door. Who would meet it: a future OData adapter card.getData's implementation signature acceptsselect/expandasstring | string[]whileGetDataRequestSchemadeclares arrays only. Pre-existing, different axis. Who would meet it: whoever next touches the single-record read path.packages/rest'sFILTER_SLOT_QUERY_PARAMScould now derive all four filter spellings fromQUERY_TRANSPORT_ALIAS_SLOTS, which would makecheck:filter-alias-paritydeletable exactly as its header prescribes. Left alone: it moves runtime code in a package this card does not land in. Who would meet it: the nextpackages/restquery-ingress card.{top: 6, $top: 5}folds tolimit: 6and discards the5without a diagnostic, in the spec fold and at the door alike. Byte-equal to before and now stated in the table's docstring rather than raised, because raising it here and not at the door would make a POST body and a GET querystring answer the same request differently. Who would meet it: the next card that touches the door's$-to-bare hop.$select: 'a,b'againstfields,$top: '50'againstlimit). That asymmetry is the transport/canonical distinction itself — the querystring carries strings — and every such shape lowers, so the OUTPUT grammar is one. Widening the canonical members to match would declare the looser grammar rather than close it. Who would meet it: nobody, unless a future card moves the GET querystring path through this schema.Generated by Claude Code