Skip to content

fix(metadata-protocol)!: the Data API refuses an empty projection and a count flag that is neither true nor false - #26811

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-3618c5
Oct 27, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-3618c5

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #26644

Clause-②: no (narrowing)
Metadata-protocol: none

BREAKING (accept-set narrowing, patch): the Data API refuses an empty projection and a count flag that is neither true nor false with 400 VALIDATION_ERROR, where it answered 200. ADR-0087 disposition in the changeset: not-required (no-migration-prescription).

维护者速读

  • 改了什么:数据列表接口遇到空的 $select(如 ?$select=、?$select=,)或不是 true/false 的 $count(如 ?$count=maybe),现在直接回 400 并点名参数和取值,不再当作「没传」处理。
  • 为什么改:以前空 $select 会返回全部字段,乱写的 $count 会照样统计总数,调用方拿到看似正常的 200,发现不了自己写错了。
  • 风险与回滚:只有本来就传了错值的调用会从 200 变 400;不传参数、传合法值的请求行为不变。回滚只需还原 packages/metadata-protocol/src/protocol.ts 的这次改动。
  • 先看:packages/metadata-protocol/src/protocol.ts(projectionFieldsValue、countFlagValue 两个函数及 findData/getData 里的调用点)、packages/rest/src/data-empty-projection-count-door.test.ts、packages/runtime/src/domains/data-empty-projection-count.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.empty-projection-unknown-count.test.ts,预期 37 个用例全绿。
  • 需要你做的:无,席位落地。

What changes

  • findData (the list normalizer every list door shares). A projection (fields, and its folded select / $select spellings) that names no field is refused: a blank or comma-only string, or an empty array. It used to become [], which assertProjectionFieldsExist has nothing to check and every driver serves as no projection, so every column came back under a 200. A count / $count that is not the boolean or the string true / false is refused; it used to stay unlowered, so the options.count === false opt-out never matched and the count ran. Both refusals are 400 VALIDATION_ERROR with param set to the spelling the caller wrote and a message quoting the value, thrown before the engine is asked.
  • The accepted count values are read from the spec, not restated. countFlagValue parses with QueryTransportParamsSchema.shape.count (@objectstack/spec/data): the boolean, the strings true / false, and null (absent). No packages/spec file is edited.
  • getData (GET /data/:object/:id) takes the same projection rule. Its comment promised "gated exactly as on the list path"; refusing the empty projection on the list alone would have made that false. A blank select was falsy and skipped, a comma-only one became []; both answered every column. It now asks the same helper on any value present.
  • Unchanged. Absent parameters keep their defaults (every column, the count runs). A named projection, with trailing commas and spaces trimmed as before, $count=true, $count=false and a JSON null answer as before.

Which doors reach the refusal (measured by reading every findData( / getData( caller on origin/main 0f3d068cf1, and pinned per door below):

door normalizer empty projection $count=maybe
RestServer GET /data/:object findData 400 VALIDATION_ERROR 400 VALIDATION_ERROR
RestServer POST /data/:object/query findData (after FindDataRequestSchema) 400 VALIDATION_ERROR already 400 VALIDATION_FAILED at the body schema, unchanged
runtime dispatcher GET /data/:object findData (via callData('query')) 400 VALIDATION_ERROR 400 VALIDATION_ERROR
runtime dispatcher POST /data/:object/query findData (after FindDataRequestSchema) 400 VALIDATION_ERROR the same FindDataRequestSchema gate as REST (not separately measured)
RestServer GET /data/:object/:id getData VALIDATION_ERROR at 404 (see Acceptance notes) n/a
runtime dispatcher GET /data/:object/:id getData (via callData('get')) 400 VALIDATION_ERROR n/a

The in-process callers (data-action-routes.ts job and export reads, import-runner.ts, connector-pull.ts, plugin-security, plugin-auth's user import) pass no projection or a named one; none sends an empty one or a count flag.

Tests

  • packages/metadata-protocol/src/protocol.empty-projection-unknown-count.test.ts (new, 37 cases): every refused spelling with code, status, param and the engine never reached; the preservation set (projection lists, count absent / true / false / null) green in both directions.
  • packages/rest/src/data-empty-projection-count-door.test.ts (new): both REST list routes and the by-id route over a real SqlDriver (SQLite): status, code, the named spelling, zero reads of the object, and the controls.
  • packages/runtime/src/domains/data-empty-projection-count.test.ts (new): the dispatcher's three doors through HttpDispatcher.dispatch() over a real store; a refusal leaves dispatch() and both host exits (declaredEnvelopeForThrow, resolveThrownHttpError) are asked and must agree.
  • Red first, on the base 0f3d068cf1 with every closure built from it: protocol 21 failed / 16 passed (all 21 refusals, every preservation green); REST 3 failed / 2 passed; dispatcher 8 failed / 1 passed (every refusal answered 200 with every column, or a total).
  • scripts/engine-double-contract.pinned.json: the new by-id double's findOne opens with assertEngineFindOnePredicate; --write recorded it.

Acceptance notes

  • REST by-id answers 404, not 400. crud-routes.ts's GET /data/:object/:id catch rewrites every 400 to 404 ("a bad id is a miss"), so every projection refusal on that route, an unknown select field included (?select=nope answers 404 INVALID_FIELD, measured in the door test), already answers 404. The empty projection joins that answer with code VALIDATION_ERROR; the dispatcher's by-id door answers 400. Changing the rewrite is outside this card (reported).
  • The spec's HTTP declaration is wider than the door. HttpFindQueryParamsSchema.count is z.coerce.boolean() (it reads 'false' as true and admits 'maybe'), and its select is z.string() (admits a blank). It has no runtime reader in this tree; it ships as json-schema/api/HttpFindQueryParams.json and the reference docs. The door reads QueryTransportParamsSchema.count, which is exact. packages/spec is outside this lane (reported).
  • A client SDK caller passing select: [] (packages/client/src/index.ts serialises it as select=) now gets the 400 instead of every column. That is the refusal working as intended; no in-repo caller passes an empty list.
  • Code choice. The blank $top refusal this mirrors answers INVALID_REQUEST (a ledger code); these answer the standard-catalog VALIDATION_ERROR, as the dispatch ruled.

Verification

All readings at the final head 28a10e6f8b unless stated; exit codes captured before any pipe; heavy runs under scripts/pm/os-verify-lock.sh (its VERDICT line quoted).

Package suites and typecheck
  • pnpm build (full, at 27848a9b, the fix's source identical to the head): VERDICT command-exit 0.
  • pnpm --filter @objectstack/metadata-protocol test: exit 0 · Test Files 270 passed, 3 skipped · Tests 21653 passed, 19 skipped. typecheck: exit 0. The new file re-run at the head after its double change: 37/37.
  • pnpm --filter @objectstack/rest test: exit 0 · Test Files 327 passed · Tests 6503 passed, 326 skipped. typecheck: exit 0.
  • pnpm --filter @objectstack/runtime test: exit 0 · Test Files 460 passed · Tests 7457 passed, 19 skipped. typecheck (with check:test-typecheck) at the head: exit 0; the new door file 9/9.
  • eslint --no-inline-config packages/metadata-protocol packages/rest packages/runtime: exit 0.
Red first, on the base `0f3d068cf1`
  • protocol.empty-projection-unknown-count.test.ts: Tests 21 failed, 16 passed — every refusal red (accepted, answered), every preservation green.
  • data-empty-projection-count-door.test.ts: 3 failed, 2 passed — e.g. $select= blank (the card): 200 with all columns label, note and the system columns; select="" by id: 200, whole record.
  • data-empty-projection-count.test.ts (dispatcher): 8 failed, 1 passed — each refused request answered {"status":200,"body":{"success":true,…}}, every column or a total.
Dogfood (the narrowing arm)
  • pnpm --filter @objectstack/dogfood test at 25a8556236: VERDICT command-exit 0 · Test Files 302 passed, 3 skipped · Tests 2507 passed, 20 skipped · no failures. The head 28a10e6f8b differs from it only in packages/runtime/src/domains/data-empty-projection-count.test.ts (a type annotation in a test outside the dogfood suite).
Derived gates
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, then every family run at 28a10e6f8b: 70 derived, 70 run, all exit 0. --ran reconciliation: 70 derived famil(ies) accounted for — 70 run, 0 NOT-MEASURED.
  • One red on the first pass, fixed: check:engine-double-contract pinned the new by-id double's findOne (now opens with assertEngineFindOnePredicate; ledger recorded by --write).
  • test:repo (vitest repo projects) not run locally: no repo-project test or census file is in the diff; CI Test Core runs it.

Generated by Claude Code

… refused on every list door (red)

Claude-Session: https://claude.ai/code/session_01FEkQ5kRR5RttkLiWWAkDA5
Co-authored-by: Claude <noreply@anthropic.com>
…hat is neither true nor false

The list normalizer read both as the absent default: a blank or comma-only
$select became [] and was served as no projection (every column), and a
$count other than true/false stayed a string the opt-out never matched, so
the count ran. Both are now refused 400 VALIDATION_ERROR naming the parameter
and the value, in findData, which every list door shares; getData's select
takes the same projection rule. The accepted count values are read from the
spec's QueryTransportParamsSchema.count.

Claude-Session: https://claude.ai/code/session_01FEkQ5kRR5RttkLiWWAkDA5
Co-authored-by: Claude <noreply@anthropic.com>
…ing projection-refusal status

Claude-Session: https://claude.ai/code/session_01FEkQ5kRR5RttkLiWWAkDA5
Co-authored-by: Claude <noreply@anthropic.com>
…d count flag refusals

Claude-Session: https://claude.ai/code/session_01FEkQ5kRR5RttkLiWWAkDA5
Co-authored-by: Claude <noreply@anthropic.com>
… engine predicate, and pin it

Claude-Session: https://claude.ai/code/session_01FEkQ5kRR5RttkLiWWAkDA5
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 13 documentable anchor(s).

27 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 c8583f80ac47177421c9c1fb71e5e33e431ad9cf.

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

What this run could not see
  • the SDK route bridge reached 55 of 207 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 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 c8583f80ac47177421c9c1fb71e5e33e431ad9cf → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ad522bc4c40197647b519187de311f4937edbc12 — the merge of head 28a10e6f8b29849c8cf41595bc7ed213f9081972 into base c8583f80ac47177421c9c1fb71e5e33e431ad9cf, 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 ad522bc4c40197647b519187de311f4937edbc12 && git checkout ad522bc4c40197647b519187de311f4937edbc12
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c8583f80ac47177421c9c1fb71e5e33e431ad9cf 28a10e6f8b29849c8cf41595bc7ed213f9081972 && git checkout -B drift-repro c8583f80ac47177421c9c1fb71e5e33e431ad9cf && git merge --no-ff 28a10e6f8b29849c8cf41595bc7ed213f9081972

node scripts/docs-audit/affected-docs.mjs --json c8583f80ac47177421c9c1fb71e5e33e431ad9cf

⚠️ 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 c8583f80ac47177421c9c1fb71e5e33e431ad9cf → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 28a10e6f8b29849c8cf41595bc7ed213f9081972
Local-runs: none
Scope: packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/protocol.empty-projection-unknown-count.test.ts, packages/rest/src/data-empty-projection-count-door.test.ts, packages/runtime/src/domains/data-empty-projection-count.test.ts, scripts/engine-double-contract.pinned.json + packages/spec/src/data/data-engine.zod.ts (QueryTransportParamsSchema, TransportCountValueSchema, QueryWithTransportSchema, foldQueryAliasSlots), packages/spec/src/api/protocol.zod.ts (FindDataRequestSchema, HttpFindQueryParamsSchema, GetDataRequestSchema), packages/spec/src/shared/lazy-schema.ts, packages/metadata-protocol/src/protocol-query-validation.ts (assertProjectionFieldsExist), packages/rest/src/crud-routes.ts, packages/rest/src/data-action-routes.ts, packages/runtime/src/domains/data.ts, packages/runtime/src/action-execution.ts, packages/core/src/utils/import-runner.ts, packages/services/service-automation/src/connector-pull.ts, packages/client/src/index.ts, scripts/check-engine-double-contract.mjs, scripts/pm/clause2-line.mjs, scripts/check-changeset-no-major.mjs, .github/workflows/ci.yml; the changeset .changeset/26644-empty-projection-count-flag.md read for ② only.

Inputs read at 2026-10-27T17:15Z: card #26644 body and its three comments (triage 6441434576 at 2026-10-27T12:04Z, claim 6446149768 at 2026-10-27T15:30Z, os-dev-report 6448504462 at 2026-10-27T17:13Z); PR #26811 body, its six-file list, and git diff origin/main...28a10e6f8b (6 files, +683 / −11; merge-base 0f3d068cf1, PR base c8583f80ac); the 34 check-runs on the head. PR author is objectstack-fleet[bot], assignee os-project-manager. Every cited file was read with git show at the head; nothing was checked out, built or run.

Check-runs on the head at read time: completed success — Auto Label, Check PR Size, Check Changeset, Flag docs affected by code changes, PR-body guards, Governed Surface Queue Guard, Lint · PM dispatch-gates, filter, Test UI (aggregate; its shards skipped), Dogfood Regression Gate (aggregate; its three shards and Dogfood Verify CLI skipped — by design on pull_request, ci.yml lines 118-137: the gate concludes success on zero attestations, so CI ran NO dogfood on this head). Skipped — the UI family, Build UI Deps, Build Docs, Temporal Conformance, the two opt-in harnesses. STILL IN PROGRESS (13) — Build Core, Plan Test Core shards (so no Test Core shard exists yet), Type Check · workspace / source gates / consumer gates / debt ledger, Lint · ESLint, Lint · docs & code guards, Lint · CI release & engine gates (the engine-double-contract gate lives there), Lint · repo & tooling gates, Lint · scoped · tooling self-tests / query-options erasure / slot lookup & corpus. Their conclusions are the gate verdicts; this record does not pre-empt them.

① Derived judgments

Accept-set changes the diff implies, each judged:

  1. findData projection (fields, folded from select / $select), all transports. Before: a string was split(',').map(trim).filter(Boolean) and an array passed untouched, so '', ' ', ',', ' , ,' and [] all became [], which assertProjectionFieldsExist returns on (protocol-query-validation.ts: if (!Array.isArray(fields) || fields.length === 0) return;) and every driver serves as no projection. After: projectionFieldsValue refuses a result naming no field, 400 VALIDATION_ERROR, param = the spelling written (slotParam('fields'), the REST 列表:sort / select / expand 指向不存在的字段时被静默丢弃(filter 轴已收口,这三条轴还没有) #4226 composition of wireSpelling and arrivedAs), before engine.find / engine.count. A non-empty list is split and trimmed exactly as before and still goes through assertProjectionFieldsExist. Absent, undefined and null keep their default (every column). Accept set after is a strict subset of before. Right, and it is the card's own prescription (the pageWindowValue precedent on the column axis).

  2. findData count flag (count, folded from $count), all transports. Before: only the strings 'true' / 'false' were lowered; any other value stayed as it was and countOptOut = options.count === false read it as "count". After: countFlagValue parses with QueryTransportParamsSchema.shape.count, which is TransportCountValueSchema.nullable().optional() = z.union([z.boolean(), z.enum(['true','false'])]) (data-engine.zod.ts:908, :1047), and is asked only when options.count != null, so null / absent never reach it and keep their default (count runs). Accepted: true, false, 'true', 'false'. Refused: 'maybe', '', '1', 'TRUE', 0, 1, anything else — 400 VALIDATION_ERROR, param = $count or count as written. The spec is read, not restated: lazySchema is a Proxy that resolves on first property access (.shape named in its own doc, lazy-schema.ts:20-30), so .shape.count.safeParse is the real schema. No packages/spec file is edited. Right. distinct keeps its old lowering only ('true' / 'false'), untouched — it is a retired key at the HTTP declaration and not this card's parameter; noted, not a finding.

  3. getData projection (GET /data/:object/:id, select). Before: if (request.select) — a blank string was falsy and skipped (every column), a comma-only one became [] (every column). After: if (request.select != null) and the same helper on a string or an array, param fixed at select (the only spelling that route accepts: REST DATA_RECORD_READ_PARAMS, dispatcher's allowlist at data.ts:268-270). Wire-reachable change: '', whitespace, ',' are refused. Right — the route's own comment (REST 列表:sort / select / expand 指向不存在的字段时被静默丢弃(filter 轴已收口,这三条轴还没有) #4226, "gated exactly as on the list path") would otherwise have become false, and the card's mechanism paragraph names the shared normalizer. One non-wire edge, not a finding: an in-process caller (action-execution.ts:546, select: params.select) passing a falsy non-string such as false or 0 used to be skipped and now rides to queryOptions.fields, where assertProjectionFieldsExist returns on a non-array and drivers read a falsy fields as no projection — the same answer (every column) either way; a querystring cannot deliver it.

  4. Every door that takes $count / count or a projection (seat question 2). Read every .findData( / .getData( caller outside tests at the head:

    • RestServer GET /data/:object (crud-routes.ts:158-164): the whole req.query to findData → both refusals, mapDataError keeps the 400. Agrees.
    • RestServer POST /data/:object/query (crud-routes.ts:347-367): FindDataRequestSchema.safeParse first. QueryWithTransportSchema extends QueryTransportParamsSchema.shape (data-engine.zod.ts:1379-1382), so count: 'maybe' is refused there as 400 VALIDATION_FAILED (pre-existing, unchanged); select: '' (z.string()), $select: ',' and fields: [] (z.array) pass the schema and are refused by findData. Agrees. Two codes for the count family at this one door (VALIDATION_FAILED at the schema, VALIDATION_ERROR at the normalizer) is the pre-existing split the $top window already has; not widened here.
    • runtime dispatcher GET /data/:object (data.ts:409-433): querystring rides verbatim to callData('query') → protocol.findData (action-execution.ts:703-709). Agrees.
    • runtime dispatcher POST /data/:object/query (data.ts:200-260): the same FindDataRequestSchema gate, then callData('query') → findData. Agrees (same shape as REST; the count arm is schema-refused on both).
    • runtime dispatcher GET /data/:object/:id (data.ts:263-274): select allowlisted, callData('get') → getData. Agrees, 400.
    • RestServer GET /data/:object/:id (crud-routes.ts:179-229): getData, but the catch rewrites every 400 to 404 — see item 5.
    • GET /data/:object/export (data-action-routes.ts:1256-1285) builds a canonical AST with no fields and no count; the job reads (data-action-routes.ts:440-447, :845-853), import-runner.ts (findArgsBase spreads the caller's request; its three call sites name no projection), connector-pull.ts:343 and the in-process action path pass no projection or a named one. None reaches either refusal. The PR body's door table is complete and correct; the export door it omits is unaffected.
    • No other reader of $count exists in any packages/*/src outside spec and tests (the only hit is a driver-memory aggregation-stage comment). ODataQuerySchema.$count (z.boolean()) is consumed only by OData.buildUrl, not a door. No runtime door is still lenient. The one surface still wider than the door is a published DECLARATION, not a door: HttpFindQueryParamsSchema (protocol.zod.ts:2367-2385) declares count: z.coerce.boolean() (admits 'maybe', and reads 'false' as true) and select: z.string() (admits a blank); it has no runtime reader at the head (grep: only CHANGELOG prose and two comments cite it). That drift pre-exists this PR and is in packages/spec — escalated in ③, not a defect of this diff.
  5. REST by-id answers the empty projection at 404 (seat question 3). res.status(mapped.status === 400 ? 404 : mapped.status) is at crud-routes.ts:229 on origin/main byte-for-byte; the diff does not touch crud-routes.ts. On main, ?select=nope on that route already answers 404 INVALID_FIELD through the same line (the door test measures it at the head). So the PR creates no new 404-for-bad-input PATH; it adds one more input class to a pre-existing rewrite's reach, and the dev pinned the status as "whatever this route gives an unknown select field" rather than a literal 404 — the honest pin. Pre-existing, outside this card's surface, judged right to leave alone. What it leaves is a transport split (REST 404 vs dispatcher 400 for one input), which pre-exists for INVALID_FIELD and now covers VALIDATION_ERROR too — that needs its own carrier (③).

  6. Public surface. projectionFieldsValue, countFlagValue and quotedQueryValue are module-private functions in protocol.ts; no export, type or signature of @objectstack/metadata-protocol changes; QueryTransportParamsSchema was already exported from @objectstack/spec/data and imported in the same block that already carries QueryPageWindowParamSchema. No packages/spec/** path in the diff. No public-surface change — right.

  7. Clause-② (seat question 1). clause2-line.mjs reserves yes (narrowing) for "a diff that widens one surface and narrows another" and defines no (narrowing) as "NOT a widening, but breaking. This is the whole point of the arm." Measured on the diff: no new accepted spelling (the $-alias table and slot table are untouched), no new accepted value (both accept sets shrink, item 1-2), no new door (every caller list unchanged), no export change (item 6). The triage's "declare Clause-②: yes (narrowing)" named the wrong arm for this diff; the dev's no (narrowing) on both the PR body and the changeset is the correct reading, and the seat's acceptance of it is confirmed. The dev's reason that "pm-dispatch execution-duties says a card narrowing a published accept set is Clause-② no" could not be located verbatim in .claude/skills/pm-dispatch/** at the head; the ruling stands on the one reader (clause2-line.mjs) alone, which is enough.

  8. Error code. The refusals carry VALIDATION_ERROR; the $top= precedent they mirror carries INVALID_REQUEST. The card's done-when binds status 400 and the named parameter ("as $top= is"), not the code; the triage ruling names no code. Two adjacent refusals of one normalizer now carry two codes. Not a contract defect (both are 400, both set param), right as ordered, recorded so a later unification is read as the error-contract change it would be.

  9. scripts/engine-double-contract.pinned.json (seat question 4). The diff is one ADDED entry (protocol.empty-projection-unknown-count.test.ts, verb findOne, pinned 2); no existing entry's file, verb or count changes. check-engine-double-contract.mjs retainedErrors (lines 2797-2815) makes the GROWTH direction an error — "pins N engine double(s) that the pinned ledger does not record … Run --write and commit" — so the new test's double (its findOne routed through assertEngineFindOnePredicate, as the test shows) requires exactly this entry. The loss direction reds only when a file's live pin count falls below its ledger entry; no existing entry moved. A pin addition the new test requires; nothing loosened. The count 2 is the gate's own census and is not re-derivable read-only; Lint · CI, release & engine gates on the head (in progress) is its verdict.

  10. Tests as contract pins. The protocol file pins code, status, param, the quoted value (bounded at 64 chars) and that the engine spies were never called, for every refused spelling, plus the preservation set (projection lists, count absent / true / false / null, by-id select string / array / undefined / null). The REST and dispatcher files pin each door over a real SQLite driver with a per-object read counter at zero across the refusals, the controls answering as before, and (dispatcher) both host exits agreeing. Red-first on the base is declared per file with counts; not re-run here (read-only), and the dev's counts are the only evidence of it. Adequate for the accept-set claims above.

② Semver level

  • Changeset: '@objectstack/metadata-protocol': patch, header fix(metadata-protocol)!: …, Clause-②: no (narrowing) as the first thing after the colon on both the PR body and the changeset body, Metadata-protocol: none beside it on both, the **BREAKING** banner, and one adr-0087: not-required (no-migration-prescription) marker with its reason. Check Changeset and PR-body guards are green on the head.
  • Level: ruling A (maintainer, 2026-10-21, mechanised in check-changeset-no-major.mjs lines 901-930, 2049-2058) decides the level by the Metadata-protocol: line alone — none ⇒ patch — and keeps Clause-② for meaning only: "A non-protocol widening (REST) or narrowing (a driver, a posture) ships patch and keeps its BREAKING banner and its ADR-0087 disposition unchanged." Nothing a metadata author writes changes here (no schema key, no authorable surface, no export), so none is true and patch is the only level the gate accepts beside it. The older docblock sentence "during the launch window a breaking change ships minor" (line 1794) is the pre-ruling-A coupling the same file says it lost; the check-run on the head is green with patch.
  • The changeset's Before / Now / Unchanged / Who-sees-it / Remedy paragraphs match the diff clause for clause, including that POST …/query already refused a non-boolean count at the body schema and still does.
  • Moved package: only @objectstack/metadata-protocol has a source change; the two other packages carry new tests only. Correct.
  • Clause-②: no (narrowing) — judged in ① item 7; matches the diff, matches the changeset, matches the (narrowing) arm the ADR-0087 gate and the queue guard read.

③ Boundary flags

Dev deviations (os-dev-report 6448504462):

  1. Clause-②: no (narrowing) instead of the ordered yes (narrowing) — answered, upheld (① item 7). No one-word change is needed on the PR body or changeset.
  2. getData in the diff beyond the list door — answered, upheld (① item 3): inside the claim's file surface (the shared normalizer) and inside the card's mechanism; both transports' by-id doors are pinned.
  3. REST by-id answers the refusal at 404 — answered (① item 5): pre-existing rewrite, not this PR's path; the status pinned relative to the route's existing projection refusal is right. The transport split is escalated below.
  4. Open PRs on protocol.ts (refactor(metadata-protocol): move the post-persistence mutation fan-out out of protocol.ts into protocol-mutation-fan-out.ts (#25467 cut 25) #26807 split cut 25, fix(metadata-protocol): land the NULL-safe sys_setting / sys_notification_preference identity on PostgreSQL, and never report a no-op as created #26782 sys_setting) — answered: at read time the PR reports mergeable: true against base c8583f80ac; the seat re-measures textual overlap at landing as the claim already says. No action here.

Dev open_questions: none declared. None found by this review beyond the escalations below.

Dev out_of_scope_findings, each needing the seat's carrier decision:

  • (a) by-id 400→404 rewrite in crud-routes.ts GET-by-id catch, now covering VALIDATION_ERROR as well as INVALID_FIELD, while the dispatcher answers 400 — escalate: needs a card (domain:server, area:api). No carrier number is on the report.
  • (b) HttpFindQueryParamsSchema.count (z.coerce.boolean, reads 'false' as true, admits 'maybe') and .select (z.string(), admits a blank) — a published declaration in packages/spec wider than the door it declares, same family as the spec: the published query schemas declare limit / offset / top / skip as plain numbers the Data API now refuses (declared ≠ enforced) #25269 top/skip repair in that schema; no runtime reader — escalate: needs a card (the spec lane; red line for this seat). Verified at the head (protocol.zod.ts:2367-2385).
  • (c) packages/client/src/index.ts serialises select: [] as select= (if (normalizedOptions.select) at :7060 and :7946 — an empty array is truthy) and that now answers 400 — agreed, no carrier: it is the refusal working; no in-repo caller passes an empty list.

Other flags raised by this review:

  • Dogfood battery owed at head (seat question 5). The battery ran at 25a8556236; git diff 25a8556236..28a10e6f8b is 2 lines in packages/runtime/src/domains/data-empty-projection-count.test.ts (a typeof message === 'string' guard in a test outside the dogfood suite), verified. CI's dogfood shards do not run on pull_request (ci.yml 118-137; the green "Dogfood Regression Gate" on this head is the zero-attestation conclusion), so the local battery is the only battery there will be. The review-checklist rule for a (narrowing) PR is a battery pinned to the head: recorded as owed at 28a10e6f8b; the dev is re-running it; the seat reads the result before enqueue. Not run here.
  • 13 check-runs still in progress at read time (listed above), including every Type Check job, ESLint, the engine-gates job that judges the pinned ledger, and Test Core (not yet planned). This PASS is a contract verdict on the diff; landing waits on their conclusions as the derived-gate verdicts.
  • Prose face for the seat's ACCEPT, not this record: content/docs/api/data-api.mdx (lines 29, 102-103) documents the $count alias and a select answer table with rows for a named and an unknown field but none for the blank select or for $count's accepted values; "Flag docs affected" is green on the head. Whether a row is owed is the seat's prose-face call.
  • Independence: the dev's session is session_01FEkQ5kRR5RttkLiWWAkDA5 on the card thread; this record is rendered for the adopting seat below.

Implemented-by: claude/project-thread-3618c5
Reviewed-by: session_01R1q2xdtYgU6FKrZ7LgjPCC

Rendered by the seat's subagent (read-only, at 2026-10-27T17:15Z); adopted by the seat session named above.

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-27T17:41Z

Head 28a10e6f8b, report 6448504462 (on #26644), claim 6446149768, contract review 6448772826 (CONTRACT_REVIEW_TIER, narrowing face, PASS on this head).

item reading
Shape Draft, base main, Fixes #26644 under triage 6441434576 (p2). 6 files: protocol.ts (findData and getData), three door tests (protocol, REST, dispatcher), one added entry in scripts/engine-double-contract.pinned.json that the new test's findOne double requires, and a patch changeset with a BREAKING block. Clause-②: no (narrowing) and Metadata-protocol: none appear in both the body and the changeset.
Fix findData refuses a projection that names no field (select=, $select=, fields: []) and a count flag that is not true/false, with 400 VALIDATION_ERROR naming the spelling the caller wrote and the value, before any engine read. The count accept set is read from spec's QueryTransportParamsSchema.shape.count, with no spec edit. getData takes the same projection rule. Absent parameters keep their default.
Clause-② The order said yes (narrowing). The seat accepted the dev's no (narrowing), and the review confirms it: scripts/pm/clause2-line.mjs reserves yes (narrowing) for a diff that widens one surface and narrows another, and this diff widens nothing.
Dogfood battery Re-run at the seat's request, pinned to 28a10e6f8b (this head, nothing pushed), full, no shard: 302 test files passed, 3 skipped; 2507 tests passed, 20 skipped (declared skips only); command exit 0. Evidence at pm-orders/server/evidence/26644/dogfood-28a10e6f8b.txt. The earlier run at 25a8556236 differed from the head by 2 lines in a runtime test. review-checklist.md :50 is met.
Evidence CI green on head 28a10e6f8b (34 success, 11 skipped, 0 failed). Governed check: NOT governed, 694 changed lines. Red first on the base: 21 + 3 + 8 failures across the protocol, REST and dispatcher pins. 70 of 70 derived gates exit 0, reconciled with --ran.
Carriers RestServer's GET-by-id catch answers the refusal 404 (a rewrite that predates this PR; ?select=nope already answers 404): #26816. HttpFindQueryParamsSchema declares count and select wider than the door, in packages/spec: #26817 for the spec seat. data-api.mdx gets no new row: its select table lists answers for named fields, and the refusal is stated in the changeset.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay, once every check on this head is green or an expected skip.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 27, 2026 17:42
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 27, 2026 17:42
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 27, 2026
Merged via the queue into main with commit 4702c46 Oct 27, 2026
46 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-3618c5 branch October 27, 2026 18:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

data list door: a blank $select answers every column and an unrecognised $count value counts — malformed values are read as the absent default

2 participants