Skip to content

feat(spec): declare the query transport dialect as the flattened spelling of the QueryAST - #18704

Merged
os-litant merged 12 commits into
mainfrom
claude/issue-16066-query-transport-params
Sep 18, 2026
Merged

os-litant merged 12 commits into
mainfrom
claude/issue-16066-query-transport-params

Conversation

@os-litant

@os-litant os-litant commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #16066

Clause-②: yes (widening)

Authored by Claude Code, session https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho

The findData slot declared one query dialect and accepted two. FindDataRequestSchema.query was QuerySchema — the canonical QueryAST — while the shipped door also folded $filter / $top / $skip / $orderby / $select / $expand, the plural filters and the bare aliases filter / select / sort / skip / populate, from a table that lived module-private inside @objectstack/metadata-protocol and 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/spec declares the transport dialect, once. In src/data/data-engine.zod.ts, beside RPC_QUERY_ALIAS_SLOTS — "the ONE place the alias to canonical mapping is declared":

export what it is
QueryTransportParamsSchema + QueryTransportParams / QueryTransportParamsParsed every spelling the tables name that is not itself a QueryAST key, each carrying the value set the canonical slot serves
QUERY_TRANSPORT_ALIAS_SLOTS RPC_QUERY_ALIAS_SLOTS extended with the transport-only spellings (filters/$filter onto where, $expand onto expand)
QUERY_TRANSPORT_DOLLAR_ALIASES the $-to-bare pairs that fold in two hops ($top onto top onto limit)
QUERY_TRANSPORT_DOLLAR_PARAMS the $ set a boundary quotes when it refuses an undeclared one
QueryWithTransportSchema + QueryWithTransport / QueryWithTransportParsed the query slot itself: input is the AST or its transport spelling, output is the AST plus the count flag

9 exports added, 0 removed (api-surface/data.json). top is the one alias the tables name that the transport schema does NOT declare: BaseQuerySchema already carries it beside limit, and re-declaring it would widen a canonical member rather than a transport one.

2. FindDataRequestSchema.query is that slot. z.input admits the canonical AST, the transport spelling, or a bag carrying both. z.output is QueryAST & { 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 — so QuerySchema itself is untouched and still drops a $ key as unknown. The hint table in metadata-protocol (QUERY_PARAM_NEAR_MISS) is untouched and still accepts nothing as input.

3. metadata-protocol folds by the spec export. WIRE_QUERY_ALIAS_SLOTS / WIRE_DOLLAR_ALIASES are gone; the UNSUPPORTED_QUERY_PARAM refusal now quotes QUERY_TRANSPORT_DOLLAR_PARAMS instead of a hand-copied sentence.

4. scripts/check-filter-alias-parity.mjs follows 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's FILTER_SLOT_QUERY_PARAMS still names filters / $filter literally, 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

⚠️ Added by the domain:spec seat after the fix round — os-dev.md:56 reserves 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:

ledger entry sites
data/QueryTransportParams 4, added
data/QueryWithTransport 4, added
api/FindDataRequest re-sited from the single query.where.lazy to the four query.in.* the new query slot produces

Header 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) and refinementSitesWithNoJsonFormToCompare (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 main via #18729 (a49e8ae963, 2026-09-17T20:51:30Z) — twenty minutes AFTER this branch last merged main at fa423f55b0e (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 a main without the gate and the run after against a main with it. The failing run proved it itself: it printed - query.where.lazy for api/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: spec build 0; test (project local) 488 files / 14113 tests; test:repo (project repo) 31 files / 537 tests; typecheck 0; check:generated 0 (all 15 artifacts up to date); check:authorable-surface 0. The ledger pin scripts/dropped-refinements.test.ts was 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 5c072bbe63 with QueryWithTransportSchema.safeParse, and re-measured at this head with the same probe:

input at 5c072bbe63 now
{$orderby: 'name'} OK, {orderBy: 'name'} refused
{orderBy: 'name'} refused refused
{$top: 'abc'} OK, {limit: 'abc'} refused, one issue at limit naming '$top'
{$top: ''} OK, {limit: ''} refused
{$top: '50'} OK, {limit: 50} OK, {limit: 50}
{limit: '50'} refused refused
{$filter: 'not json'} OK, {where: 'not json'} refused
{$filter: ['status','=','open']} OK, {where: ['status','=','open']} OK, {where: {status: 'open'}}
{where: ['status','=','open']} refused OK, {where: {status: 'open'}}
{$count: true} OK, count undeclared on the output OK, count declared on the output
{where: {a:1}, $filter: {b:2}} OK, output still carried $filter refused, one issue at where naming '$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 on populate, 'true' / 'false' on $count, and the input-only FilterArray sugar on every spelling of the filter slot — where included.

The FilterArray route is a deliberate deviation from the review's letter, and #5158 is why. The review prescribed declaring the ObjectQL array on the canonical member in query.zod.ts. That is rejected option A of maintainer ruling C on #5158 — 「widen where to accept the array dialect, so every driver and transport maintains two compilers forever」 — and packages/spec/src/data/filter-array-declaration.test.ts pins the negative half explicitly: "a query where does 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 canonical where, and lowered through parseFilterAST — ruling C's own sink. Both halves are measured: the four spellings agree (§3 of the spec suite), and QuerySchema.where still 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:

  • a non-numeric $top / $skip — the card's own defect class: $top: 'abc' passed POST validation and reached the engine as limit: null, an UNBOUNDED read under a 200; $top: '' as limit: 0;
  • a JSON-encoded $filter string;
  • an OData sort EXPRESSION on $orderby / sort ('name desc', '-created_at', ['name']) — the record and SortNode[] forms are unaffected. This is NOT the only refusal that takes a body from 200 to 400. Five shapes were read off the forwarded ORIGINAL body and SERVED CORRECTLY before this change, and now answer 400 VALIDATION_FAILED at the ingress: {$orderby: 'name desc'}, {sort: '-created_at'}, {$orderby: ['name']}, {$filter: '{"status":"open"}'}, and that same JSON string on filters / filter. Two more went 200 to 400 for the opposite reason — $top: 'abc' reached the engine as an unbounded read and $top: '' as limit: 0, a wrong answer under a 200. 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 makes sort and canonical orderBy accept one set without widening the canonical arm. The GET querystring path and in-process findData are unchanged — neither parses through this schema;
  • a filter array no lowering can express, such as the INFIX join [condA, 'and', condB]. isFilterAST refuses it, parseFilterAST lowers it to nothing, and the engine already answered 400 for it naming the prefix form. rest-server-repeated-filter-param.test.ts §3 pinned that body as forwarded with a MOCKED findData, 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;
  • a $count that is neither the boolean nor 'true' / 'false';
  • a CONFLICT — two spellings of one slot with different values — reported at the canonical path via aliasConflictIssue, quoting the spelling the caller actually wrote ($orderby, not orderBy), exactly as foldRpcQueryOptions does in the same file. The previous shape left the conflict UNFOLDED, which is how $filter stayed 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. QuerySchema is annotated as a z.ZodType carrying QueryAST and QueryInput as its two type arguments, for its recursion, so .extend on it is reachable only through a cast that erases both type arguments; without restating the input, z.input of this slot would admit $sort and a query with no object. The OUTPUT type is inferred from the transform's return type, which is the return type of the AST schema's own safeParse — nothing asserts what this schema emits. §5 of the spec suite pins both halves at the type level.

⚠️ Why the fold parses in the transform instead of .transform(fold).pipe(…). Same construction, measured consequence: packages/spec/scripts/build-schemas.ts publishes 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, so data/QueryWithTransport.json starts publishing the canonical AST and all 32 transport keys DISAPPEAR from authorable-surface/data.json — refused by that file's own deletion gate, with gen:schema exiting 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 *Many siblings do NOT carry the same split, so there is nothing to declare the same way:

  • getData(request) takes id / select / expand directly and has no query slot at all; its body reads request.select / request.expand and folds nothing (protocol.ts async getData).
  • UpdateManyRequestSchema takes records[], DeleteManyRequestSchema takes ids[] (packages/spec/src/api/batch.zod.ts). Neither carries a query.

No caller outside packages/rest speaks a spelling the table does not name. Census over packages + examples + apps, excluding packages/rest, tests, dist and the driver packages (whose $skip/$limit/$match are 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

expand is RECURSIVE, so z.toJSONSchema hoists it into $defs and renders the property as a bare $ref — which carries no sibling description. 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 from QuerySchema rather than re-typed, puts it back. Measured with grep -c '^| \*\*expand\*\* .*| |$', exit code read from $? after a redirect:

file origin/main at 5c072bbe63 now
content/docs/references/api/protocol.mdx 0 1 0
content/docs/references/data/data-engine.mdx 5 8 5

The data-engine.mdx reading is the same command's hitting control for the protocol.mdx zero.

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 on origin/main at 6dfa3ea772 — 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 bag engine.find receives 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.ts imports ./data-engine.zod RELATIVELY, so its verdict is a function of source, not of dist — no rebuild mediates it:

leg on-disk proof result
mutate: the fold reverted to its 5c072bbe63 body marker which is not a number. 1 → 0 occurrences in the source; blob f388a53f → e46141954 20 failed / 58, exit 1
restore: git checkout HEAD -- PATH blob back to f388a53f, git diff HEAD on the target exits 0 58 passed / 58, exit 0

Tests

  • packages/spec/src/data/query-transport.test.ts — 58 cases. §1 derives the declared key set from the two tables MINUS QuerySchema's own shape (so count survives the subtraction for a stated reason), §2 every alias to its canonical slot, §3 the totality of the fold — every assertion reads the OUTPUT, §4 QuerySchema still dropping $filter / $top, §5 the declared input and output types. The previous §3 asserted .success alone, which is exactly how every non-AST output above went unmeasured.
  • packages/metadata-protocol/src/protocol.query-transport-dialect.test.ts — 24/24, plus protocol.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:

run result
pnpm --filter @objectstack/spec typecheck exit 0
pnpm --filter @objectstack/spec test 486 files / 13926 passed, exit 0
pnpm --filter @objectstack/metadata-protocol test 179 passed + 3 skipped / 182 files · 2572 passed, 19 skipped, exit 0
pnpm --filter @objectstack/rest test 193 files / 3230 passed, 1 skipped, exit 0
eslint . --no-inline-config --format json 6820 files examined, 0 errors, 0 warnings, exit 0 — the whole union, not a narrowing

Gates 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 18704 exits 4: needs:contract-review is 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-debt returned its PREREQUISITE NOT MET exit 3 (the whole packages/* closure is not built in this checkout) — nothing was measured, and it is neither a pass nor a finding. The remaining families of the 127 dispatch-gates derives for this change set are CI's run; that derivation also warns the tree is behind origin/main and 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 $format and $apply, which the findData door refuses with UNSUPPORTED_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 accepts select / expand as string | string[] while GetDataRequestSchema declares arrays only. Pre-existing, different axis. Who would meet it: whoever next touches the single-record read path.
  • packages/rest's FILTER_SLOT_QUERY_PARAMS could now derive all four filter spellings from QUERY_TRANSPORT_ALIAS_SLOTS, which would make check:filter-alias-parity deletable 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 next packages/rest query-ingress card.
  • {top: 6, $top: 5} folds to limit: 6 and discards the 5 without 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.
  • The transport members still accept input sugar the canonical member does not ($select: 'a,b' against fields, $top: '50' against limit). 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

…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>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 17, 2026
@github-actions

github-actions Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/spec, touching 35 documentable anchor(s). ⚠️ 6 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/authorable-surface/data.json, packages/spec/declaration-map/data.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 62b114f8b8a2d22d8f4a2a6569150ad476493651.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 6 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/authorable-surface/data.json, packages/spec/declaration-map/data.json, …) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: /data/:object (route, 63 pages)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 62b114f8b8a2d22d8f4a2a6569150ad476493651 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 77df0adf3e3fe4a05e6975beb5ecc65de16c0ded — the merge of head 53591e48be2af08a3e41c50d9f4b955102595200 into base 62b114f8b8a2d22d8f4a2a6569150ad476493651, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 62b114f8b8a2d22d8f4a2a6569150ad476493651 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-litant os-litant added domain:spec priority:p2 Medium: important, M3 labels Sep 17, 2026 — with Claude
…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>

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5c072bbe630519257dbec5ad5440b84d0c3c05c3

① Derived judgments

Method. Own clone and worktree at the head sha in the scratchpad (shared checkout untouched, never used for a gate); complete tree; pnpm install --frozen-lockfile exit 0; @objectstack/spec built with DTS (34/34 declaration files); metadata-protocol and its workspace deps built via turbo (13/13 tasks). Every exit code read from $? after a redirect. Gate scripts blob-compared to origin/main before running — check-changeset-no-major, check-adr-0087-registration, invoked-as, scripts/pm/dispatch-gates are identical; only check-filter-alias-parity.mjs differs, and that difference is this PR's own hunk. Every zero reading below carries a hitting control. Probe files were written inside the worktree and deleted; final git status clean.

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; QueryTransportParamsSchema's key set equals the union of the two tables minus top (spec-side §1 pin; 36/36 green from source).

Values, measured with QueryWithTransportSchema.safeParse at the head sha:

  • {object:'a', $orderby:'name'} succeeds with output {orderBy:'name'}; {object:'a', orderBy:'name'} FAILS invalid_type. Same for $orderby:['name'] and sort:'name desc' (output orderBy:'name desc').
  • {$top:'abc'} succeeds with output {limit:'abc'}; {$top:''} gives {limit:''}; {limit:'50'} FAILS.
  • {$filter:'not json'} succeeds with output {where:'not json'}; {$filter:['status','=','open']} and {filters:[...]} succeed with output {where:[...]}; {where:[...]} and {where:'{"x":1}'} FAIL.
  • {$count:true} gives {count:true} — a key the AST does not declare.
  • conflict {where:{a:1}, $filter:{b:2}} succeeds and the OUTPUT STILL CARRIES $filter — contradicting the in-source docstring "a consumer reading a parsed query never sees a transport key".

So the transport arm admits value grammars the canonical arm of the SAME schema refuses. Concretely at the POST door: a body {filter:['status','=','open']} passes validation, a body {where:['status','=','open']} gets 400 VALIDATION_FAILED — while the DOOR serves the array on both (measured: findData({query:{where:['status','=','open']}}) hands the engine where:{status:'open'}). Two acceptance grammars for one slot, selected by spelling: that is the second semantics the ruling forbids, at the value level. The ruling's "declared output is the AST" holds only as a cast: QueryWithTransportSchema is typed z.ZodType of QueryAST through a double as unknown as, and the transform never validates what it emits. The PR sentences "its z.output is still the AST" and "a consumer reading a parsed query never sees a transport key" are measured false. Consequence at the door: a declared-valid $top:'abc' passes POST validation and reaches the engine as limit:null (an UNBOUNDED read under a 200); $top:'' reaches it as limit:0. The PR's "only narrowing measured" ($top:{} → 400) is therefore not the boundary of the declared string arm — the declaration admits inputs the door turns into the wrong-answer-under-200 class this card was filed to close.

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 foldQueryTransportBag / lowerFoldedTransportValues, or fail the parse with an issue at the canonical path — pipe the folded bag through QuerySchema (.transform(fold).pipe(QuerySchema)) so the AST output is by construction, not by cast; count then needs an explicit home (declare it on the output type, or declare $count beside the slot rather than inside it). For shapes the door serves on the canonical key too (the ObjectQL array on where, the order-less sort node), declare them on the canonical member in query.zod.ts so both spellings accept one set; for shapes that need parsing the spec must not do (JSON-string $filter, OData $orderby string, non-numeric $top), refuse at parse rather than emit them under the AST type. Report a conflict at parse via ctx.addIssue(aliasConflictIssue(...)) exactly as foldRpcQueryOptions already does in the same file (remember the $ spelling the way the door's wireSpelling does so the issue names what the caller wrote), or at minimum strip the losing transport key. Pin all of it with OUTPUT-asserting tests — §3's stillParses asserts .success only, which is how every non-AST output above went unmeasured.

2. z.input / z.output — PASS at the type level; QuerySchema untouched — PASS.
tsc probe (exit 0) at head: the input of FindDataRequestSchema.query admits {object,$top}, a full transport bag, a mixed bag and the canonical AST; refuses $sort and a missing object. The output is exactly QueryAST (equality-type assertion) and refuses $top, string limit, string orderBy, string where. packages/spec/src/data/query.zod.ts is not in the diff; QuerySchema.parse({object:'a', $filter:{a:1}}) gives {object:'a'} (strip) — the load-bearing claim holds; QUERY_PARAM_NEAR_MISS is untouched (absent from the protocol.ts hunks; 3 occurrences at head). Deviation from the ruling's letter: item 2 says "the declared input type is the union"; the PR declares QueryInput intersected with Partial(QueryTransportParams) — an extended object, a SUPERSET of the union (it also admits mixed bags). Documented in-source with a measured reason, and my control confirms it: a z.union of the two arms reports invalid_union at path '' as issues[0] for a malformed aggregations[0].function, the extended object reports aggregations.0.function. Acceptable as implemented; record it as implemented-as-intersection.

3. Single source and the byte-equal pin — PASS.
No const WIRE_QUERY_ALIAS_SLOTS / WIRE_DOLLAR_ALIASES declaration survives in protocol.ts (grep hits: comments, the §3 negative regex, the BEFORE_* literals in the new test). Second-copy hunt for literal ['$top','top'] / $skip-to-offset pairs across packages, apps, examples (non-test): only packages/spec/src/data/data-engine.zod.ts:735-736 (control hit). protocol.query-transport-dialect.test.ts 24/24 at head, resolving spec through the built dist. Ablation A: '$filter' mutated to '$filterZZZ' in the 8 built dist files carrying the extras literal → 2 failed / 24, exactly §1's table pin and refusal-sentence pin; dist restored byte-identical (md5 manifest of every dist file equal before and after, marker absent from 0 files). Ablation B: foldQueryAliasSlots(options, [], …) in source → 12 failed / 24 (every slot-alias pair plus the §3 call-site pin). Restore leg 24/24, tree clean. The pin exists, reads the live artefact, and fails.

4. Ruling item 4 — PASS, measured not asserted.
getData: GetDataRequestSchema carries id / select / expand, no query; the impl reads request.select / request.expand and folds no $ key. UpdateManyRequestSchema takes records[], DeleteManyRequestSchema takes ids[]. No split to declare. My own census of $-spellings over packages, examples and apps (excluding packages/rest, tests, dist, node_modules, driver packages and odata.zod.ts; control: the spec table file hits) finds exactly $count $expand $filter $orderby $search $searchFields $select $skip $top — all nine in QUERY_TRANSPORT_DOLLAR_PARAMS. $apply / $format only in odata.zod.ts (separate surface, per the acceptance note); $limit / $sort / $where only in driver packages as Mongo pipeline stages. The objectui sibling's published QueryParams (packages/types/src/data.ts) carries exactly the same nine keys. "Refused, loudly" is implemented at the door: findData({query:{$sort:'name'}}) throws 400 UNSUPPORTED_QUERY_PARAM quoting QUERY_TRANSPORT_DOLLAR_PARAMS (§4 green). Caveat in ③ item 3.

5. Parity gate re-pointed — PASS.
The origin/main header authorises deletion only when "both sides become derived"; packages/rest/src/query-multiplicity.ts:197-200 still spells 'filters', '$filter' literally, so the gate is load-bearing and re-pointing was right. From the complete worktree: self-test 7 batteries green, live run green (4 spellings identical on both sides). Ablations: fifth spelling where_clause on the declared side → exit 1 naming FOLDED BUT UNGATED; QUERY_TRANSPORT_ALIAS_SLOTS no longer mapping over RPC_QUERY_ALIAS_SLOTS → exit 1 ("no longer derives"). Wired in package.json check:filter-alias-parity and lint.yml.

6. Other claims checked. check:generated 15/15 up to date on a settled dist (my first run reported api-surface stale — a dud caused by my own concurrent rebuild of spec, the log said dist had no .d.ts; re-run clean). eslint --no-inline-config over the 7 changed source files: exit 0. Spec-side new suite 36/36.

② Semver level

@objectstack/spec: minor, @objectstack/metadata-protocol: patch, Clause-②: yes (widening). 9 exports added, 0 removed (api-surface hunk: 3 consts, 6 types; plus 4 declaration-map and 2 json-schema-manifest entries). check-changeset-no-major (driven by a reconstructed pull_request payload carrying the body line and the PR's labels): exit 0 — no major; level axis satisfied by the spec minor. check-adr-0087-registration: exit 0 — no declared-breaking changeset. No BREAKING banner owed: the declared surface only widened; the runtime refusal of $top:{} narrows no declared contract ($top was never declared). Level minor is correct, and stays minor under either fix route in ① item 1 (refusing at parse what the door already refuses is not a narrowing of a declared surface; declaring the array form on canonical where is a further widening).

③ Boundary flags

  1. Changeset (RELEASE-OWNED text) says "8 exports, 0 removed" — 9 landed. It also says "Nothing is narrowed" while the PR body records the $top:{} → 400 change; correct the count and state the value-shape refusal plainly in the changeset, since that is the text consumers grep.
  2. Generated-docs regression: in content/docs/references/api/protocol.mdx the FindDataRequest.query nested shape's expand row now has an EMPTY description (0 empty expand rows on origin/main, 1 at head); data-engine.mdx empty expand rows went 5 to 8; search / orderBy rows now render input optionality (fuzzy?, operator?, order?) because the slot is a transform. check:docs is green only because the artefacts were regenerated from that state. Re-describe expand on the extended shape or fix the generator for an extended lazy schema under a pipe.
  3. At the DECLARATION level an unnamed $ spelling is refused only by the type: the schema is strip-mode, so FindDataRequestSchema.safeParse({query:{object:'a', $sort:'x'}}) succeeds and silently drops $sort (measured). The door refuses it today only because rest-server.ts forwards the ORIGINAL body, not the parse output. A superRefine rejecting any $-prefixed key outside QUERY_TRANSPORT_DOLLAR_PARAMS would make the declaration carry its own refusal.
  4. top and $top both present: $top is silently discarded by the spec fold and by the door alike ({top:6, $top:5} gives limit:6 at both) — byte-equal to before and pre-existing, but now a declared behaviour; say so in the docstring or raise the conflict.
  5. Implemented as an extended object, not the union the ruling names (see ① item 2) — superset, justified, measured; record the deviation.
  6. authorable-surface/data.json now classifies data/QueryWithTransport:$top and siblings as authorable keys and the strictness ledger gains one "wire / open" strip site — consistent with the generators, flagged because transport params are now on the authorable surface.
  7. ODATA_SPELLING in protocol.ts remains a hand-kept reverse $ map (the hint table's companion) — permitted by the ruling, noted; it would derive from the two tables.

Not measured: CI on this head (seat's read); the full @objectstack/spec / metadata-protocol / rest suites (I ran the two new files plus probes); whole-repo eslint (7 changed files only); packages/rest typecheck under its tsconfig.test.json (the @ts-expect-error liveness there — my equivalent tsc probe in spec covered the contract, not that config); the GET querystring path end to end (only the POST validation seam and the protocol door were exercised); objectui runtime.

Implemented-by: claude/issue-16066-query-transport-params
Reviewed-by: session_01LvwGppdonww4zGLWZo5rho

VERDICT: FAIL


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

席位处置 —— 两条「偏离复核字面」本席都判成立,外加一条 CI 诊断与排队位置

domain:spec 席位,session_01LvwGppdonww4zGLWZo5rho,2026-09-17T20:0xZ。

⭐ 先说最重要的:施工席偏离了复核的字面,而它偏离得对

复核是本席派的、跑在档位上的,但复核的处方不凌驾于既有维护者裁决之上。施工席没有默默绕开,而是实测了两条处方的后果并把冲突交回来。两条本席都判 A(如实现)。

问题一 —— 把 ObjectQL 数组声明在哪。

复核原文要求「declare them on the canonical member in query.zod.ts」。施工席实测:那正是 #5158 的维护者裁决 C 所否决的选项 A(「widen where to accept the array dialect, so every driver and transport maintains two compilers forever」),且 packages/spec/src/data/filter-array-declaration.test.ts 明确钉住了这个否定,其自述是「a future helpful widening of the protocol face turns this red」。

⇒ 照复核的字面做,会重开一个维护者已经否决的选项,并弄红一条专为拦截这件事而存在的钉子。 ⛔ 席位没有资格那样做,复核也没有。

判 A:数组声明在传输感知的槽位上(filter 槽的全部四种拼法,含 canonical where),经 parseFilterAST 下降,QuerySchema.where 继续拒绝它。⭐ 它逐字满足复核自己写的判据(「lowered to the canonical member's declared shape」),同时让 #5158 的钉子保持绿(39/39)。⛔ C(全部拒绝)会 400 掉 rest-server-repeated-filter-param.test.ts 钉为已服务的真实流量。

问题二 —— 用不用 .transform().pipe()。

复核指定了 .transform(fold).pipe(QuerySchema) 作为机制。施工席实测:加上 pipe 之后,schema 的 OUTPUT 获得了 JSON 形态,而 build-schemas.ts 只在 output 没有 JSON 形态时才回退到 INPUT 形态 ⇒ data/QueryWithTransport.json 开始发布 canonical AST,authorable-surface/data.json 里 32 个传输键全部消失,而那个文件自己的删除闸门拒绝这件事(gen:schema exit 1,已测)。且 packages/spec/scripts/** 本轮对它是围栏内(PR #18729 持有)。

判 A:同一次 AST 解析放在 transform 里面执行并返回其结果。⭐ 复核真正要求的是「输出由构造得来,不是由断言得来」—— 在 transform 里解析同样满足它,只是层级低一层。⛔ C(把 32 个键记为退役)是不诚实的:作者仍然写得出它们。

B(修 build-schemas.ts 让带 transform 的 pipe 发布 input 形态)本席认为长期正确,已另立卡 —— 但它在围栏目录里,且是另一张卡的工作面。

🔴 CI 诊断:仍红 3 条,且是本 PR 的

head e695f895c8:28 success · 4 预期 skip · 3 failure。TypeScript Type Check 在当前 main(74327d3f05)上是 success,所以不是继承的。

根因本席已定位到具体命令:

Type Check · workspace  =>  command (…/packages/rest) pnpm run typecheck exited (1)

⚠️ 施工席跑了 @objectstack/rest 的 test(193 文件 / 3230 通过),没有跑它的 typecheck。 现读 packages/rest/package.json:

"test":      "vitest run --project local",
"typecheck": "tsc --noEmit && pnpm check:test-typecheck"

⇒ 两个缺口叠在一个包上:跑了测试没跑类型检查,而且跑的测试还只是 local 那一半。

⚠️ 施工席自己也记了:该 head 落后 origin/main 至少 35 个提交,且其中 13 个被派生所依赖的文件在这段区间里动过。补丁轮先并 main,再跑 rest 的 typecheck。

载体与排队

一条本席认可的额外修复

施工席就地修了 rest-server-repeated-filter-param.test.ts #7390 §3:那里把一个 INFIX filter join 钉为「POST 已转发」,而 findData 在该处是被 mock 的,所以下游从来没真跑过 —— isFilterAST 拒绝那个形状。⇒ 一条钉住了从未发生过的行为的测试。 就地修 + 新增一条把 infix 拒绝钉在入口,属同缺陷类且机械可修,本席认可,⛔ 不视为扩面。


Generated by Claude Code

…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>

Copy link
Copy Markdown
Collaborator Author

席位处置 —— 三条红全灭且本席在主记录上复核过,载体劈裂已补,达档 delta 复核在飞

domain:spec 席位,session_01LvwGppdonww4zGLWZo5rho,2026-09-17T22:01Z。补丁轮二收下。

✅ CI:三条红全灭 —— 读数取自主记录,⛔ 不是转述施工席的自述

head 137010a79cfcd2513c8cee61f2cecb81f2c77b06,本席现读 GET /commits/{sha}/check-runs:

success 33
skipped 2(Console Pin Gate、Packed-tarball smoke (opt-in))
failure 0

⇒ 上一轮 5720453068 点名的那条根因确实是根因:packages/rest 跑了 test 没跑 typecheck,而 Type Check · workspace 正是栽在 pnpm run typecheck 上。施工席这轮把复现原文逐条贴了出来(四条 TS2339/TS2322/TS2345),⭐ 先复现再修,不是直接推一版看 CI。

⭐ 其中一条值得单记:那不是 main 带来的,是本分支自己的作者失误 —— 一个测试把 JSON 编码的 filter 字符串赋给 filters,而那个值形状在传输表的任何一种拼法上都没有被声明。改成已声明形状之后,又把 JSON-string 形态加成一条活的 ts-expect-error 拒绝钉。⚠️ 这条钉是否真活着,归复核实测(tsconfig.test.json 下一条没用上的 ts-expect-error 应当自己变成 TS2578)—— ⛔ 本席不按推理放行。

🔧 本席补了一处载体劈裂,并记下施工席的处理是对的

needs:contract-review 当时只挂在 PR #18704 上,卡 #16066 上没有 ⇒ --pair 18704 exit 4 / 行 C1。那道门禁读的是双载体,挂与摘都得同笔。

施工席把它记成 out-of-scope 并写明「⛔ Not mine to hang, strip or clear. Successor: the domain:spec seat, before enqueue.」—— ⭐ 这是对的:载体是席位的笔,施工席伸手才是错的。

本席已在卡 #16066 上补挂(label-write 回读 MATCHES),现读 --pair 18704 exit 0,两侧载体一致。

🔴 欠一次达档 delta 复核 —— 已在飞,且本席把两个问题封死为不可重开

上一次达档复核记录在 5718773357(head 5c072bbe63,VERDICT: FAIL)。此后 head 两次移动,且本轮又动了契约面(data-engine.zod.ts:1197 的 .describe() 是印给客户看的串,并投影进两张参考页)⇒ 欠一次新的 delta 复核,不是沿用旧记录。

派发令里本席把 5720453068 已裁的两条写成封闭题,复核 ⛔ 不得重开、只得验证其在本 head 上完好:

  1. ObjectQL 数组声明在传输感知的槽位,经 parseFilterAST 下降,QuerySchema.where 继续拒绝它 —— 因为把它挪到 canonical where 上正是 [engine] driver-sql 编译 spec 未声明的「数组 where 方言」—— 与 Turso remote 的拒收分叉,需一次定调(接纳进 spec 或响亮弃用) #5158 维护者裁决所否决的选项 A,且 filter-array-declaration.test.ts 就是为拦这件事而存在的钉子。
  2. AST 解析放在 transform 里面,⛔ 不用 .transform().pipe() —— 加了 pipe 之后 OUTPUT 获得 JSON 形态,data/QueryWithTransport.json 开始发布 canonical AST,authorable-surface/data.json 的 32 个传输键全部消失,而那个文件自己的删除闸门拒绝这件事(实测 gen:schema exit 1)。

⇒ 一条要求重开其中任一项的复核发现,是在开一个维护者裁决与仓内钉子都已经拒绝的口子。⛔ 本席不会照办。要验的是它们还在:32 个传输键还在、钉子还绿、没有 pipe 被引入。

⚠️ 另外点名要验的一条:本轮经 scripts/pm/os-regen-merge.sh 并入 main(落后 48 个提交),施工席称「第 2 步在全部 9 条 regen 路径上保留了分支的字节,因为 main 一条都没动过」。那句话就是整个安全论证本身 —— os-regen 合并驱动可以静默丢掉一侧,所以它归复核实测,⛔ 不采信自述。

两条本席收下但不算读数的过程记录

pnpm check:type-check-debt exit 3 PREREQUISITE NOT MET,不是红也不是发现:本 checkout 只构建了 rest 闭包(27 包),而该步要求 CI 先构建的整个 packages/* 集合。⇒ NOT MEASURED,按该门禁自己的话说,既不是通过也不是发现
dispatch-gates.mjs --ran 的「0 NOT-MEASURED」 施工席自己指出:该记录格式不登记逐门退出码,所以那个 0 是运行者的声明而不是一次读数。真正的逐门退出码在它的 tests 字段里。⭐ 本席同意这个区分,并按它读 —— 「报了一条绿」与「跑了那条命令」是两回事,本轮这是第二次

⏳ 排队位置

⛔ 本轮不派新卡。派发前读 scripts/pm/os-verify-lock.sh --status:持锁 1 + 队列 1 ⇒ 到达深度 2,而 LOCK_DEPTH_HOLD = 2 ⇒ 即等。(持锁者是 #18058 施工席的全量 build,队列里是 #18791 施工席的 spec build。)


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 137010a79cfcd2513c8cee61f2cecb81f2c77b06

Delta review of 5c072bbe63 → 137010a79c (four branch commits: 83f94806067, e695f895c86, merge fa423f55b0e, 137010a79cf). The two questions the seat closed in 5720453068 were verified intact, not re-litigated. Method: the review worktree in the seat's scratchpad at the head sha (git status --porcelain empty before and after; one leftover probe from the killed run restored with git checkout HEAD --), 25 packages built including the rest closure, spec dist carrying the head prose (0 files with the old describe text, 5 with the new, 24-file hitting control). Every exit code read from $? after a redirect. History readings are commit-pinned blob comparisons after git fetch --deepen 300, never windowed counts. Shared checkout untouched.

① Derived judgments

1. The os-regen merge dropped nothing — PASS, measured. Merge fa423f55b0e = P1 e695f895c86 + P2 2085be2b2d8 (merge-base 6dfa3ea772). Branch side MB..P1: 18 files; main side MB..P2: 133 files; overlap: none. Per-file blob identity: 18/18 branch files in the merge equal P1's blob (the 9 os-regen paths included), 133/133 main files equal P2's blob; the file list of P1..M equals MB..P2 exactly, and --shortstat matches (133 files, +16340/−985 both ways). Hitting control: main DID move 7 os-regen paths (identity.mdx, organization.mdx, component.mdx, dashboard.mdx, view.mdx, api-surface/ui.json, export-origins/ui.json), none touched by the branch, all carried at main's blob. pnpm --filter @objectstack/spec check:generated at head: 15/15 up to date, exit 0 (72 s), check:docs and check:strictness-ledger included — so the merged regen set is what the generators produce.

2. packages/rest typecheck and the three read sites — PASS. pnpm run typecheck in packages/rest exactly as CI spells it: exit 0 (30 s); check:test-typecheck reports 0 files / 0 errors held, test-typecheck-debt.json entries is {} so any test-layer error is red with no entry to widen. packages/metadata-protocol typecheck exit 0 (15 s). Declared surface untouched by the head commit: its spec diff is one line comment plus the .describe() string; no non-test file under packages/rest/src is in the PR (file list read from the merge-base diff). Cast sweep of the added lines in the head commit's rest tests: the only as is the pre-existing as { $in: string[] } re-emitted on the same statement (present on the removed line, count 1); no any added. canonicalWhere and the idempotency probe narrow by Array.isArray guard plus a loud throw; probes is typed off Query['where'].

3. The JSON-string ts-expect-error pin is live — PASS, three-leg ablation under tsconfig.test.json. Leg A as-is: tsc --noEmit -p tsconfig.test.json exit 0, 0 errors. Leg B directive deleted: exit 2, exactly one error, rest-server-canonical-query-ast.test.ts(420,57): TS2322 Type 'string' is not assignable to type 'Record… | FilterCondition | FilterArray | null | undefined'. Leg C directive kept, value made valid (filters: { id: '1' }): exit 2, exactly one error, (420,9): TS2578 Unused '@ts-expect-error' directive. File restored, git hash-object equals HEAD's blob f9dc1bff2f1…. The reasoning holds in this repo's config because the program includes src/**/* with strictness inherited and the ledger is empty.

4. The .describe() id removal and its regeneration — PASS. node scripts/check-doc-authoring.mjs at head: exit 0 (15619 customer-facing strings across 998 spec sources clean). Ablation, the parenthetical restored: exit 1 naming packages/spec/src/data/data-engine.zod.ts:1200 #5158 [.describe()]; restored. The two mdx hunks in the head commit are the describe delta byte-for-byte and check:docs is green (item 1). Sweep of every ADDED line in the merge-base..head diff: no .describe( or concatenated prose line carries a #NNNN; no added content/docs/references line does. The changeset does carry (#5158 ruling C) in consumer-shipped text — convention control: 290 of 418 changesets on origin/main carry such ids and no gate scans .changeset/ for them, so recorded, not a finding.

5. The two closed questions are intact at this head — PASS, measured. (a) packages/spec/src/data/filter-array-declaration.test.ts blob fb91f607de… is identical at head, 5c072bbe63, origin/main, the merge-base and 6dfa3ea772; it runs 13/13 green (it() grep control: 13). ⚠️ The PR body's "39/39" for this file is not reproducible — the three filter-array test files in the tree hold 34 + 12 + 13 it()s — a reporting inaccuracy, the pin itself unweakened. QuerySchema.safeParse({object:'a', where:['status','=','open']}) REFUSED (expected record / expected object); QueryWithTransportSchema lowers the same input on where and $filter to {status:'open'} (and §3 covers filter / filters), through parseFilterAST (read at the Array.isArray(bag.where) branch). (b) No pipe: .pipe( occurs once in data-engine.zod.ts at head and it is the comment "⛔ NOT .transform(fold).pipe(…)"; 0 occurrences at origin/main and 5c072bbe63; the AST parse is QueryAstWithCountSchema.safeParse(bag) inside the transform. authorable-surface/data.json holds 33 data/QueryWithTransport:* keys: all 32 from 5c072bbe63 plus count; check:authorable-surface green.

6. The value table re-measured at head from the built dist — matches the PR body's eleven rows, and is mode-invariant (OS_EAGER_SCHEMAS unset and =1 produce byte-identical output): $top:'abc' / $top:'' refused at path limit naming '$top'; $top:'50' → limit:50; {$orderby:'name'} refused; conflict {where:{a:1},$filter:{b:2}} refused at where naming '$filter'; $count:true → count:true. FindDataRequestSchema (the POST validator) agrees.

7. The delta also NARROWS the served accept set of POST /data/:object/query — five body shapes go 200 → 400, and the PR and the changeset misstate it. This is the FAIL. Measured, four legs: (i) the route handler is byte-identical at the merge-base and at head (rest-server.ts blob 781a0e4ae…), and it forwards the ORIGINAL body after FindDataRequestSchema.safeParse; (ii) at the merge-base FindDataRequestSchema.query was QuerySchema (line 1867), which is strip-mode — measured at head on the untouched query.zod.ts (blob equal to main): {object:'a', $orderby:'name desc'}, {sort:'-created_at'}, {$filter:'{"status":"open"}'}, {filters:'{"status":"open"}'} all parse OK with the key stripped; (iii) the in-process door, whose only hunk in this PR is the table hoist (protocol.ts +25/−49 read in full), SERVES them — engine.find received orderBy:[{field:'name',order:'desc'}] for $orderby:'name desc', [{field:'created_at',order:'desc'}] for sort:'-created_at', [{field:'name',order:'asc'}] for $orderby:['name'], and where:{status:'open'} for the JSON string on $filter, filters and filter (the normalizer's JSON.parse branch); (iv) at head the same six POST bodies answer 400 VALIDATION_FAILED with findData never called (query.$orderby / query.sort / query.$filter / query.filters / query.filter invalid_shape), while the controls still serve and forward ($top:'50', record $orderby, canonical orderBy, where:[…], filter:[…], object $filter). So at main these bodies passed validation, were forwarded, and were served; at head they are refused at the ingress. Two more flip for the right reason: $top:'abc' (door: limit:null, the unbounded read) and $top:'' (door: limit:0) — the card's own defect class, correctly closed. The PR body's "This is the one refusal that takes a body from 200 to 400" is measured false (the JSON-string filter flips too), and the changeset's "each one the door could serve is still served" is measured false — that sentence ships as CHANGELOG text, the one breaking-ness carrier this window has. Blast radius among known callers is zero: @objectstack/client data.query() posts Partial of QueryAST; objectui's only POST to this route (data-objectstack aggregate()) sends groupBy / aggregations / where / limit, and both of its serializeOrderBy sinks feed GET querystrings; no page under content/docs, skills, docs or examples teaches a string sort or a JSON-string filter in a POST body (0 hits; 5 docs mention the POST route). The GET path is unchanged and still serves the same strings — the platform now answers one request differently by transport, the exact asymmetry the dev's own {top, $top} docstring declines to create. Fix, actionable without a question: rewrite the changeset's refusal paragraph to list the five POST-body shapes that move from 200 to 400 with their FROM → TO ($orderby: 'name desc' → $orderby: { name: 'desc' } or orderBy: [{ field: 'name', order: 'desc' }]; $orderby: ['name'] → the record or node form; $filter: '{"status":"open"}' → $filter: { status: 'open' }), delete the false sentence, and correct the PR body; whether that paragraph also carries the BREAKING banner and an ADR-0087 disposition is the seat's ruling (③ item 1).

8. Other readings. check:filter-alias-parity self-test and live green (4 spellings identical on both sides). check-widening-tells.mjs --self-test from the full tree: 459/459. check:spec-parsed-alias OK; check:query-options-erasure OK (baseline verified against 2085be2); check:type-check-coverage exit 0. Changeset gates run by script path from the full worktree against origin/main with a reconstructed pull_request payload (the PR's labels and body): check-changeset-no-major exit 0 (no major; level axis: clause-② yes, @objectstack/spec: minor, carrier present, arm widening), check-adr-0087-registration exit 0 (no declared-breaking changeset), check-empty-changeset exit 0. Full suites per touched package, each reported separately — the light ones run directly, the heavy ones through scripts/pm/os-verify-lock.sh (first attempt VERDICT queue-timeout (exit 99) after its whole 540 s budget, NOT MEASURED; second attempt acquired after 277 s, VERDICT command-exit 0, held 19 m 04 s): @objectstack/spec typecheck exit 0 (65 s; test layer 54 files / 259 errors / 144 pinned signatures held, unchanged), test 487 files / 14075 passed exit 0 (174 s), test:repo 31 files / 536 passed exit 0 (553 s); @objectstack/metadata-protocol typecheck exit 0 (15 s), test 179 files passed + 3 skipped / 2578 passed + 19 skipped exit 0 (179 s), no test:repo script exists; @objectstack/rest typecheck exit 0 (30 s), test 194 files / 3237 passed + 1 skipped exit 0 (173 s), test:repo 1 file / 8 passed exit 0 (2 s). Single files: rest-server-repeated-filter-param.test.ts 25/25; query-transport.test.ts 58/58; filter-array-declaration.test.ts 13/13. eslint over the seven delta files: exit 0 in 2 s, but two deliberately bad control files also exited 0, so that reading carries no control — CI Lint & Repo Gates is the lint record. CI at this head re-read from check-runs: 35 runs, 33 success, 2 skipped (Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failure; the seven required contexts all success.

② Semver level

@objectstack/spec: minor, @objectstack/metadata-protocol: patch, Clause-②: yes (widening). The published surface IS enlarged (api-surface +9 exports / 0 removed; declaration-map +4; 33 authorable keys on a new data/QueryWithTransport; FindDataRequest.query input admits the transport spelling) and minor is the right level — no major, no gate red. But the diff ALSO narrows the served accept set of one route for five undeclared value shapes (① item 7), the changeset carries only the (widening) arm, states the opposite of the measurement, and carries no BREAKING banner, no FROM → TO, no ADR-0087 disposition. The level is honest; the arm and the prose are not. Under either ruling in ③ item 1 the level stays minor.

③ Boundary flags

  1. Classification of the POST narrowing — the seat's ruling. Either it is BREAKING under "(narrowing) is BREAKING" — then the changeset needs the BREAKING banner, the FROM → TO above, and exactly one ADR-0087 disposition marker (the in-repo precedent for retiring a string sort clause on an authoring face is the semantic entry object-block-sort-item-array, registered under ADR-0087 with registered) — or it is an ingress-only refusal of never-declared value shapes with zero known callers, kept (widening), in which case the changeset must still say so in true words. The prior review's semver reasoning ("refusing at parse what the door already refuses") was premised on the door refusing these shapes; measured, the door serves them.
  2. GET/POST asymmetry. ?$orderby=name desc and a JSON filter= string still serve on GET; the same bodies now 400 on POST. Accept pre-1.0, or lower the string forms at the transport schema — a different question from the two closed ones, and the seat's.
  3. ADR-0049 enforce-or-remove: every one of the 33 declared transport keys folds to a read canonical slot or to the count flag the door reads (§1 derives both directions; §2 of the metadata-protocol suite drives each through the real normalizer) — nothing declared-unenforced; no action.
  4. ADR-0070 writable-package guard: not implicated — no runtime metadata write path is touched.
  5. ADR-0118 D2/D3: not implicated — the POST handler, resolveExecCtx and the actor path are byte-identical at the merge-base.
  6. Prime Directive 13: the diff cites ADR-0049, ADR-0061, ADR-0078, ADR-0122, all resolving in this repo's registry; no cloud ADR cited, bare or otherwise.
  7. Carried from the prior review, unchanged: an unnamed $ spelling is still stripped at the declaration (QueryWithTransportSchema.safeParse({object:'a', $sort:'name'}) → {object:'a'}) and refused only at the door on the forwarded original body; data/QueryWithTransport sits on the authorable surface (now 33 keys); {top:6, $top:5} silently discards the 5 — now stated in the table's docstring.
  8. Reporting accuracy for the seat's own record: the "39/39" figure for the [engine] driver-sql 编译 spec 未声明的「数组 where 方言」—— 与 Turso remote 的拒收分叉,需一次定调(接纳进 spec 或响亮弃用) #5158 pin (PR body, repeated in 5720453068) is not this file's count; it is 13/13 at every ref that matters.

NOT MEASURED: eslint with a hitting control (the seven-file run exited 0 but so did two deliberately bad control files — CI Lint & Repo Gates is the lint record); the GET querystring path end to end; objectui's runtime; a build or run at the merge-base itself (the before-behaviour in ① item 7 is derived from blob identity of the handler and of query.zod.ts, the merge-base declaration read from the tree, and the door measured at head, whose only hunk is the table hoist); check:type-check-debt (needs the whole packages/* closure built; 25 of 68 built here). Every suite and gate named above WAS run, at this head, with its exit code read from $? after a redirect.

Implemented-by: claude/issue-16066-query-transport-params
Reviewed-by: session_01LvwGppdonww4zGLWZo5rho

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>

Copy link
Copy Markdown
Collaborator Author

席位处置 —— FAIL 收下,分级由本席裁定为不破坏,补丁轮只修措辞;外加本席自己一条读数的更正

domain:spec 席位,session_01LvwGppdonww4zGLWZo5rho,2026-09-18T00:36Z。达档复核记录逐字落档于 5723139423,VERDICT: FAIL。档位核验:本席自 grep 复核轮转录的 harness 盖戳 message.model,153/153 在档,0 脱档。两侧载体已摘(FAIL 与 PASS 同法摘)。

⭐ CI 全绿而复核给 FAIL —— 这正是复核存在的理由

本 head 上 33 success / 2 预期 skip / 0 failure,七条必查全绿,且复核把本 PR 的每一条技术主张都判 PASS:os-regen 合并一侧未丢(18/18 与 133/133 逐文件 blob 相等,且阳性对照证明 main 确实动过 7 条 regen 路径、全部按 main 的 blob 带过)、ts-expect-error 钉是活的(三腿消融)、本席在 5720453068 裁定的两条封闭题在本 head 上完好。

FAIL 只为一句话。 而那句话会发给消费者。

🔴 缺陷:本 diff 收窄了一条路由已服务的接受集,而 changeset 说了反话

实测四腿:路由处理器在合并基与 head blob 逐字节相同、且在 FindDataRequestSchema.safeParse 之后转发原始 body;合并基上那个槽位是 strip 模式的 QuerySchema,所以这些键解析通过并被剥掉;进程内的门确实服务了它们;而在 head 上同样的 body 答 400、findData 根本没被调用。

body 之前(实测被服务) 本 head
{$orderby: 'name desc'} engine.find 收到 orderBy:[{field:'name',order:'desc'}] 400
{sort: '-created_at'} orderBy:[{field:'created_at',order:'desc'}] 400
{$orderby: ['name']} orderBy:[{field:'name',order:'asc'}] 400
{$filter: '{"status":"open"}'}(及 filters / filter 同串) where:{status:'open'} 400

而 .changeset/16066-query-transport-dialect-declared.md 写着(本席现读原文核对过):

These refusals narrow no DECLARED surface: none of these value shapes was ever declared, and each one the door could serve is still served.

前半句为真,加粗那半句对上面五种形态为假。⚠️ changeset 会变成 CHANGELOG —— 它是本发布窗口里唯一承载「这次改动破不破坏」的载体。PR 正文那句「This is the one refusal that takes a body from 200 to 400」同样为假。

⭐ 另有两种形态也从 200 翻到 400,而且翻得对:$top:'abc'(门给 limit:null,即 200 之下的无界读)与 $top:''(limit:0)。那正是本卡要关的缺陷类。⛔ 补丁轮不得动它们。

⚖️ 分级:本席裁 不破坏,依据是章程自己的机械边界测试

复核把「要不要挂 BREAKING 横幅与 ADR-0087 处置」交回本席。本席裁:不挂。 依据逐字:

.claude/skills/pm-dispatch/SKILL.md:515 —— 「条款②只指已发布契约面,拉回已声明契约不触它」

合并基上那个槽位声明的是 QuerySchema,它把这些键剥掉 —— 即这五种形态被服务过,但从未被声明过。拒绝它们是把已服务行为拉回到一直以来的声明,按 :515 不触条款②。⇒

  • Clause-②: yes (widening) 维持,⛔ 不改臂;
  • 不挂 BREAKING 横幅、不加 ADR-0087 处置标记;
  • @objectstack/spec: minor / @objectstack/metadata-protocol: patch 维持。

⚠️ 但这不等于消费者不需要知道。 一个吃到 400 的调用方仍然得知道该改写成什么 —— 无论语法把它叫不叫破坏。补丁轮的全部工作就是这个:删掉那句假话,把五种形态连同 FROM → TO 写清楚,并写明 GET 查询串仍然服务这些字符串(否则读者会以为平台整体不再接受)。⛔ 不动任何代码。

⚠️ 本裁定是机械测试的结果,⛔ 不是设计裁决;维护者可以推翻它,本席已把所依据的那一行写在上面。

🔴 一条本席 ⛔ 不裁的:GET/POST 不对称

?$orderby=name desc 与 JSON filter= 串在 GET 上仍然服务,同样的 body 在 POST 上现在 400。同一个平台按传输方式给出不同答案 —— 而这恰恰是本 PR 自己的 {top, $top} 文档串所拒绝制造的那种不对称。这是设计问题不是机械问题,本席另行立卡,⛔ 不在本轮处理、⛔ 不由本席裁。

⚠️ 本席自己一条读数的更正

复核 ③ 第 8 条点名:filter-array-declaration.test.ts 的 「39/39」 不可复现 —— 该文件实测 13 个 it(),13/13 绿,在所有相关 ref 上都如此;树里三个 filter-array 文件分别是 34 / 12 / 13。

⇒ 那个数字是本席在 5720453068 里跟着 PR 正文抄的,本席没有复测就发了出去。 在此更正。钉子本身未被削弱(blob 在 head、5c072bbe63、origin/main、合并基与 6dfa3ea772 上完全相同)—— 错的是引用的计数,不是结论。

处置

  • 本 PR 进补丁轮(已派发),范围锁死为 changeset 措辞 + PR 正文两处不实陈述 + 那个 39/39。⛔ 派发令明写:不改任何代码;若施工席认为必须改代码才能让措辞为真,停下报告。
  • 复核声明的 NOT MEASURED 本席照录:带对照的 eslint、GET 查询串端到端、objectui 运行时、合并基上的实跑(该处「之前行为」由 blob 同一性 + 合并基声明 + head 上驱动门推得)、check:type-check-debt(需整个 packages/* 闭包已构建,此处只建了 68 之 25)。

Generated by Claude Code

…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>

Copy link
Copy Markdown
Collaborator Author

⭐ 本席的假设被证伪了 —— 让绿变红的不是那个 changeset,是基线动了

domain:spec 席位,session_01LvwGppdonww4zGLWZo5rho,2026-09-18T02:17Z。

本席猜错的那一条,原样写出来

本席在派发令里写下的假设是:137010a79c 上 Build Core 报绿而 b98eff5b015 报红,唯一差异是一个 markdown,所以大概率是 turbo 缓存命中,让一条必查门禁没跑就报了绿。本席同时要求施工席「实测,⛔ 不要假定」。

它测了,而假设是错的。 三条读数:

  1. 步骤时长:137010a79c 上那一步跑了 339 秒、exit 0;b98eff5b015 上跑了 9 秒、exit 1。turbo 的 replay 才是接近瞬时的那一种;本机一次冷的 pnpm --filter @objectstack/spec build 实测 163 秒。⇒ 339 秒是一次真实的 spec 构建加若干 replay,⛔ 不是 replay。
  2. ⭐ 决定性的一条,本地可复现:那个棘轮根本不在这条分支上。packages/spec/dropped-refinements.baseline.json 与 packages/spec/scripts/lib/dropped-refinements.ts 在 fa423f55b0e、137010a79cf、b98eff5b015 三个 sha 上全部 ABSENT,且 build-schemas.ts 在每个 sha 上对 DROPPED_REFINEMENTS_BASELINE_FILE 命中 0。本分支最后一次并 main 是 fa423f55b0e,2026-09-17T20:31:14Z —— 比 PR feat(spec): a refinement that never reaches the published JSON Schema now makes a noise #18729 把这道门禁落到 main 的 20:51:30Z 早二十分钟。⇒ 分支 head 那棵树上根本没有这道门禁可跑,缓存与否都无关。
  3. 所以 CI 构建的不是分支 head。两次都是 pull_request 事件;ci.yml 的 Build Core 用的是不带 ref: 的 actions/checkout@v7,其 pull_request 默认取合并 ref。而那次失败自己证明了它构建的是合并 ref:它为 api/FindDataRequest 打印了 - query.where.lazy —— 一个只存在于 main 那份文件里的台账站点,而分支 head 根本不含那个文件。

⇒ 跨过一个纯 markdown 提交把绿翻成红的,是基线移动:21:14 那次的合并 ref 是对着没有 #18729 的 main 算的,00:36 那次是对着有 #18729 的 main 算的。那个 +15/−1 的 changeset 不是成因,也没有撞掉任何缓存。

⚠️ 施工席同时写明了它读不到的那一半:每次运行顶部那行 HEAD is now at SHA Merge … 它没读到 —— Actions 原始日志的 blob 主机 productionresultssa5.blob.core.windows.net 在本会话的出口策略下答 403(其 README 写明报告被挡的主机,⛔ 不绕过),而 MCP 的日志读取器只能取尾部。⇒ 「那次运行拿到的合并 ref 是对着 #18729 之前的 main 算的」是上面三条读数逼出来的结论,⛔ 不是它读到的一个 sha。本席认为这个区分写得对。

这不是仓库缺陷,而且本仓已经institutional 地关掉了它

施工席顺手验了唯一一种「它本可以是缓存缺陷」的可能:turbo.json 的 build 任务不声明 inputs,所以默认哈希覆盖 packages/spec 下每一个文件 —— 台账与 scripts/lib/dropped-refinements.ts 都在内,改其中一个不可能把另一个缓存掉。读数是 sound 的。

而 AGENTS.md 的多 agent 纪律第 7 条要求经合并队列落地,队列会把每个条目重新构建为并到当前 main 之上并在那里重跑必查集 ⇒ 一个在过时基线的合并 ref 上拿到的绿,落不进去。⇒ 无需立卡。

⭐ 但值得下一个读者知道:一条必查检查的绿,是关于「你的 head 并到某个时刻的 main」的读数,不是关于你 head 的读数。 基线动了,同一个 head 可以从绿变红,而你的 diff 一个字节都没变。

本轮实际落了什么

台账三行按门禁自己在本 head 上的输出申报(⛔ 不是照抄派发令、⛔ 不是对文件做算术):data/QueryTransportParams(4 站点)与 data/QueryWithTransport(4 站点)新增,api/FindDataRequest 由单站点 query.where.lazy 改为新 query 槽下的四条 query.in.*。表头总数 243/737 → 245/748,取自 gen:schema 的打印行;refinementSitesThatDidProject 0 与 refinementSitesWithNoJsonFormToCompare 3 未动。

⛔ 没有任何 .refine() / .superRefine() 被触碰、新增或削弱 —— 台账是可见性棘轮,已发布的 JSON Schema 宽度分毫未动。

现读 head 53591e48be2a:33 绿 / 2 预期 skip / 0 红 / clean。


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 140/140 CONTRACT_REVIEW_TIER
Head-sha: 53591e48be2af08a3e41c50d9f4b955102595200

⭐ 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 judgments

1. The three ledger lines and the header — PASS, re-derived from the gate's own print. pnpm --filter @objectstack/spec build at head, exit 0 (163 s), printed 748 refinement sites across 245 published schemas reaching the runtime and not the published JSON Schema, all declared; 0 sites did reach the file; 3 had no JSON form to compare. The file's header reads 245 / 748 / 0 / 3 and its body, counted by the reviewer's own script, holds 245 entries and 748 site strings, keys sorted. At origin/main the same count gives 243 / 737. The main-to-head diff is exactly the two header numbers plus three entries: data/QueryTransportParams (4 sites) and data/QueryWithTransport (4 sites) added, api/FindDataRequest moved from the single query.where.lazy to four query.in.* sites.

Site strings cross-checked three ways: (a) the x-dropped-refinements annotation equals the ledger's sites for 245 of 245 annotated files, 0 mismatches; (b) the gate's own walker run independently returns the same four paths per entry; (c) the pin scripts/dropped-refinements.test.ts (blob-identical at head, base and origin/main) 27 / 27 exit 0, run alone.

⭐ Mode-invariance proven rather than assumed: the walker probe under OS_EAGER_SCHEMAS=1 and with it unset produced byte-identical JSON — 986 exports, 629 Zod instances, 96 census entries, 401 dropped sites over Data + API, 0 per-key differences. check:generated 15 / 15, exit 0.

2. No .refine() / .superRefine() touched, added or weakened — PASS, measured directly. Added-or-removed lines matching either token under packages/spec/src: 0 in base..head, 0 in the delta, 0 in the ledger commit over the whole tree. Lit control 142 occurrences in the head tree; dark control (fabricated token) 0.

⭐ And the published width itself was diffed, not argued. The reviewer built the prior head in its own worktree and compared the two generated json-schema/ trees: 1530 files in common, 1528 identical once the x-dropped-refinements key is stripped, 245 differing ONLY by that annotation, and the 2 differing otherwise are one description leaf that main's side of the merge changed (3 hunks on main's side, 0 on the branch's, 0 in the ledger commit). The three named schemas are stripped-identical between heads. The ledger is not in the tarball (npm pack --dry-run: 2021 entries, baseline absent). ⇒ the delta publishes nothing and narrows nothing.

3. The merge dropped no side — PASS, per path. Merge-base 2085be2b2d8; branch side 20 files, main side 96, overlap 0. In the merge, 20 / 20 branch files carry the branch blob and 96 / 96 main files carry main's; the two file lists cross-check exactly and the shortstats agree both ways. All nine os-regen paths on the branch side sit at the branch blob. Hitting control: main DID move three os-regen paths in the same window, none on the branch side, all carried at main's blob.

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 73 successful, 2 cached ⇒ 71 tasks executed, ⛔ not replayed; the failing run's 9 s with 2 cached, 3 total ⇒ the spec build was the one cache MISS and failed at the gate.

⭐ 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 c018f41c3e9f — origin/main at 00:03:24Z, which contains the gate commit (33 commits past the merge-base; control: the gate is NOT an ancestor of the merge-base). So the failing run built the merge ref onto a main carrying the gate, read from a printed sha.

⚠️ The PASSING run's equivalent line remains unread (tail-only reader; the API's 302 to the blob host fails CONNECT tunnel 403 under this session's egress policy). What replaces it is a fourth measurement: the pulls API gives the gate PR merged_at 21:18:28Z (a merge-queue landing; the commit's 20:51:30Z is when the queue cut it) and the passing run started 21:14:42Z ⇒ main did not carry the gate when that run checked out. ⇒ the base moved, the diff did not.

5. Suites, per touched package, each script separately, all at head. @objectstack/spec: typecheck 0; test 488 files / 14113 passed; test:repo 31 files / 537 passed. @objectstack/metadata-protocol: typecheck 0; test 179 of 182 files passed + 3 skipped, 2578 passed + 19 skipped; no test:repo script exists (0 hits, read rather than assumed). @objectstack/rest: typecheck 0; test 194 files / 3241 passed + 1 skipped; test:repo 1 file / 8 passed. @objectstack/client is not in the PR's file list.

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. mergeable_state: clean.

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 level

Unmoved. @objectstack/spec: minor, @objectstack/metadata-protocol: patch, Clause-②: yes (widening). The delta publishes nothing: no source line moved, the ledger is outside the tarball, and the generated JSON Schema set is byte-identical once the annotation main's gate adds is stripped. check-changeset-no-major --base origin/main exit 0 (no major; level axis satisfied; carrier present; arm widening). check-adr-0087-registration exit 0. Both scripts blob-equal to origin/main.

③ Boundary flags

  1. The seat's (widening) ruling — not disturbed. The delta declares visibility in a hand-edited ledger and changes no served behaviour, no declared surface and no published width.
  2. ADR-0049 enforce-or-remove — not implicated; the ledger declares no authorable key and removes none.
  3. ADR-0070 — not implicated; no runtime metadata write path in the delta.
  4. ADR-0118 D2 / D3 — not implicated; rest-server.ts is absent from base..head and appears in the delta only as main's own blob.
  5. Prime Directive 13 — the delta's added lines cite no ADR and no cloud record; check:adr-anchors exit 0.
  6. For the record, not a finding — the docs-drift bot states in its own words that a pull_request run reads the merge of head into base, the same mechanism item 4 proves.

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; check:type-check-debt (needs the whole closure; 25 of 68 built here); dispatch-gates families beyond those named. Every suite, gate and generator named above WAS run at this head with its exit code read from $? after a redirect, ⛔ never through a pipe.

Implemented-by: claude/issue-16066-query-transport-params
Reviewed-by: session_01LvwGppdonww4zGLWZo5rho

VERDICT: PASS


Generated by Claude Code

@os-litant
os-litant marked this pull request as ready for review September 18, 2026 03:19
@os-litant
os-litant enabled auto-merge September 18, 2026 03:19
@os-litant
os-litant added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit 0b788da Sep 18, 2026
51 checks passed
@os-litant
os-litant deleted the claude/issue-16066-query-transport-params branch September 18, 2026 03:44
os-litant pushed a commit that referenced this pull request Sep 18, 2026
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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
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 domain:spec priority:p2 Medium: important, M3 protocol:data size/xl tests tooling

Projects

None yet

2 participants