Skip to content

Commit 6de8ba2

Browse files
authored
fix(v2): close the correctness gaps an end-to-end audit found (#6655)
* fix(v2): stop a third-party tool description from 500ing MCP discovery `v2McpToolInputSchema` declared `description: z.string().optional()` inside a `.catchall(z.unknown())` object, and a declared key beats the catchall. The MCP SDK's own `ToolSchema.inputSchema` does not declare `description` at all, so any value — including the JSON `null` a Python server emits for an absent one — passes its validation and reaches Sim unchecked. The builder's outbound `.parse()` then threw, and the discovery error policy correctly declines to classify a Sim-side schema defect, so the endpoint that completes MCP onboarding answered a bare 500. The key is dropped and left to the catchall; `type`, `properties`, and `required` stay pinned because the SDK enforces those at least as tightly. Also in the v2 resources family: - The single-resource query schemas for MCP servers, skills, custom tools, and secrets are now `.strict()`, matching every list in the same family. A mistyped flag was silently ignored behind a 200. - `openapi/resources.ts` re-derived `RESOURCE_ERRORS` and `RESOURCE_CONFLICT_ERRORS` inline in 21 of 22 operations. They now import the shared constants; the generated spec is unchanged, which is the point. - The internal MCP refresh route stamped `updatedAt` alongside `lastToolsRefresh`. `updatedAt` means "configuration last changed" and is a public keyset sort, so a refresh moved rows out from under an in-flight page. `updateServerStatus` already held that invariant; the route now matches it. - The discovery cooldown is a typed `McpServerCooldownError` rather than a substring search for `cooldown`. `McpConnectionError` interpolates the server's display name into its message, so a server named after the word was reported as a transient cooldown when its connection had genuinely failed. * fix(v2): close correctness gaps in the workflows deployment surface Deploy and rollback bodies were plain objects, so a misspelled key was stripped rather than rejected. On rollback that is silent misbehavior: an omitted `version` legitimately means "reactivate the preceding version", so `{"versoin": 5}` rolled back somewhere else and answered 200. Both v2 bodies, the run-read query, and the versions cursor are now strict. Deployment versions are an `integer` column, but the path param, the versions cursor, and the v1 body each bounded it differently or not at all — an out-of-range value overflowed the comparison into an unclassifiable 500. One exported bound now covers all three. Resume admission raised bare `Error`s for a stale contextId or an already-resumed run, which the resume surfaces could not classify and reported as 500. They now use the sibling `ResumeAdmissionError` already in that file, carrying 404/409/400 and whether an automatic retry can clear the refusal. Docs corrections: rollback publishes the 409 its webhook-path conflict already produces; deploy/undeploy/rollback reject a workspace key with 403, not the concealed 404 they documented; the workflows OpenAPI module imports the shared error sets instead of re-deriving them; import and the folder ops explain their folder-tree 413. The export route is marked `headSafe: false` so a HEAD probe stops filing a WORKFLOW_EXPORTED audit event for an export that never happened. `runId` is one bounded schema across the run and log resources. * fix(v2): conceal knowledge upload existence, tighten knowledge/files bounds Security: the four knowledge document-upload routes rendered a bare upload error policy with no resource concealment, while every sibling knowledge route uses one. Because the use case resolves the knowledge-base context before workspace authorization, the unconcealed 403 told any valid API-key holder that a knowledge base exists in a workspace it cannot reach — the exact signal GET /api/v2/knowledge/{id} withholds by answering 404 either way. All four now use the composed concealing policy, which also renders the 415/402/413 the route-local renderer already handled; that duplicate renderer is deleted. Contracts: - POST /knowledge/search is strict. It was the only non-strict v2 request body, so a mis-cased rerankerEnabled or topK returned 200 with the key stripped, changing what the caller was billed and silently disabling reranking. - The document list takes limit, cursor, and search from the shared v2 schemas. search was an unbounded, empty-accepting v1 string, so ?search= answered 200 with a full page here and 400 on GET /knowledge, and the term reached an unindexed filename LIKE scan with no ceiling. - The 16 non-strict single-field workspace query slices across both families are strict, matching GET /knowledge/{id}/tags. - GET /audit-logs takes workspaceIdSchema instead of a bare string (?workspaceId= was forwarded as a filter and returned zero rows) and the shared run-window bounds for startDate/endDate. Documentation: - listAuditLogs drops the 404 it has no code path to emit. - upsertFileShare describes its workspace-key refusal as the 403 it renders; the operation denies the key by principal kind, which the concealment policy does not rewrite. - The 12 body-reading knowledge and files operations publish the 413 their pre-validation body read raises, and the file list publishes the folder-tree 413 its now-capped path index raises. Correctness: queryWorkspaceFilePage loads its folder path index under MAX_FOLDERS_PER_WORKSPACE like the workflow, table, and knowledge lists. An uncapped index does not fail on truncation, so a real folder outside the read rows resolved to undefined and answered "Folder not found". * fix(v2): publish the reachable 413 on body-carrying resources ops `parseRequest` buffers a JSON body through `parseJsonBody` under `DEFAULT_MAX_JSON_BODY_BYTES` before any schema runs, and the v2 builders supply `V2_PARSE_DEFAULTS.payloadTooLargeResponse`, so every operation whose contract declares a body already answers 413 above the cap. The resources family published it on none of them. A status a caller cannot see in the spec is a status they will not handle. Adds `RESOURCE_BODY_ERRORS` and `RESOURCE_CONFLICT_BODY_ERRORS` to the shared sets and applies them to the seven affected operations: createMcpServer, updateMcpServer, createSkill, updateSkill, createCustomTool, updateCustomTool, and setSecret. All seven are `defineV2JsonRoute` handlers on non-GET methods with no `parseOptions` override, so the 413 is genuinely reachable on each. The new sets are opt-in rather than folded into the base sets precisely because reachability is not automatic — an operation with no body, or one whose payload reaches it through an uncapped path, would be publishing a response that can never arrive. A sweep test pins the invariant across the resources, billing, and logs documents. It is one-directional by construction: several bodyless operations publish 413 for their own folder-tree and render ceilings, so the converse would flag correct documentation. Also completes the shared-constant consolidation started in cd3efefab9: `openapi/billing.ts` and `openapi/logs.ts` each re-derived `RESOURCE_ERRORS` inline in two operations. Both now import it, and both regenerate byte-identical. * fix(v2): head-safe binary downloads, coded 403s, and truthful surface docs Adds `headSafe` to `defineV2BinaryRoute`, mirroring the JSON builder: a HEAD on a route that declares itself unsafe is authenticated and rate-limited, then answered bodiless before parsing or executing. `GET /api/v2/files/{fileId}` is the one binary v2 route and it records a `FILE_DOWNLOADED` audit event, so a HEAD probe used to fabricate a download that never happened. Names the cause of five refusals that reached the wire as codeless 403s (billing principal-kind, personal-keys-disabled and role, secret admin and write, the workspace table quota, and public sharing), adding three members to the closed `FORBIDDEN_DETAIL_CODES` set. The billing cross-tenant refusal is concealed as a 404 instead of coded, and the credential-list and knowledge file-ownership refusals stay codeless deliberately, documented at the site. Makes `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` reachable: an operation that denies workspace keys also omits them from `principalKinds`, so the kind guard always fired first and callers got `PRINCIPAL_KIND_NOT_PERMITTED` instead of the published code. Drops the unused 410 response, shares one `order` schema between the two run reads so both specs spell the enum the same way, and corrects the false statements about 403 codes, 413 causes, cursor schemes, and full-set lists in the conventions skill and the contract TSDoc. * fix(tables): close the v2 tables correctness and contract gaps - updateColumnOptions was the only column mutator with no lock assert: an options-only PATCH applied on a schema-locked table, and an option REMOVAL cleared cells on a delete-locked one. Assert schema always, escalate to the destructive gate only when options are dropped. - GET/DELETE /tables/imports/{id} 500'd on a first-party import job (null payload) or an unrepresentable status. Both now read as absent, so the answer is the 404 it always was. - Offset cursors stamped the sort but not the filters, so a page-2 cursor replayed under a different predicate paged an unrelated sequence silently. Offsets now carry a filter fingerprint and refuse a mismatch. - Publish 413 on every tables operation that accepts a request body: the v2 JSON builder reads the body under a byte ceiling before validation, so the status is reachable on all of them. Derived at document assembly so a new route cannot regress it. - Enforce MAX_VIEWS_PER_TABLE on view create, making the list contract's "small bounded set" claim true. - Accept the upload control token on the import read, so an upload-backed import is readable during the phase its own 201 reported; drop the `queued` status the reads can never return. - Declare the Find search-term cap, the Find match cap, and the run row-id ceiling the domain already enforces. - Uniform 201 on the row and column creates. * docs(v2): record why the two migrate-on-read GETs stay head-safe An enumeration of side-effecting v2 GETs flagged these two for issuing a workflow_blocks update. The write is convergent and would be issued by the next ordinary read, and headSafe: false answers 200 unconditionally, so declaring it would cost HEAD its existence check to prevent nothing. * fix(api): classify the caller input that reached the driver unvalidated Four families of caller-reachable 500s share one shape: a value the contract admits, the application forwards, and the database rejects. An unclassified driver throw renders as INTERNAL_ERROR, so a bad request came back as a server fault — on pure reads as well as writes. NUL bytes are rejected at the contract boundary, in parseRequest, not per field. A shared string primitive only protects the fields somebody remembers to build on it, and it cannot protect the values that have no string schema at all: a table cell and a predicate value are z.unknown() because their type belongs to the column, not the wire, and those are exactly the values found reaching the driver. One scan over the already validated params/query/body covers every field including the ones nobody has enumerated. Only U+0000 is rejected; every other control character is ordinary content that Postgres stores verbatim. Date bounds on a filter are now parsed, not merely type-checked, with the same normalizer the date column type uses to store cells — so the filter grammar and the storage grammar agree, and gt/gte/lt/lte on both JSONB date columns and the createdAt/updatedAt system columns answer an unparseable bound with 400 instead of an invalid-input-syntax 500. An afterRowId/beforeRowId anchor that does not exist is a classified not-found rather than a bare Error, and a zero-byte knowledge document is refused at admission: every parser rejects an empty buffer outright, so the upload could only ever consume storage and quota on its way to processingStatus failed. * fix(v2): stop six endpoints from returning a confident untruth Six defects that share a shape: a 200 that misrepresents what happened, which is the one class a caller cannot detect from the response. Knowledge search silently degraded. Reranking is implemented and does run, but a deployment with no Cohere credential, a provider error, or a timeout was swallowed into a warning log and answered 200 with plain vector ordering and no `rerankerScore` anywhere — indistinguishable from a reranker that ran and agreed with the vector order. The fallback stays (an outage should not take search down) and is now reported: `rerankerStatus` is required on every search response. v2 also omitted the `rerankerModel` default the internal contract supplies, so `rerankerEnabled: true` alone failed the use case's model guard and returned unreranked results after paying for the widened candidate retrieval; it now defaults like its sibling. `GET /billing/logs` accepted `startDate`/`endDate` with any relative period and dropped them, answering over the default 30-day window — a caller reconciling charges got real rows that were not the rows it asked for. Both bounds are now rejected outside `period=custom`, take the same strict UTC form as `GET /logs` via the shared `v2RunWindowBoundSchema`, and reject an inverted window instead of returning an empty page. MCP registration stamped `connectionStatus: 'connected'` and `lastConnected: now` at insert without contacting the endpoint, and did the same on any non-OAuth re-registration while leaving `lastError` stale. `tool-validation` gates tool availability on that column, so an unreachable server read as healthy. Both paths now leave the columns at their honest defaults for `mcpService.updateServerStatus` to move after a real discovery; the client-side optimistic copy matches. `skills.create` allowed a workspace API key while every other skill write denies one, so a key could only ever accumulate skills it could never remove — and the row it left was attributed to the workspace's billing owner, minting an editor grant for a human who did not act. Creation now denies a workspace key, making the lifecycle symmetric on the per-skill editor model that authorizes the rest of it. `runCount` counts successful non-paused runs and is never decremented by retention, so it disagrees with the runs list in both directions; the description now says so rather than claiming "total recorded runs". Run retention itself was undocumented — free-plan runs are hard-deleted after 30 days, which is why a workflow reports runs beside an empty list — and is now stated on both reads over the execution-log table. * fix(tables): refuse the writes v2 was silently discarding - Uncoercible cell values were stored as null under a 200 on any optional column: "abc"/true/[1] into number, "yes"/1/{} into boolean, "not-a-date" into date, an undeclared option into select, an object into string. The read side already 400s on the same mismatch in a predicate, so the two halves of the API disagreed about the same value. `coerceRowValues` / `coerceRowToSchema` now take an explicit policy and default to `reject`; `null` is passed only where a machine produced the value for a cell no caller typed — a computed (workflow/enrichment) write and a CSV import, neither of which has anyone to answer with a 400. - A multi-select coerced `["green"]` to `[]` — the drop was inside the registry, so no policy above it could see it. It now refuses any part that matches no option, which is what the single branch and the bulk retype gate already did. - A bare number in a date cell was read as epoch milliseconds, so the far more common Unix-seconds shape stored a timestamp 50 years early. The unit is not recoverable from the value and both readings are in range, so a bare number is refused in both directions and the retype gate no longer needs an override to be stricter than the write path. - Unknown column names were dropped by the name→id remap: an insert of {"nosuchcol":"x"} created an empty row under a 201, and a patch of {"zzz":"x"} answered updatedCount:0, indistinguishable from an empty match. The v2 row boundary now names them and refuses. - The table ceiling was enforced only inside createTable, which for an upload-backed import does not run until the CSV has crossed the wire: a full workspace got a 201 and a presigned PUT for up to 5 GiB, then a 403 at complete with an orphaned object left behind. The advisory check now runs when the session is created; the authoritative one stays in the transaction because the quota can move mid-upload. - Cap workflow groups per table. GET /tables/{id}/groups is published as a full-set list, and the group count had no bound of its own — the indirect one does not survive an update path that adds no columns. - Present a group's outputs/dependencies/inputMappings by column NAME. They are created by name, stored by id, and were read back as ids on a surface that is otherwise name-keyed, so a group could not be round-tripped. - Publish the predicate grammar: the operator set, the per-type restrictions, and that `*` — not `%` — is the wildcard. It was true only in the SQL builder's own comments, so the natural guess matched zero rows under a 200. - Stop advertising a `workflowId` default of "" on group create; a manual group that omits it has always been refused. * fix(v2): bind every paged list's cursor to its filters, not just its sort A v2 cursor names a position in one sequence, and a list decides that sequence from its sort AND its filters. Only the sort was stamped on the shared keyset codec, so a cursor from an unfiltered walk was accepted under a changed `search`, `scope`, `deployedOnly`, or folder and answered from a sequence the caller never asked for. The two offset lists already stamped both; nothing else did. The failure differs by scheme but is silent in both. An offset lands at an unrelated ordinal. A keyset stays internally coherent — correctly ordered, duplicate-free — and drops every match sorting before its position, which a caller holding an opaque token reads as "almost nothing matched". One mechanism, shared with the table-row codec: canonical JSON plus a SHA-256 fingerprint (`lib/api/cursor-binding.ts`), stamped by `cursorFilterScope` alongside `cursorSortKey`. The two stamps stay separate so the 400 names which half changed. `limit` is never bound — it selects how much of the sequence to return, not what it is. The three lists whose token is minted by a domain codec (`/logs`, `/audit-logs`, `/billing/logs`) get the same binding by wrapping that token in a query-stamped envelope; the domain cursor is untouched. `present` now also receives the parsed request, so a presenter reads the filters it stamps straight from the query instead of the use case carrying an HTTP cursor concern back out — the `cursorSort`/`cursorScope` round-trips through three application services are removed. `list-pagination.test.ts` now declares each paged list's binding and checks it against the contract in both directions, so a new list, or a new filter on an existing one, fails until its binding is decided. * fix(v2): authorize HEAD probes and declare every v2 query schema Two ways the v2 surface answered a request it had not checked. `headSafe: false` exists so a HEAD cannot fire the side effect its GET performs — an outbound MCP discovery, a FILE_DOWNLOADED audit event, a WORKFLOW_EXPORTED audit event. The short-circuit sat between admission and parsing, so it returned a bodiless 200 before resource authorization ran at all: authorization lives inside the use case, and the use case was exactly what the short-circuit skipped. Any valid API key drew 200 for a denied principal kind, a nonexistent id, another tenant's workspace, and a request missing a required param, while the GET beside it answered 403 or 404. That is an existence oracle over MCP server ids, file ids, and workflow ids. `OperationUseCase` gains an optional `authorize()` that runs the phase before the business transaction — allowed-principal check, canonical load, asserted-scope comparison, current access check — and stops. `defineAuthorizedWorkspaceUseCase` shares one implementation between it and `execute`, so the two cannot answer differently. A HEAD on a not-head-safe route is now admitted, parsed, and authorized like the GET, rendering refusals through the route's own error policy, then answered bodiless. The builders refuse at definition time to pair `headSafe: false` with a use case that has no `authorize`, so the next such route is a boot failure rather than a silent 200. Separately, `parseRequest` validates the query slice only when the contract declares one, so an omitted `query` means "never look at the query string" rather than "takes no query params". 69 v2 contracts omitted it and accepted anything: `?bogus=1` was a 200 on `GET /workflows/{id}` and a 400 on every list. They now declare `noInputSchema`, and 8 more contracts that declared a query without `.strict()` are tightened. A sweep over the contracts tree is the enforcement — a compile-time gate on `defineRouteContract` was tried and reverted because the required intersection collapses inference of the sibling generics. Four route tests appended `?workspaceId=` to a PATCH/PUT that reads it from the body; that copy was being silently dropped and is now a 400. The generated specs are byte-identical: the OpenAPI generator learns that a slice declaring no keys publishes no parameters. * test(tables): pin the multiselect paste on the refusal, not the silent empty cleanCellValue runs the same registry coercion the server does, so tightening multiselect on the server changed this helper too. The case asserting an empty array was pinning the silent-drop the tightening removed. * docs(v2): make the API-key security description render as plain prose The description was already published on every spec but did not appear in the rendered Authorization block. It carried a raw > and backticks, which the markdown pass in the docs renderer does not survive; the operation description on the same page renders fine. Reworded to plain prose with the same substance. * fix(v2): bind the query cursor to its filter on every shape Two agents each fixed half of this: the shared list codecs gained filter binding, and the table codec gained a fingerprint, but the pure-keyset shape stamped it on neither encode nor decode. A keyset position is absolute in (order_key, id), which is why it was left unbound — but absolute ordering is not completeness. Replaying the cursor under a wider filter silently omits every match sorting before it, so paging predicate A then B returned rows 7,9 where the full B sequence is 1,3,5,7,9. Also answers a lost create race with the conflict it already documents, and shortens three descriptions that dwarfed their siblings — the forbidden-code catalogue now lives on the error envelope's details field, published once per document instead of on all 135 operations. * fix(tables): make a saved view's column references survive the write A view config stores every column reference as a stable column id, but two things wrote it in different vocabularies and nothing translated between them. `config.sort` was pruned on read against the live column ID set while the contract defines `sort[].field` as a column NAME, so every name-keyed sort — the only kind the v2 surface can express — pruned to nothing and the view came back with `sort: null`, on both create and PATCH, with no warning. The same prune dropped a sort on `createdAt`/`updatedAt`/`id`, which are sortable row columns that simply are not in `schema.columns`. `config.filter` had the opposite failure: it was stored verbatim, so a predicate naming a column that does not exist saved happily and then 400'd on every `/query`, `/query/count`, and `/rows/find` that tried to use it. The write path now canonicalizes a config before storing it: every column reference (layout keys, `sort[].field`, each `filter` leaf `field`) is resolved to the column's stable id, and `filter`/`sort` are validated against the live schema so a reference that can never resolve is refused instead of saved. The v2 read presents the config back keyed by column name, matching `presentV2WorkflowGroup` and every other v2 row/data surface — a caller never sees a `col_…` id, and what it wrote is what it reads. Resolution is a lookup with pass-through, so the id-keyed first-party UI is unaffected. Column LAYOUT stays unvalidated on write and pruned on read: it auto-saves as the user drags, so racing a column delete must self-heal, not fail the drag. The read path still never prunes a predicate, for the reason already documented there — a pruned condition silently widens the view's row set. * fix(storage): validate at the decode and multipart boundaries, bound derived keys Four caller-reachable 500s shared one shape: input passed boundary validation, then failed in the storage/key layer. Each is fixed at the boundary that owns the transformation, not at the call sites. Percent-encoded NUL in a canonical folder path. `parseRequest`'s NUL scan sees `%00` as three ordinary characters; the NUL only exists after `parseFolderPath` decodes it. Reads survived as 404s, writers carried the decoded name into an INSERT and the driver threw. The rejection now lives in `encodeFolderPathSegment`, the single chokepoint both building and parsing funnel through, so it covers every escape a caller can spell. NUL in a multipart field. A multipart route declares no body contract, so its fields never reach contract validation at all — the knowledge-document key was sanitized while `original_name` was not, and the object landed in storage before the insert threw. `readFormDataWithLimit` is the shared multipart reader every such route already funnels through, so the scan goes there and runs before a caller holds a File to upload, which removes the orphan rather than cleaning it up. Storage-key overflow at 225 characters. Every generator embedded the file name in a path component it also prefixed with a timestamp and a uniquifier, so the effective limit was 255 minus that prefix while the contract advertised 255 — a 225-character name produced a 256-byte component and ENAMETOOLONG from local storage, and the upload session handed out a transfer URL that could never succeed. `buildStorageKeySegment` reserves the prefix out of the component's budget, making the key independent of name length and the declared limit honest. The NUL predicate is now shared from `@sim/utils/string` by all three boundaries instead of being restated at each. * docs(v2): make the published spec describe the API it has Three descriptions asserted behavior the code no longer has, and three rules the code enforces were published as unconstrained strings. `downloadFile` and `listMcpServerTools` still told callers a `HEAD` on a not-head-safe route "is answered with an empty 200 ... reports only that the endpoint exists and the caller is authorized". That was true of the old short-circuit, which sat between admission and parsing and therefore returned 200 for an id the same caller's `GET` refused. The builders now authorize a HEAD exactly as the GET, so the spec said the opposite of a security fix. One `HEAD_MIRRORS_GET` constant replaces both sentences and is added to `exportWorkflow`, whose `headSafe: false` was never documented at all. A test walks the `app/api/v2` tree for the declaration and fails on any operation that carries it without the sentence, or that resurrects the old claim. `createMcpServer` promised that re-registering an existing URL "rewrites the configuration and returns the server to the same unverified state"; it is a 409 pointing at PATCH. `authType` claimed Sim "detects it from the server when omitted" — registration deliberately never contacts the server, and the column defaults to `headers`. The default stays: `headers` and `none` are behaviourally identical (only `oauth` branches), so changing it is a migration with no caller-visible payoff, while the sentence was simply false. `predicate` was the API's most consequential gap: a `pipe` over `z.unknown()` documents from its input, so the leaf keys `field`/`op`/`value` appeared nowhere in the contract and `{column, operator, value}` was a 400 a caller could not correct against. Both predicate schemas now publish a real recursive JSON Schema through `.meta()`, self-referencing so the recursion resolves from one `$defs` entry, with every bound read from the constant that enforces it. Also published: the canonical folder-path rule and its 4096-byte cap on the four path components (the `superRefine` contributed nothing to JSON Schema); the closed 12-value `recursive` vocabulary on a destructive delete; and the null-matching behaviour of the negating operators. The clamping `limit` branch drops `minimum`/`maximum`, which in JSON Schema mean "rejected outside" and made SDKs refuse locally what the server clamps. `deleteFile` stops publishing a 409 nothing in its path can raise. `restoreFile` and `abortFileUpload` keep theirs — the report called them unemittable, but restore raises `FileConflictError` after exhausting its rename retries and abort refuses a completed session. Description tail, across the seven specs: p99 733 to 465, max operation 1643 to 1114, over 700 chars 31 to 13, over 400 70 to 61. Constraints moved from operation prose onto the fields they constrain rather than being deleted. * fix(v2): make upload completion, blank query values, search, and folder filters answer correctly Four defects on the v2 surface, each reproduced before it was fixed. Upload completion dispatched document indexing from inside the completion transaction, so a queue or processing failure returned 500 after the object was stored, the document row was created, and the session was marked completed — and the only recovery, replaying the request, answered 200. The dispatch is now a follow-on step that runs after the session is durably completed and is logged rather than raised. Its outcome stays visible on the document itself (`failed` with an error, or `pending` when it was never picked up), and the recovery path re-queues a `pending` registration instead of keying off a message left on the session. A query parameter sent with no value was read as `0`, `false`, or the parameter default: `?limit=` became `LIMIT 1` on the three lists that clamp, and `?minCost=` on `/logs` became a live `cost >= 0` filter. `search` and `cursor` already rejected a blank and documented "omit the parameter instead"; that rule now applies to every v2 parameter, enforced on the raw query before coercion so a parameter added later inherits it. The document list matched `_` and `%` in `search` as live LIKE wildcards while every sibling list escaped them through `searchFilter`, so the documented substring match returned everything for `a_itest`. It now uses the same helper. A `folderPath`/`folderPaths` naming no folder answered 404 on `/logs`, `/files`, `/workflows`, `/tables`, and `/knowledge`, while every other filter answers an empty page and the sibling folder lists already do. All five now return an empty page. Mutations keep their 404. * chore(v2): regenerate the specs from the merged sources The four spec conflicts in the wave-3 merge were resolved by taking one side, which left them describing neither branch. Regenerated so the published documents match the contracts they are built from. * docs(v2): give a built-in skill's id its real form The contract said a built-in skill uses its name as the id. The ids are `builtin-` plus the name, so a client following the description asks for /skills/research and gets a 404 where the spec promises the skill. * fix(uploads): keep local upload artifacts inside NAME_MAX `POST /api/v2/files/uploads` accepted a name of up to 255 characters, returned 201, and handed back a transfer URL that could never succeed: the PUT against it 500'd and `complete` then reported the object missing. The local provider named its staged object after the destination — `{key}.{uploadId}-{uuid}.tmp` plus a `.upload-metadata.json` sidecar — so the staged component was the key's length plus ~99 bytes of fixed overhead. Past roughly 125 characters of name that crossed POSIX `NAME_MAX`, and `ENAMETOOLONG` is not a `LocalUploadBodyError`, so it escaped as a 500. Multipart `complete` built the same name and failed the same way. Only local storage is affected; S3, Azure, and GCS have no per-component limit. `buildStorageKeySegment` already budgeted the key to 255, one layer above where the overflow happened. Two changes close it at the layers that own each suffix: - Staged artifacts move to a `.staging` root and are named from the upload id alone. A name derived from the destination inherits its length and then adds to it; a fixed-width one removes the arithmetic instead of re-budgeting it, so no suffix added here later can depend on the caller's file name. The staging root is a cleanup sweep root, which also reclaims artifacts that used to be orphaned beside the destination. - The durable sidecar is reserved out of the key budget centrally. `LOCAL_UPLOAD_METADATA_SUFFIX` moves next to the budget that must account for it, and the budget is derived from a list of sidecar suffixes, so adding one shrinks every key builder at once. The declared `maxLength: 255` stays honest: a 255-character name now completes PUT and `complete` end to end. * fix(uploads): budget every key built from a caller-supplied name Auditing the rest of the codebase for the shape that broke the upload-session PUT found five more key builders that put an unbounded name into a path component local storage writes directly. Three are on the same route as the original bug: `table_import`, `profile_picture`, and `workspace_logo` built their key inline with `sanitizeFileName`, which maps characters and never truncates, while their sibling purposes went through `buildStorageKeySegment`. A 255-character name broke `table_import` at the metadata sidecar and the other two at the object write itself. The other two are local-storage writers reached from elsewhere: knowledge-base connector sync capped the document title at 200 and then appended a timestamp, a uuid and `.txt` on top of the cap, landing at exactly 255 with no room for the sidecar; the Mistral-OCR staging and chunk keys inlined the sanitizer with no bound at all; and inbound email attachments went into a key with neither sanitizer nor bound, on a file name an outside sender chooses. All now derive their component through `buildStorageKeySegment`, so the reservation is stated once. The upload-session test asserts it for every purpose the contract admits, which is what keeps a newly added purpose from reintroducing the hand-built form. * fix(v2): stop the logs and billing reads answering 500 or a silent restart Four caller-reachable failures on `GET /logs`, `GET /logs/{runId}`, and `GET /billing/logs`, each fixed at the layer that owns the guarantee. `minDurationMs`/`maxDurationMs` were published as `number` against an `integer` column, so `1.5`, `-0.5`, `2147483648`, and `1e30` all reached Postgres as bind parameters it refuses to parse. They are now whole milliseconds bounded to int4, and the generated spec says so. `0000-01-01T00:00:00Z` satisfies the published `date-time` pattern but names no instant Postgres can store, since the proleptic Gregorian calendar has no year zero. `v2RunWindowBoundSchema` now rejects it, which covers both log families and the files-audit read that share the schema. A scoped cursor whose inner token was the empty string passed the `typeof === 'string'` envelope check and then read as falsy in every domain reader, so both lists silently served page one again with a `nextCursor` inviting another lap — the exact failure `UNKNOWN_CURSOR_MESSAGE` exists to make visible. An empty inner is now unreadable, and the sibling `decodePublicLogCursor` gets the same treatment for its `id` half. The rejection message no longer names `sortBy`/`sortOrder`, which neither operation accepts. `GET /logs/{runId}` reported `folderPath: null` for both a workflow at the workspace root and a folder it could not resolve, so a caller could distinguish neither, and `null` is not a value `folderPaths` takes back as a filter. The root is now `/`, matching the workflow resources. Also, from the same audit: comma lists reject an empty entry the way `folderPaths` already did instead of dropping it; a query param sent twice is named as duplicated rather than reported absent; and the `triggers=all` sentinel, the detail-level promotion by `includeTraceSpans`/`includeFinalOutput`, and the 403/404 split against the billing family are documented where each is decided. * fix(v2): pin naive timestamps to UTC and close six contract divergences Application-written timestamps reached the wire as a local wall clock labelled `Z`. Every column in `schema.ts` is `timestamp without time zone`, so the instant a value denotes was decided by whoever wrote it and whoever read it, and the writers disagreed: `now()` renders in the session's TimeZone, drizzle's `mapToDriverValue` is `toISOString()`, and a raw `Date` bound through postgres.js is cast down in the session's TimeZone. The read side disagreed the same way — postgres.js parses oid 1114 with `new Date(x)`, which is the process's local zone, while a value it hands back as a string is read as UTC by drizzle. The result passes every `date-time` check, so it silently corrupts sorts and range predicates and can place `updatedAt` before its own `createdAt`. `packages/db/timestamps.ts` removes the ambiguity at the driver boundary rather than at the call sites: the session TimeZone is pinned to UTC so all three write paths store the same wall clock, and oid 1114 is parsed as UTC so every read path recovers that instant. `withUtcTimestamps` merges both into a client's options, because `connection` is nested and a pool setting its own `application_name` would otherwise drop the TimeZone. Production already runs both in UTC, so nothing changes there; every other environment now behaves the way production does. Alongside it, six places where the published contract and the code disagreed: - Multi-select `ncontains` was documented as "the exception" that excludes nulls. It never did, and no test claimed it did — `data` is never NULL, so containment is false for an absent key and the negation is true, exactly like every other negation. The sentence was wrong. - `recursive` published twelve lowercase spellings while `z.stringbool()` folded case, so the server honoured `recursive=True` as a destructive recursive delete that a generated client would have refused to send. Narrowed to case-sensitive: accept exactly what is published. - The upload data plane answered with a bare `{ error: string }`. Being absent from the OpenAPI documents is a statement about addressability, not about behaviour; both PUTs now use the canonical envelope, and what the transfer step promises is published on `transfer.url`. - Full-set lists told callers to "send it back as `cursor`" on a `.strict()` query that rejects `cursor`. `v2CursorListResponse` now takes `paged`. - A `HEAD` on a download skips the read that produces `Content-Length`, so it cannot size a download; the description says so. - The upsert conflict-target rejection echoed the storage id a name-keyed surface had already translated to, and the scoped-cursor 400 named `sortBy`/`sortOrder` params `/audit-logs` does not accept. * improvement(v2): cut the extraneous half out of the published descriptions The v2 spec's description median was already healthy at 42 characters; the tail was not. 174 descriptions ran past 200 characters and 13 past 700, almost all of it rationale, cross-references, and constraints restated on the wrong object. Trim the shared error, folder-path, retention, pagination, and workspace-key constants first, since each is published on between two and twenty-seven operations. `FOLDER_TREE_TOO_LARGE` dropped the clause explaining why the tree has to load, `FULL_SET_LIST` dropped a second sentence restating its first, `RUN_RETENTION` dropped the `runCount` caveat that already lives on `runCount`, and the 503 and 499 descriptions dropped the paragraphs narrating why they are documented at all. That reasoning belongs in the TSDoc beside each constant, which is where it now is. Then the operations. Execute Workflow and List Runs each restated a rule their own parameters already carry — the `X-Run-Id` uniqueness claim and the `order` sort deviation — so both moved to the parameter that owns them. The run-status enum sent a caller to `paused.automaticResumeWaitingReason` and then explained that field in place of describing it; the explanation moved onto the field, which previously said only that it was "the reason automatic resume is waiting". Align the parameter vocabulary a caller meets in every family. One `cursor` description had forked on the table row query, one `sortBy` on knowledge documents, and the table row `limit` published neither its bounds nor its default. `nameSortCollation` is now a function of the column it names, so the knowledge document list can state the caveat about `filename` without claiming a `name` field it does not have. `scripts/openapi/documents.test.ts` pins `cursor` and `sortOrder` to one string each, and the retention window to both reads that publish it. Distribution over the seven documents: mean 71 to 67, p95 223 to 199, p99 453 to 370. Over 200 characters 174 to 147, over 300 94 to 59, over 400 54 to 20, over 700 13 to 9. The median is unchanged at 42. * fix(v2): keep one unreadable-cursor message Two branches each added the constant, in cursor-binding and list-query. It belongs beside its sibling REFILTERED_CURSOR_MESSAGE, so the list-query copy and its importers move there. * fix(v2): bind a cursor to what a set filter means, not how it was spelled workflowIds, triggers and folderPaths are comma lists the query treats as unordered sets, and tagFilters is an object whose key order carries no meaning. Fingerprinting the raw spelling bound the cursor to the spelling, so a caller who reordered an equivalent filter mid-walk got a 400 for a page that was genuinely the next one. * 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. * fix(w5): narrow three fixes that reached past the harm they were fixing The workflow-create `23505` handler answered for the whole transaction, which also runs `saveWorkflowToNormalizedTables`. `workflow_blocks.id` is a global primary key, so a block-id collision — an integrity fault already seen in production — surfaced as `A workflow named "X" already exists in this folder`. Match on the constraint name; any other unique violation propagates unchanged. Moving the knowledge dispatch out of the completion transaction was right, but a dispatch failure then committed the session as `completed` and left the document at `pending`, which nothing sweeps and `retryProcessing` refuses. Record the failure on the document instead, so it lands on the existing failed-document path, and describe what the code does rather than a recovery branch that cannot fire for this state. The MCP re-registration reset stopped a registration claiming a connection it never made, but reset for any re-registration. `isServerEligibleForDiscovery` skips an OAuth row that is not `connected`, so a rename removed every tool the server published with no path back. Scope the reset to url, transport, headers, auth type, OAuth credentials, and revival. * fix(tables): confine the write-policy tightening to what the caller sent The null-policy work made `reject` the default for caller-supplied writes, which is right, but it landed on the wrong values. - A partial update coerces the MERGED row, so an untouched legacy cell failed an unrelated column's update — and failed a paged bulk job after its earlier pages had committed. The merged-row callers now name the patch's keys; every other key follows the `null` policy, in the in-memory copy only (the write sends the patched keys alone). - A multiselect whose members do not all resolve returned `{ok:false}`, which on the machine paths that pass `'null'` — CSV import, computed writes, the cell-write snapshot — erased the whole cell. Those paths now consult a new `salvage` hook and keep the members that do resolve; a caller-supplied write still 400s on an unknown option. - Refusing a bare number in `date.coerce` reached the executor, v1, copilot and the grid. The refusal stays where there is a caller to tell, and `salvage` restores the milliseconds reading where the only other answer is a blank cell. Also: the cursor docblocks claimed pure-keyset cursors were left unbound while the code and its tests bind them; a saved-view create took the table's SCHEMA advisory lock, so it queued behind column rewrites whose statement timeouts run past its 3s lock_timeout, and now takes a views-scoped lock instead; and a view whose column was deleted could not be saved at all, because the Save chip always resends the filter — references the stored config already carries are now exempt while a newly introduced one is still refused. The cursor version is deliberately not bumped: the stamp is additive, unfiltered in-flight tokens keep working, and a filtered one fails with the accurate "restart paging without the cursor" rather than a generic unreadable-cursor 400. * test(db): narrow the mapped timestamp to Date mapFromDriverValue is typed unknown, so the composition assertions did not type-check outside the test's own runner. * fix(v2): correct four stale contracts and clear the merge debris behind them Five of the reported defects were real and four of them were documentation that had stopped describing its own code. `cleanCellValue` said only "coerce a raw input value"; it also answers `null` for anything the column type refuses, and since the multiselect write path started refusing partial matches that is the difference between a paste storing one option and blanking the cell. It deliberately does not consult `salvage`, which would read the same paste as the option that did resolve — that reading is for writes with no caller to answer, and a typed cell has one. The pairing is now asserted, so a future helper that "improves" the paste by salvaging it fails. `EXECUTE_OPTION_CONSTRAINTS` carried two stacked TSDoc blocks, the second explaining that the enumeration had moved onto the fields; the body schema still told a reader the six combinations were enumerated in the constant. The deployment route's second block orphaned the endpoint documentation above it, and `list-query.ts` kept the TSDoc for a cursor message that now lives, with its own rewritten doc, in `cursor-binding.ts`. Two agents left near-identical essays arguing the same 400-vs-403-vs-409 question about the table ceilings and concluding that neither status changes; the decision is recorded once, in `billing.ts`, and `service.ts` points at it. The credentials use case echoed `sortBy`/`sortOrder` back with a TSDoc explaining that the presenter needs them, which it no longer does — it reads `query.*`. The local upload roots move from the data-plane provider to `core/storage-key.ts`, beside the sidecar suffix, so the cleanup sweep can name what it reclaims without importing the transport that writes it. `documents.test.ts` justified sweeping only knowledge and files for the 413 by saying the same sweep over the other five documents still reported gaps. It does not: widened to all seven, every body-carrying operation publishes it. Three reports did not survive checking, and the evidence is recorded where the next reader will look. An empty rerank result is not the reranker matching nothing — `rerank` asks for `top_n` over a non-empty document list, so an empty array means the response carried nothing usable, which is what `unavailable` already promises. The zero-byte knowledge document is refused on the upload-session path too, by `validateFile`, under both boundary contracts; that parity is now pinned, and it fails if the guard is removed. The MCP re-registration reports exactly the connection fields its SET clause writes, and the create mutation already drops both caches — what lags is the status badge, not the tools, because discovery is gated on `connected` for OAuth rows only. * fix(v2): de-duplicate a set filter before fingerprinting it The filters compile to inArray, which is set membership, so workflowIds=A,A,B selects exactly what A,B does. Sorting alone still bound them to different pages, so an equivalent filter with a repeated member 400d mid-walk. * fix(w6): close a head-authorization hole, a TZ leak, and five tests that could not fail Six risks an adversarial read of this week's diff raised, verified one at a time. Two of the six were already correct and are reported as such rather than changed. `v2HeadAuthorizationResponse` optional-called the use case's authorization phase, so a use case without one would have answered the bodiless 200 that `headSafe: false` exists to prevent. The definition-time guard does cover both builders that reach it — they are its only callers — but an optional call turns a missing phase into that leak silently, so the responder now refuses instead of skipping. `packages/db/timestamps.test.ts` assigned `process.env.TZ` at module scope and never restored it. `TZ` is process state: a worker running files back to back carried Asia/Tokyo into every file that followed, and only when the ordering put it after this one. The zone is now set and restored around the file, with both properties the suite depends on intact. Upload publication moved its staging area out of the destination's own directory into a shared `.staging` root, which makes the publishing `link` a cross-subtree one. A volume mounted under part of the uploads tree puts the two on different devices and `link` answers `EXDEV`, which the same-directory link could not. Publication now copies onto the destination's device and links from there, keeping the create-or-fail step that stops a replay from overwriting a stored object. Five tests that passed regardless of the code: - `resolveFolderPathFilter` was only ever exercised through hand-written reimplementations in the suites that mock it out, so widening a miss to unfiltered — every filtered list answering with the whole workspace — left them all green. The real helper is now tested where it lives. - The only measurement of `generateWorkspaceFileKey` asserted the key's last component against `NAME_MAX` rather than the component plus the sidecar written beside it, so it passed with the sidecar reservation removed. - `GET /logs` asserted only that a rejected cursor does NOT name `sortBy`, which almost any wording satisfies, including one saying nothing at all. - The skills lifecycle test asserted that the four writes agree on a workspace-key policy, which a lifecycle uniformly allowing one also satisfies; it now pins the policy they agree on and the kinds they admit. - The v2 skills create test lost `expect(capture).not.toHaveBeenCalled()` when the create path moved to a personal key. The behaviour it pinned is gone — the workspace-key create is refused now — so it is re-homed as the refusal reaching the caller as a 403 with no analytics behind it. Two claims did not hold. `CURSOR_VERSION` is correctly left at 1: the filter stamp is additive, a pre-stamp token still decodes, an unfiltered read still resumes, and only a filtered replay fails — with a conflict that names the filter, where a version bump would answer a generic unreadable-cursor 400 to every in-flight token. Tests pin all three, plus the minted version itself. And the upload-session key-budget cases do exercise the real shared budget through the real segment builder; only the workspace-key prefix is the stub's, which is now stated where the stub is declared. * refactor(v2): collapse two names for the cursor scope key onto one helper `cursorFilterScope` in the v2 response module was a one-line pass-through to `cursorScopeKey` in `lib/api/cursor-binding`, so the same function was reachable under two names from two modules. Routes now call `cursorScopeKey` directly, the way they already import `unorderedScopePart` and the cursor messages from that module, and the wrapper plus its duplicated doc comment are gone. Also folds the `id -> name` column map in the v2 tables presenter onto `buildColumnNameById`, which the same file already imports and calls thirteen lines above; restores two doc comments that had drifted onto the wrong declaration; and replaces three `as Date` casts in the timestamp test with `toEqual(new Date(...))`, which needs no cast and additionally fails when the mapped value is not a Date at all. * refactor: delete three pieces of surface this branch added with no consumer `v2CursorSchema` had one caller, `v2PaginationFields`, in the same file, and its only parameter was a default nobody overrode — so the export and the parameter were both unreachable. Inlined into the pair it belongs to; the emitted schema and its description are byte-identical, so the generated OpenAPI does not move. `PatchedKeys` was declared `ReadonlySet<string> | readonly string[]`, but all four callers pass `Object.keys(...)` and no test passes a set, which left the `instanceof Set` arm of `policyResolver` unreachable. Narrowed to the array form the callers actually use. `NUL_CHARACTER` was exported from `@sim/utils/string` and imported by nobody — every boundary imports `containsNulCharacter` instead. Kept as the module-local constant the predicate reads, dropped from the package surface. * docs(v2): state why the local upload data-plane routes bypass the builders Both local-storage PUT routes use raw `withRouteHandler`. The global rule allows that only for documented protocol or lifecycle exceptions, and their TSDoc explained the OpenAPI exemption and the error envelope but never the builder bypass itself. Record the actual reason: a signed `upload-token` is the credential, so there is no API key, `Principal`, or semantic operation for a builder to authenticate and authorize against, and the body streams straight to storage rather than being parsed. * test(v2): pin cursor-to-filter binding on the tables and runs lists The branch binds every paged cursor to the filters it was minted under, but the binding was enforced end-to-end on only 4 of 16 paged lists. The contract-level CURSOR_BINDINGS sweep looks like the safety net and is not: it checks each contract against a hand-maintained map of param names, never against what a route actually stamps into cursorScopeKey, so it stays green for a route that dropped the stamp entirely. Confirmed by deletion. Removing tableCursorFilters from both call sites on GET /v2/tables left all 8 tests passing, and the runs route was worse — its one relevant assertion was weakened from toEqual to toMatchObject in this same branch, leaving the new filter field unpinned. Adds a mint-then-replay test to each: a cursor minted under one filter set and replayed under another is a 400 that never reaches the use case, with a same-filter resume case as the control so the 400 cannot be satisfied by blanket rejection. Restores toEqual on the runs cursor payload, pinning that a filter is stamped without hardcoding the fingerprint. Both new guards were verified to fail: removing the binding reddens the refiltered test on tables, and both the refiltered and the re-armed toEqual test on runs. * fix(tables): keep the v2 write strictness inside v2 The write-path tightening on this branch changed shared code that every first-party surface reaches, so the workspace grid, the internal `/api/table` routes, `/api/v1`, the Copilot table tools, and the executor's Table block all inherited a contract only `/api/v2` publishes. Each of them now behaves exactly as it does on staging again, and v2 keeps the strictness by opting into it. - `coerceRowValues`/`coerceRowToSchema` default to the `null` policy again — an uncoercible optional cell is blanked and the row is written. `reject` is reached through `RowWriteOptions.uncoercibleValues`, which the v2 row routes set via `strictWrite` on the application input. - The same `strictWrite` scopes the unknown-column refusal to v2. Copilot feeds the model's raw arguments in unfiltered, so a hallucinated key, an echoed `id`, or a name left over from a rename had begun refusing the whole write. - Multiselect and bare-epoch values land again for first-party callers through the registry's existing `salvage` hook, which the `null` policy already consults; the grid's `cleanCellValue` consults it too, so a paste naming one live option and one deleted one keeps the live one instead of erasing the cell. - The saved-view name→id remap no longer rewrites a ref that already means something else, so a user column named `id`/`createdAt`/`updatedAt` cannot hijack a view's system-column sort or filter. - `createTableView` tolerates the refs its own config carries unless the caller is strict, so "Save as view" stops 400ing on a dangling filter the Save chip accepts. - The bulk update runner is byte-identical to staging again. The 100-view cap stays: the list read is unpaginated, so the promise it makes only holds if the write side enforces it, and it refuses a new view rather than an existing config. * test(v2): pin cursor-to-filter binding on seven more paged lists Extends the mint-then-replay guard from tables and workflow runs to the remaining paged v2 lists the audit found with no route-level coverage: credentials, audit-logs, custom-tools, mcp-servers, secrets, knowledge bases, and knowledge documents. Each gets a cursor minted by driving GET under one filter and replayed under another, asserting a 400 carrying REFILTERED_CURSOR_MESSAGE that never reaches the use case, plus a same-filter resume control so the 400 cannot be satisfied by blanket rejection. The three cursor schemes are all covered: keyset (readSortedCursor), the scoped wrapper audit-logs uses for its domain token, and the offset cursor on knowledge documents. The documents suite had no GET coverage at all, so its list use case gains a real mock and the route's GET export a describe block. All fourteen were verified to fail: dropping the cursor-filter argument from both call sites on each route reddens exactly that route's refiltered test and leaves every other assertion in the file green, which is the failure mode the contract-level CURSOR_BINDINGS sweep cannot see. * test: cover four untested behaviors and drop five tests that cannot fail Adds coverage that goes red when the behavior is reverted: - `rejectDuplicateQueryValues` through `parseRequest`, not just the pure helper — the existing blank-query tests stay green even when parseRequest ignores the flag entirely. - `failUndispatchedDocumentProcessing`'s pending + not-deleted WHERE guard, asserted on the condition tree so removing it fails. - The widened `present(result, request)` signature, so dropping the second argument stops being a silent no-op. - The NUL scan on `readFormDataWithLimit`'s content-length branch — the branch every ordinary browser and curl upload takes, and the one the existing multipart tests never reached. Removes tests verified incapable of failing: the credentials projection row (the outbound `.parse()` strips unknown keys either way), the per-document 413 sweep (vacuous on two of three documents, subsumed by the sweep in scripts/openapi/documents.test.ts), the two upload-session rows that assert their own `generateWorkspaceFileKey` stub, the storage-key row whose 20-byte name never reaches the budget, and the views-lock assertion against a function `views/service.ts` does not import. * fix(v2): parse a bound list filter once, so the scope matches the query The logs list fingerprinted `workflowIds`, `triggers`, and `folderPaths` through unorderedScopePart, which trims each member, then split the same raw values itself with `.split(',').filter(Boolean)`, which does not. So `?workflowIds=A,B` and `?workflowIds=A, B` produced one fingerprint and two different result sets: the second selects on a member with a leading space that matches no row. A cursor minted under one was accepted under the other, which is the exact failure the filter binding exists to refuse. Extracts parseUnorderedList as the single parse. unorderedScopePart now derives from it, and the route passes the array to the query and the joined form to the scope, so the members fingerprinted are by construction the members filtered on. Also drops three inline splits. Reported by Greptile. * fix(v2): bind an AND-conjoined filter array as a set, not a sequence The knowledge documents list fingerprinted tagFilters through canonicalJson, which sorts object keys but preserves array order. Each filter compiles to a condition in and(...whereConditions), and AND is commutative, so the same clauses written in a different order select the same documents — and got a different fingerprint, refusing a cursor for a page that was genuinely the next one. Adds unorderedJsonScopePart beside parseUnorderedList: members are canonicalized, de-duplicated, and sorted, so `A AND A` binds like `A` and clause order stops mattering. A non-array or unparseable value still binds by its raw spelling, since that request fails validation anyway. Replaces the route-local canonicalTagFilters, and corrects the claim on canonicalJson that array order only ever costs a restart — for a set-valued filter it costs a spurious 400. Reported by Greptile. * fix(v2): bind list filters by the value the query acts on, not its spelling Third report of one root cause, so this fixes the cause rather than the case. A cursor scope must fingerprint what the query filters on; every place it fingerprinted the caller's raw text instead, two spellings of one filter got two scopes and a valid next page got a 400. Knowledge documents: tagFilters bound the raw query text while the route already parsed it two lines below for the use case. The schema defaults operator to 'eq', so {tagName,value} and {tagName,value,operator:'eq'} are one filter to the query and were two scopes to the cursor. The scope now binds the parser's output, which also subsumes the clause-order fix…
1 parent 1e60042 commit 6de8ba2

305 files changed

Lines changed: 14624 additions & 2556 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/v2-api-conventions/SKILL.md

Lines changed: 24 additions & 10 deletions
Large diffs are not rendered by default.

.claude/commands/v2-api-conventions.md

Lines changed: 24 additions & 10 deletions
Large diffs are not rendered by default.

.cursor/commands/v2-api-conventions.md

Lines changed: 24 additions & 10 deletions
Large diffs are not rendered by default.

.gitattributes

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ Dockerfile* text eol=lf
2121
.gitignore text eol=lf
2222
.gitattributes text eol=lf
2323

24+
# Source files always diff as text. Git otherwise classifies a whole file as
25+
# binary the moment it contains a NUL byte, hiding every line of it from review.
26+
*.ts diff
27+
*.tsx diff
28+
*.js diff
29+
*.jsx diff
30+
*.json diff
31+
*.md diff
32+
2433
# Denote all files that are truly binary and should not be modified
2534
*.png binary
2635
*.jpg binary
10 Bytes
Binary file not shown.

apps/docs/content/docs/de/api-reference/getting-started.mdx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum
171171
| `404` | Resource not found | Verify the ID exists and belongs to your workspace |
172172
| `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header |
173173

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.
179+
180+
```json
181+
{
182+
"error": {
183+
"code": "BAD_REQUEST",
184+
"message": "Invalid request",
185+
"details": [
186+
{ "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" }
187+
]
188+
}
189+
}
190+
```
191+
174192
<Callout type="info">
175193
Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage.
176194
</Callout>

apps/docs/content/docs/en/api-reference/getting-started.mdx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum
171171
| `404` | Resource not found | Verify the ID exists and belongs to your workspace |
172172
| `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header |
173173

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.
179+
180+
```json
181+
{
182+
"error": {
183+
"code": "BAD_REQUEST",
184+
"message": "Invalid request",
185+
"details": [
186+
{ "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" }
187+
]
188+
}
189+
}
190+
```
191+
174192
<Callout type="info">
175193
Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage.
176194
</Callout>

apps/docs/content/docs/es/api-reference/getting-started.mdx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum
171171
| `404` | Resource not found | Verify the ID exists and belongs to your workspace |
172172
| `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header |
173173

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.
179+
180+
```json
181+
{
182+
"error": {
183+
"code": "BAD_REQUEST",
184+
"message": "Invalid request",
185+
"details": [
186+
{ "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" }
187+
]
188+
}
189+
}
190+
```
191+
174192
<Callout type="info">
175193
Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage.
176194
</Callout>

apps/docs/content/docs/fr/api-reference/getting-started.mdx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum
171171
| `404` | Resource not found | Verify the ID exists and belongs to your workspace |
172172
| `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header |
173173

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.
179+
180+
```json
181+
{
182+
"error": {
183+
"code": "BAD_REQUEST",
184+
"message": "Invalid request",
185+
"details": [
186+
{ "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" }
187+
]
188+
}
189+
}
190+
```
191+
174192
<Callout type="info">
175193
Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage.
176194
</Callout>

apps/docs/content/docs/ja/api-reference/getting-started.mdx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,24 @@ The API uses standard HTTP status codes. v2 errors include a stable code and hum
171171
| `404` | Resource not found | Verify the ID exists and belongs to your workspace |
172172
| `429` | Rate limit exceeded | Wait for the duration in the `Retry-After` header |
173173

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.
179+
180+
```json
181+
{
182+
"error": {
183+
"code": "BAD_REQUEST",
184+
"message": "Invalid request",
185+
"details": [
186+
{ "code": "unrecognized_keys", "keys": ["limt"], "path": [], "message": "Unrecognized key: \"limt\"" }
187+
]
188+
}
189+
}
190+
```
191+
174192
<Callout type="info">
175193
Use [Get Billing Status](/api-reference/billing/getBillingStatus) to inspect current credit and storage usage.
176194
</Callout>

0 commit comments

Comments
 (0)