You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
data list door: a blank $select answers every column and an unrecognised $count value counts — malformed values are read as the absent default #26644
reach: any caller of the generic list door (GET /api/v1/data/:object, and through the shared normalizer POST /data/:object/query and the runtime dispatcher). A script that builds $select from an empty list, or sends a mistyped $count, gets a 200 that looks normal: every column, and a count it may not have asked for.
Reproduction (twice, on two fresh showcase boots at fb91e979, as the seed admin)
request
answer
expected
GET /api/v1/data/showcase_task?$top=2&$select=title
200, 1 column
—
…?$top=2&$select=
200, all 25 columns
400
…?$top=2&$select=,
200, all 25 columns
400
…?$top=2&select=
200, all 25 columns
400
…?$top=2&$select=nope
400 (unknown field)
—
…?$top=
400 (blank window)
—
…?$top=2&$count=maybe
200, total reported
400
…?$top=2&$count=false
200, no total
—
Mechanism
Both cases are in the list normalizer in packages/metadata-protocol/src/protocol.ts:
Projection: a string fields is split(',').map(trim).filter(Boolean). A blank or comma-only value becomes [], assertProjectionFieldsExist has nothing to refuse, and an empty projection is served as no projection.
Count: the boolean step converts only the strings 'true' and 'false'. Any other string stays a string, so countOptOut = options.count === false is false and the count runs.
Why this is a defect, in the door's own terms
pageWindowValue (Data API paging: a negative $skip/offset answers 500, and a non-numeric or negative $top/limit is silently ignored #25229) refuses a blank $top rather than reading it as absent. Its doc says: "The blank ?filter= is absent because an empty filter and no filter mean the same thing; an empty window does not — absent means 'every row', so reading ?$top= that way is the silent widening". An empty projection and no projection are not the same either. Absent means every column, so a blank $select read as absent is the same widening on the column axis.
ODataQuerySchema.$count is declared z.boolean() (packages/spec/src/api/odata.zod.ts).
Done when
A blank or comma-only $select / select / fields, and a $count / count that is not true or false, are refused 400 with the parameter named, as $top= is. Pins cover both.
p2: at the generic list door, a blank $select returns every column and an unrecognised $count counts. A malformed value is read as the absent default. Refuse both with 400, in the shared normalizer, so every path that uses it agrees. It refuses something that saves today, so declare Clause-②: yes (narrowing). Handled directly in 18.x.
Claim: PM loop round 9 · card thread · the list door refusing a blank $select and an unknown $count
Session: session_01R1q2xdtYgU6FKrZ7LgjPCC (the seat); the card thread is session_01FEkQ5kRR5RttkLiWWAkDA5
Account: os-project-manager (the seat's linked user; the card's assignee)
Branch: claude/project-thread-3618c5 (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
Worktree: the card thread's own container checkout
Domain: domain:server
Seat: domain:server#1
Ruling-ref: triage 6441434576 (p2): a blank $select and an unrecognised $count value are refused 400 in the shared normalizer, so every list path agrees; absent parameters keep their default. Intake split with domain:server#2 for the 18.2.0 wave-2 cards (#25807).
File surface: the shared list-query normalizer (packages/rest/** or wherever it lives) and every list door that uses it, their tests, a patch changeset. ⛔ No packages/spec/** (lane red line).
Container & model: S, mode:thread, default model and effort (set at thread open)
Clause-②: yes (narrowing) — per the triage: a malformed value that answered 200 is refused 400; the dev measures it on the diff
Metadata-protocol: none
Responsibility: a client that sends $select= gets every column, and a typo in $count silently counts
Thread-read: 6441434576
Premise: holds (read 2026-10-27T15:30Z at origin/main0f3d068cf1: card open, pm:queue, no live claim)
Serial constraints cleared: none known on these paths; the dev re-measures open PRs on the files it touches before pr_create.
os-dev-report
{
"issue": 26644,
"status": "done",
"branch": "claude/project-thread-3618c5",
"pr": "#26811",
"head": "28a10e6f8b",
"session": "session_01FEkQ5kRR5RttkLiWWAkDA5",
"premise_still_valid": true,
"summary": "findData (the list normalizer every Data API list door shares) now refuses a projection naming no field (blank or comma-only $select/select/fields, or an empty array) and a count/$count that is not true/false, 400 VALIDATION_ERROR with param = the spelling written and the value quoted, before any read. The count accept set is read from the spec's QueryTransportParamsSchema.shape.count (no packages/spec edit). getData (by id) takes the same projection rule, because its comment promised the list path's gate and would otherwise have become false. Doors measured: RestServer GET list and POST query, dispatcher GET list and POST query (all via findData), RestServer and dispatcher GET by id (via getData). RestServer by id answers the refusal at 404, because that route's catch rewrites every 400 to 404 (as it already does for an unknown select field); the dispatcher answers 400. POST query already refused a non-boolean count at FindDataRequestSchema (400 VALIDATION_FAILED), unchanged. Absent and well-formed values unchanged.",
"tests": "Red first on base 0f3d068 (closures built from it): protocol.empty-projection-unknown-count.test.ts 21 failed / 16 passed; rest data-empty-projection-count-door.test.ts 3 failed / 2 passed (200 with every column); runtime domains/data-empty-projection-count.test.ts 8 failed / 1 passed. At head: the three files 37/37, 5/5, 9/9. pnpm --filter metadata-protocol test exit 0 (21653 passed, 19 skipped), typecheck exit 0; rest test exit 0 (6503 passed, 326 skipped), typecheck exit 0; runtime test exit 0 (7457 passed, 19 skipped), typecheck exit 0 at head after one test-typing fix. eslint --no-inline-config on the three packages exit 0. Full pnpm build VERDICT command-exit 0. Ablation: the red-first runs are the mutation leg (the base source built into dist, the REST and dispatcher pins resolving metadata-protocol through dist); no separate ablation.",
"gates": "dispatch-gates --commands at head 28a10e6: 70 derived families, all run, all exit 0; --ran reconciliation exit 0 (70 run, 0 NOT-MEASURED, 0 UNRUN). First pass red on check:engine-double-contract (the new by-id double's findOne did not route through assertEngineFindOnePredicate): fixed, ledger scripts/engine-double-contract.pinned.json written by --write, re-run exit 0. check:type-check-debt exit 3 on the first pass (lock queue-timeout behind the dogfood run, PREREQUISITE NOT MET), exit 0 on the head re-run. test:repo not run locally (no repo-project file in the diff), declared to CI Test Core. PR check-runs: in_progress at report time.",
"dogfood_battery": "25a8556236 · VERDICT command-exit 0 · Test Files 302 passed, 3 skipped (305) · Tests 2507 passed, 20 skipped (2527) · no failures. The head 28a10e6 differs only in packages/runtime/src/domains/data-empty-projection-count.test.ts (a type annotation in a test outside dogfood).",
"mcp_calls": "4 — reads only: issue_read get #26644, issue_read get_comments #26644 (twice: triage, then the claim check), search_pull_requests (open PRs on the touched files)",
"api_writes": "2 relay strokes — pr_create (POST /repos/objectstack-ai/objectstack/pulls + POST issues/26811/assignees, read back byte-identical, assignee os-project-manager); this os-dev-report comment (POST issues/26644/comments). git push not counted.",
"deviations": [
"Clause-② declared no (narrowing), not the order's and claim's yes (narrowing): measured on the diff, nothing is widened and no export changes; clause2-line.mjs reserves yes (narrowing) for a diff that widens one surface and narrows another, and pm-dispatch execution-duties says a card narrowing a published accept set is Clause-② no. The (narrowing) arm, BREAKING banner, ADR-0087 not-required (no-migration-prescription), patch and the dogfood battery are as ordered. If the seat wants yes, the PR body and changeset line need the one-word change.",
"getData (GET /data/:object/:id) is in the diff beyond the list door: its own comment says it is gated exactly as on the list path, so leaving it would make that published comment false (os-dev rule 3, a touched defect is fixed). The claim's file surface (the shared normalizer) covers the file.",
"RestServer GET by id answers the refusal at 404, not 400: that route's catch rewrites every 400 to 404. Pinned as the same status that route gives an unknown select field, not as a literal 400; reported below.",
"Open PRs on protocol.ts: #26807 (protocol.ts split cut 25, mutation fan-out) and #26782 (sys_setting identity); neither touches findData/getData's projection or count lines, so no textual overlap expected."
],
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: RestServer GET /api/v1/data/:object/:id?select=nope answers 404 INVALID_FIELD (measured in packages/rest/src/data-empty-projection-count-door.test.ts), and the new empty-projection refusal at 404 VALIDATION_ERROR, while the dispatcher's by-id door answers the empty projection 400 · evidence: crud-routes.ts GET by id catch maps every mapped 400 to 404 ('a bad id is a miss'), so a malformed request reads as no such record · dedupe: by-id 400 rewritten to 404, select unknown field 404, crud-routes get catch, record read malformed parameter",
"class: b · Seam: spec:HttpFindQueryParamsSchema.count and .select → runtime:metadata-protocol findData (countFlagValue, projectionFieldsValue) · reach: the published declaration (packages/spec json-schema/api/HttpFindQueryParams.json and the reference docs) admits count 'maybe' and reads 'false' as true (z.coerce.boolean), and admits a blank select (z.string()); no runtime reader in this tree; the door reads QueryTransportParamsSchema.count, which is exact. Same drift family as the top/skip fix in that schema · dedupe: HttpFindQueryParamsSchema count coerce boolean, HttpFindQueryParams select blank, list query param declaration drift",
"carrier: none · noted, not filed: packages/client/src/index.ts serialises select: [] as select=, which now answers 400 instead of every column (the refusal working as intended; no in-repo caller passes an empty list)"
]
}
Activity
objectstack-fleet commentedon Oct 27, 2026
Triage: first grade —
bug·priority:p2·domain:server·area:api·pm:queue· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-27T12:04Z⛔ Not a claim, ⛔ not a dispatch.
$selectreturns every column and an unrecognised$countcounts. A malformed value is read as the absent default. Refuse both with 400, in the shared normalizer, so every path that uses it agrees. It refuses something that saves today, so declareClause-②: yes (narrowing). Handled directly in 18.x.Generated by Claude Code
objectstack-fleet commentedon Oct 27, 2026
Claim: PM loop round 9 · card thread · the list door refusing a blank
$selectand an unknown$countSession:
session_01R1q2xdtYgU6FKrZ7LgjPCC(the seat); the card thread issession_01FEkQ5kRR5RttkLiWWAkDA5Account:
os-project-manager(the seat's linked user; the card's assignee)Branch:
claude/project-thread-3618c5(the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)Worktree: the card thread's own container checkout
Domain:
domain:serverSeat:
domain:server#1Ruling-ref: triage
6441434576(p2): a blank$selectand an unrecognised$countvalue are refused 400 in the shared normalizer, so every list path agrees; absent parameters keep their default. Intake split withdomain:server#2for the 18.2.0 wave-2 cards (#25807).File surface: the shared list-query normalizer (
packages/rest/**or wherever it lives) and every list door that uses it, their tests, apatchchangeset. ⛔ Nopackages/spec/**(lane red line).Container & model:
S,mode:thread, default model and effort (set at thread open)Clause-②: yes (narrowing) — per the triage: a malformed value that answered 200 is refused 400; the dev measures it on the diff
Metadata-protocol: none
Responsibility: a client that sends
$select=gets every column, and a typo in$countsilently countsThread-read: 6441434576
Premise: holds (read 2026-10-27T15:30Z at
origin/main0f3d068cf1: card open,pm:queue, no live claim)Serial constraints cleared: none known on these paths; the dev re-measures open PRs on the files it touches before
pr_create.objectstack-fleet commentedon Oct 27, 2026
os-dev-report
{
"issue": 26644,
"status": "done",
"branch": "claude/project-thread-3618c5",
"pr": "#26811",
"head": "28a10e6f8b",
"session": "session_01FEkQ5kRR5RttkLiWWAkDA5",
"premise_still_valid": true,
"summary": "findData (the list normalizer every Data API list door shares) now refuses a projection naming no field (blank or comma-only $select/select/fields, or an empty array) and a count/$count that is not true/false, 400 VALIDATION_ERROR with param = the spelling written and the value quoted, before any read. The count accept set is read from the spec's QueryTransportParamsSchema.shape.count (no packages/spec edit). getData (by id) takes the same projection rule, because its comment promised the list path's gate and would otherwise have become false. Doors measured: RestServer GET list and POST query, dispatcher GET list and POST query (all via findData), RestServer and dispatcher GET by id (via getData). RestServer by id answers the refusal at 404, because that route's catch rewrites every 400 to 404 (as it already does for an unknown select field); the dispatcher answers 400. POST query already refused a non-boolean count at FindDataRequestSchema (400 VALIDATION_FAILED), unchanged. Absent and well-formed values unchanged.",
"tests": "Red first on base 0f3d068 (closures built from it): protocol.empty-projection-unknown-count.test.ts 21 failed / 16 passed; rest data-empty-projection-count-door.test.ts 3 failed / 2 passed (200 with every column); runtime domains/data-empty-projection-count.test.ts 8 failed / 1 passed. At head: the three files 37/37, 5/5, 9/9. pnpm --filter metadata-protocol test exit 0 (21653 passed, 19 skipped), typecheck exit 0; rest test exit 0 (6503 passed, 326 skipped), typecheck exit 0; runtime test exit 0 (7457 passed, 19 skipped), typecheck exit 0 at head after one test-typing fix. eslint --no-inline-config on the three packages exit 0. Full pnpm build VERDICT command-exit 0. Ablation: the red-first runs are the mutation leg (the base source built into dist, the REST and dispatcher pins resolving metadata-protocol through dist); no separate ablation.",
"gates": "dispatch-gates --commands at head 28a10e6: 70 derived families, all run, all exit 0; --ran reconciliation exit 0 (70 run, 0 NOT-MEASURED, 0 UNRUN). First pass red on check:engine-double-contract (the new by-id double's findOne did not route through assertEngineFindOnePredicate): fixed, ledger scripts/engine-double-contract.pinned.json written by --write, re-run exit 0. check:type-check-debt exit 3 on the first pass (lock queue-timeout behind the dogfood run, PREREQUISITE NOT MET), exit 0 on the head re-run. test:repo not run locally (no repo-project file in the diff), declared to CI Test Core. PR check-runs: in_progress at report time.",
"dogfood_battery": "25a8556236 · VERDICT command-exit 0 · Test Files 302 passed, 3 skipped (305) · Tests 2507 passed, 20 skipped (2527) · no failures. The head 28a10e6 differs only in packages/runtime/src/domains/data-empty-projection-count.test.ts (a type annotation in a test outside dogfood).",
"mcp_calls": "4 — reads only: issue_read get #26644, issue_read get_comments #26644 (twice: triage, then the claim check), search_pull_requests (open PRs on the touched files)",
"api_writes": "2 relay strokes — pr_create (POST /repos/objectstack-ai/objectstack/pulls + POST issues/26811/assignees, read back byte-identical, assignee os-project-manager); this os-dev-report comment (POST issues/26644/comments). git push not counted.",
"deviations": [
"Clause-② declared no (narrowing), not the order's and claim's yes (narrowing): measured on the diff, nothing is widened and no export changes; clause2-line.mjs reserves yes (narrowing) for a diff that widens one surface and narrows another, and pm-dispatch execution-duties says a card narrowing a published accept set is Clause-② no. The (narrowing) arm, BREAKING banner, ADR-0087 not-required (no-migration-prescription), patch and the dogfood battery are as ordered. If the seat wants yes, the PR body and changeset line need the one-word change.",
"getData (GET /data/:object/:id) is in the diff beyond the list door: its own comment says it is gated exactly as on the list path, so leaving it would make that published comment false (os-dev rule 3, a touched defect is fixed). The claim's file surface (the shared normalizer) covers the file.",
"RestServer GET by id answers the refusal at 404, not 400: that route's catch rewrites every 400 to 404. Pinned as the same status that route gives an unknown select field, not as a literal 400; reported below.",
"Open PRs on protocol.ts: #26807 (protocol.ts split cut 25, mutation fan-out) and #26782 (sys_setting identity); neither touches findData/getData's projection or count lines, so no textual overlap expected."
],
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: RestServer GET /api/v1/data/:object/:id?select=nope answers 404 INVALID_FIELD (measured in packages/rest/src/data-empty-projection-count-door.test.ts), and the new empty-projection refusal at 404 VALIDATION_ERROR, while the dispatcher's by-id door answers the empty projection 400 · evidence: crud-routes.ts GET by id catch maps every mapped 400 to 404 ('a bad id is a miss'), so a malformed request reads as no such record · dedupe: by-id 400 rewritten to 404, select unknown field 404, crud-routes get catch, record read malformed parameter",
"class: b · Seam: spec:HttpFindQueryParamsSchema.count and .select → runtime:metadata-protocol findData (countFlagValue, projectionFieldsValue) · reach: the published declaration (packages/spec json-schema/api/HttpFindQueryParams.json and the reference docs) admits count 'maybe' and reads 'false' as true (z.coerce.boolean), and admits a blank select (z.string()); no runtime reader in this tree; the door reads QueryTransportParamsSchema.count, which is exact. Same drift family as the top/skip fix in that schema · dedupe: HttpFindQueryParamsSchema count coerce boolean, HttpFindQueryParams select blank, list query param declaration drift",
"carrier: none · noted, not filed: packages/client/src/index.ts serialises select: [] as select=, which now answers 400 instead of every column (the refusal working as intended; no in-repo caller passes an empty list)"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 27, 2026
Landed
PR #26811 merged at 2026-10-27T18:30Z by the merge queue as
4702c46802onmain(Fixes #26644; ACCEPT6449275650).git ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-26811-*'origin/mainby contentgit cat-file -e origin/main:packages/metadata-protocol/src/protocol.empty-projection-unknown-count.test.tsgit merge-base --is-ancestor 4702c46802 origin/mainState: this card closed by
Fixes(completed);pm:dispatchedremoved.Generated by Claude Code