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
Commit f8f568e
Browse filesBrowse the repository at this point in the historyBrowse files
fix(v2, db): make two unfalsifiable tests observable and document strict query
Three follow-ups on the w5 policy work: one decision recorded, two tests that
could not fail.
The `query: noInputSchema` sweep is kept. It is a real tightening — 69 v2
operations that ignored an unknown query param now answer 400 — so it was
weighed rather than assumed. The v2 body slice on those same endpoints was
already `.strict()`, and every v2 list already rejected `?bogus=1`, so the
split was arbitrary rather than a promise: the same typo was a 400 on
`GET /workflows` and a silent 200 on `GET /workflows/{id}`. A parameter the
server drops without saying so is the bug class the lists' rule already exists
to prevent. No first-party caller is affected — the two SDKs send only
`includeOutput`/`selectedOutputs`, both declared; the UI and the desktop app
make no v2 calls at all; `requestJson` appends nothing implicitly and no v2
cache buster exists; every docs example uses a declared param. A third-party
caller appending a tracking tag does break, which is why the behavior is now
documented in the API reference with the exact 400 body rather than left to be
discovered, and why the reasoning sits in the v2 conventions skill next to the
rule instead of only in a commit message.
`packages/db/timestamps.test.ts` asserted that `withUtcTimestamps` registers a
UTC parser on oid 1114 by reading it off a bare postgres.js client. Every real
client is then handed to `drizzle()`, which overwrites that entry with a
transparent parser, so the assertion held whether or not the parser had any
effect. The mechanism is fine and stays: drizzle's own `PgTimestamp` mapper
appends `+0000`, so the read is UTC-correct either way and the session
`TimeZone` pin — the write-side fix — is untouched by `drizzle()`. The test now
resolves the parser both before and after `drizzle()`, pins the clobbering it
depends on, and asserts the instant recovered through the full composition, so
a regression in either layer is red. `timestamps.ts` records why the inert
entry is kept.
`nul-byte-boundary.test.ts` embedded a raw U+0000, so git classified it binary
and rendered it as `Bin 0 -> 4102 bytes` — the test proving the NUL hardening
works was the one file a reviewer could not read. The escape is byte-for-byte
equivalent at runtime. Two older files had the same defect and are fixed the
same way. `check:source-text` now fails the build on a raw NUL in any tracked
source file, and `.gitattributes` forces source files to diff as text so the
next one is visible in review rather than hidden by it.
Copy file name to clipboardExpand all lines: .agents/skills/v2-api-conventions/SKILL.md
+5Lines changed: 5 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -119,6 +119,10 @@ Return `nextCursor: null` on the last page and only then. Never construct a curs
119
119
120
120
## Rule 4 — reject what you do not implement
121
121
122
+
**Every contract declares a `query`, even when the endpoint takes none** — `query: noInputSchema` (`z.object({}).strict()`), never omission. `parseRequest` validates the query slice only when the contract declares one, so an omitted `query` means "never look at the query string", not "takes no query params". The two were indistinguishable, which is how 69 contracts ended up accepting anything without anyone deciding they should: `GET /workflows/{id}?bogus=1` answered 200 while every list answered 400 for the same shape. `query-declaration.test.ts` sweeps the tree so contract 70 fails at authoring time rather than shipping unvalidated.
123
+
124
+
Declaring them is a **deliberate tightening** of endpoints that previously ignored an unknown param. It was weighed and kept: the v2 body slice on those same endpoints was already strict, so the split was arbitrary rather than a promise to callers; a mistyped param that is silently dropped is the bug class this rule exists to prevent; and no first-party caller sends an undeclared v2 query param (the two SDKs send only `includeOutput`/`selectedOutputs`, both declared; the UI makes no v2 calls at all; `requestJson` appends nothing implicitly and no v2 cache buster exists). Third-party callers appending a tracking tag or cache buster do break, which is why `api-reference/getting-started.mdx` documents the behavior rather than leaving it to be discovered from a 400.
125
+
122
126
Query and body schemas are **`.strict()`** — and `.strict()` binds the **top level only**. A strict body containing a non-strict nested object still drops unknown keys one level down, which is the headline `filter` bug at a smaller scale: `sort: [{ field, direction, nulls: 'last' }]` answered 200 and ordered by the default. Strictness belongs on the shared nested schema (`sortSpecSchema`'s element, `tableViewConfigSchema`), not restated per body.
123
127
124
128
Before tightening a schema that is **also** a response or a stored blob, make the read canonical first. `table_views.config` is schemaless JSONB, so a legacy row carrying a retired key would fail a newly strict response parse and become a 500; `normalizeStoredViewConfig` projects the stored blob onto the declared keys so the tightening is safe in both directions. Zod strips unknown keys by default, so a non-strict schema answers `?limit=1` with 200 and the whole set — the caller believes it bounded the response and it did not. That is a contract lie, and on an uncapped list it is also an unbounded-response risk.
@@ -207,6 +211,7 @@ Run this against any new or changed v2 endpoint.
207
211
-[ ] Success body is exactly `{data}` or `{data, nextCursor}`; failures are exactly `{error:{code,message,details?}}`.
208
212
-[ ] Route uses a shared builder; no hand-built `NextResponse.json`.
209
213
-[ ] Query and body schemas are `.strict()`.
214
+
-[ ] The contract declares a `query` — `noInputSchema` when the endpoint takes no query params, never omission.
210
215
-[ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type.
211
216
-[ ]`limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`.
212
217
-[ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them.
Copy file name to clipboardExpand all lines: .claude/commands/v2-api-conventions.md
+5Lines changed: 5 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -118,6 +118,10 @@ Return `nextCursor: null` on the last page and only then. Never construct a curs
118
118
119
119
## Rule 4 — reject what you do not implement
120
120
121
+
**Every contract declares a `query`, even when the endpoint takes none** — `query: noInputSchema` (`z.object({}).strict()`), never omission. `parseRequest` validates the query slice only when the contract declares one, so an omitted `query` means "never look at the query string", not "takes no query params". The two were indistinguishable, which is how 69 contracts ended up accepting anything without anyone deciding they should: `GET /workflows/{id}?bogus=1` answered 200 while every list answered 400 for the same shape. `query-declaration.test.ts` sweeps the tree so contract 70 fails at authoring time rather than shipping unvalidated.
122
+
123
+
Declaring them is a **deliberate tightening** of endpoints that previously ignored an unknown param. It was weighed and kept: the v2 body slice on those same endpoints was already strict, so the split was arbitrary rather than a promise to callers; a mistyped param that is silently dropped is the bug class this rule exists to prevent; and no first-party caller sends an undeclared v2 query param (the two SDKs send only `includeOutput`/`selectedOutputs`, both declared; the UI makes no v2 calls at all; `requestJson` appends nothing implicitly and no v2 cache buster exists). Third-party callers appending a tracking tag or cache buster do break, which is why `api-reference/getting-started.mdx` documents the behavior rather than leaving it to be discovered from a 400.
124
+
121
125
Query and body schemas are **`.strict()`** — and `.strict()` binds the **top level only**. A strict body containing a non-strict nested object still drops unknown keys one level down, which is the headline `filter` bug at a smaller scale: `sort: [{ field, direction, nulls: 'last' }]` answered 200 and ordered by the default. Strictness belongs on the shared nested schema (`sortSpecSchema`'s element, `tableViewConfigSchema`), not restated per body.
122
126
123
127
Before tightening a schema that is **also** a response or a stored blob, make the read canonical first. `table_views.config` is schemaless JSONB, so a legacy row carrying a retired key would fail a newly strict response parse and become a 500; `normalizeStoredViewConfig` projects the stored blob onto the declared keys so the tightening is safe in both directions. Zod strips unknown keys by default, so a non-strict schema answers `?limit=1` with 200 and the whole set — the caller believes it bounded the response and it did not. That is a contract lie, and on an uncapped list it is also an unbounded-response risk.
@@ -206,6 +210,7 @@ Run this against any new or changed v2 endpoint.
206
210
-[ ] Success body is exactly `{data}` or `{data, nextCursor}`; failures are exactly `{error:{code,message,details?}}`.
207
211
-[ ] Route uses a shared builder; no hand-built `NextResponse.json`.
208
212
-[ ] Query and body schemas are `.strict()`.
213
+
-[ ] The contract declares a `query` — `noInputSchema` when the endpoint takes no query params, never omission.
209
214
-[ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type.
210
215
-[ ]`limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`.
211
216
-[ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them.
Copy file name to clipboardExpand all lines: .cursor/commands/v2-api-conventions.md
+5Lines changed: 5 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -113,6 +113,10 @@ Return `nextCursor: null` on the last page and only then. Never construct a curs
113
113
114
114
## Rule 4 — reject what you do not implement
115
115
116
+
**Every contract declares a `query`, even when the endpoint takes none** — `query: noInputSchema` (`z.object({}).strict()`), never omission. `parseRequest` validates the query slice only when the contract declares one, so an omitted `query` means "never look at the query string", not "takes no query params". The two were indistinguishable, which is how 69 contracts ended up accepting anything without anyone deciding they should: `GET /workflows/{id}?bogus=1` answered 200 while every list answered 400 for the same shape. `query-declaration.test.ts` sweeps the tree so contract 70 fails at authoring time rather than shipping unvalidated.
117
+
118
+
Declaring them is a **deliberate tightening** of endpoints that previously ignored an unknown param. It was weighed and kept: the v2 body slice on those same endpoints was already strict, so the split was arbitrary rather than a promise to callers; a mistyped param that is silently dropped is the bug class this rule exists to prevent; and no first-party caller sends an undeclared v2 query param (the two SDKs send only `includeOutput`/`selectedOutputs`, both declared; the UI makes no v2 calls at all; `requestJson` appends nothing implicitly and no v2 cache buster exists). Third-party callers appending a tracking tag or cache buster do break, which is why `api-reference/getting-started.mdx` documents the behavior rather than leaving it to be discovered from a 400.
119
+
116
120
Query and body schemas are **`.strict()`** — and `.strict()` binds the **top level only**. A strict body containing a non-strict nested object still drops unknown keys one level down, which is the headline `filter` bug at a smaller scale: `sort: [{ field, direction, nulls: 'last' }]` answered 200 and ordered by the default. Strictness belongs on the shared nested schema (`sortSpecSchema`'s element, `tableViewConfigSchema`), not restated per body.
117
121
118
122
Before tightening a schema that is **also** a response or a stored blob, make the read canonical first. `table_views.config` is schemaless JSONB, so a legacy row carrying a retired key would fail a newly strict response parse and become a 500; `normalizeStoredViewConfig` projects the stored blob onto the declared keys so the tightening is safe in both directions. Zod strips unknown keys by default, so a non-strict schema answers `?limit=1` with 200 and the whole set — the caller believes it bounded the response and it did not. That is a contract lie, and on an uncapped list it is also an unbounded-response risk.
@@ -201,6 +205,7 @@ Run this against any new or changed v2 endpoint.
201
205
-[ ] Success body is exactly `{data}` or `{data, nextCursor}`; failures are exactly `{error:{code,message,details?}}`.
202
206
-[ ] Route uses a shared builder; no hand-built `NextResponse.json`.
203
207
-[ ] Query and body schemas are `.strict()`.
208
+
-[ ] The contract declares a `query` — `noInputSchema` when the endpoint takes no query params, never omission.
204
209
-[ ] No caller-supplied value can produce a 500 — check every numeric param reaches SQL as a validated integer, and that any bound value passed as an argument to a SQL function carries an explicit type.
205
210
-[ ]`limit` comes from `v2PaginationFields`, not a hand-written `z.coerce.number()`.
206
211
-[ ] If the response carries `nextCursor`, the query accepts `limit` + `cursor` and the query actually applies them.
Copy file name to clipboardExpand all lines: apps/docs/content/docs/de/api-reference/getting-started.mdx
+18Lines changed: 18 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum
171
171
|`404`| Resource not found | Verify the ID exists and belongs to your workspace |
172
172
|`429`| Rate limit exceeded | Wait for the duration in the `Retry-After` header |
173
173
174
+
### Unrecognized fields are rejected
175
+
176
+
Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list.
177
+
178
+
This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents.
Copy file name to clipboardExpand all lines: apps/docs/content/docs/en/api-reference/getting-started.mdx
+18Lines changed: 18 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum
171
171
|`404`| Resource not found | Verify the ID exists and belongs to your workspace |
172
172
|`429`| Rate limit exceeded | Wait for the duration in the `Retry-After` header |
173
173
174
+
### Unrecognized fields are rejected
175
+
176
+
Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list.
177
+
178
+
This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents.
Copy file name to clipboardExpand all lines: apps/docs/content/docs/es/api-reference/getting-started.mdx
+18Lines changed: 18 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum
171
171
|`404`| Resource not found | Verify the ID exists and belongs to your workspace |
172
172
|`429`| Rate limit exceeded | Wait for the duration in the `Retry-After` header |
173
173
174
+
### Unrecognized fields are rejected
175
+
176
+
Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list.
177
+
178
+
This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents.
Copy file name to clipboardExpand all lines: apps/docs/content/docs/fr/api-reference/getting-started.mdx
+18Lines changed: 18 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum
171
171
|`404`| Resource not found | Verify the ID exists and belongs to your workspace |
172
172
|`429`| Rate limit exceeded | Wait for the duration in the `Retry-After` header |
173
173
174
+
### Unrecognized fields are rejected
175
+
176
+
Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list.
177
+
178
+
This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents.
Copy file name to clipboardExpand all lines: apps/docs/content/docs/ja/api-reference/getting-started.mdx
+18Lines changed: 18 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum
171
171
|`404`| Resource not found | Verify the ID exists and belongs to your workspace |
172
172
|`429`| Rate limit exceeded | Wait for the duration in the `Retry-After` header |
173
173
174
+
### Unrecognized fields are rejected
175
+
176
+
Every v2 endpoint validates the request against its published schema — path parameters, query string, and body — and answers `400` for any field it does not declare. A misspelled parameter is an error rather than a silent no-op, so `?limt=20` fails instead of quietly returning an unbounded list.
177
+
178
+
This holds for endpoints that declare no query parameters at all. Do not append tracking tags, cache busters, or other extra parameters to a v2 URL; send only what the endpoint documents.
0 commit comments