fix(v2): close the correctness gaps an end-to-end audit found - #6655
fix(v2): close the correctness gaps an end-to-end audit found#6655waleedlatif1 wants to merge 72 commits into
Conversation
`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.
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.
…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".
`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 cd3efef: `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.
… 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.
- 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.
…-hardening # Conflicts: # apps/sim/lib/api/contracts/v2/shared.ts
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.
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.
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.
- 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.
…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.
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.
…t 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.
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.
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.
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.
…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.
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.
…er 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.
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.
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 2996814. Configure here.
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.
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 9ad50fd. Configure here.
…elling
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 — both
route tests go red against the raw-text form.
Logs and workflow runs: startDate/endDate bound the raw text, but
z.string().datetime() admits every sub-second spelling of one instant, so
`…00Z` and `…00.000Z` name one window and got two scopes. New
instantScopePart binds the parsed instant.
Replaces unorderedJsonScopePart, which took raw text and could not see a
schema default, with unorderedScopeOf over the parsed value.
Swept all fourteen routes that build a cursor scope for the same divergence;
these were the only ones where a scope part is derived differently from the
value reaching the use case.
Reported by Greptile.
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 065eda2. Configure here.
The previous sweep for this defect looked for a transform in mapInput, so it missed the two routes that pass their raw bounds to a use case that parses them deeper. Both fingerprinted startDate/endDate as text while their predicates convert to a Date, so `…00Z` and `…00.000Z` name one window and got two scopes, refusing the genuine next page. Billing keeps stamping the raw params rather than resolveDateRange's output, for the reason already recorded there: a relative `period` resolves against the clock, so hashing the resolved window would reject every next page. Normalizing the explicit bounds is compatible — instantScopePart is a pure function of the caller's own text and resolves nothing. Re-swept all fourteen cursor-scope routes by scope part rather than by transform site. Every temporal and structured part now binds canonically; the rest are enums and identifiers with one spelling per value. Reported by Greptile.
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit b78a640. Configure here.
resolveKnowledgeTagFilters builds every structured filter with the stored definition's fieldType and never reads the caller's — not for resolution, not for validation, not in its output. Fingerprinting it made a field the query ignores decide whether a cursor resumes, so adding or removing a matching fieldType refused a page that had not moved. Swept the other twelve cursor-scope routes for the same shape. No scope part is absent from its mapInput, this was the only scope carrying a structure resolved against stored state, and knowledge/search has no cursor at all. Reported by Greptile.
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 28ccfb3. Configure here.
Hardens the v2 surface after an end-to-end audit and roughly 2,000 live checks against a running server. Four waves of fixes, each verified at runtime rather than by unit test alone.
Caller-reachable 500s, now correct statuses
POST /files/uploadsreturned 201 and issued a transfer URL whosePUTthen 500'd. Local staging artifacts were name-derived and stacked ~130 bytes of suffixes onto a key already budgeted to 255, crossingNAME_MAX. Staging artifacts are no longer derived from the caller's name at all, so no future suffix can reintroduce the arithmetic; the durable sidecar keeps a central reservation. Five more key builders had the same shape — including an inbound email attachment name that was neither sanitized nor bounded.minDurationMs=1.5and any value outside int4 reached Postgres unguarded; the contract publishednumberagainst anintegercolumn.0000timestamps satisfied the published\d{4}pattern and 500'd in both logs and billing.afterRowId, zero-byte uploads, and MCPinputSchema.descriptionall 500'd.Silent wrong answers
Z. All 283timestamp(...)columns arewithout time zone, so three writers and two readers disagreed about what instant a stored wall clock meant. A saved view'screatedAtwas seven hours off while passing schema validation, corrupting any sort or predicate over those fields. The session is now pinned to UTC with a UTC-anchored parser for oid 1114.""slipped the envelope check and restarted at page 1 with anextCursor, the exact failure the billing ledger documents as unacceptable.nulland returned 200. Now rejected, with'null'retained at three machine-write sites so one bad row in a CSV import cannot reject the other 99,999.config.sortwas always discarded — the pruner compared column ids against a contract that specifies names.SELECTwith no unique-violation handler.HEADskipped authorization entirely, answering 200 for a denied principal, a nonexistent resource, and an unauthorized workspace — and fabricated audit rows for exports and downloads that never happened.Contract and documentation
69 contracts declared no query schema at all and silently accepted any parameter; 8 more were non-strict. Blank query values are now rejected surface-wide rather than coercing to
0. The spec now documents what the service does: HEAD semantics on the three head-unsafe routes, the predicate grammar with its*wildcard, the canonical folder-path format, therecursivevocabulary, retention windows, and a built-in skill's real id form. Descriptions lost their extraneous half — p99 down from 733 to 370 characters, the >400 band down 63% — with load-bearing rules relocated to one shared place rather than deleted.Verification
Roughly 2,000 live checks across all 135 operations and seven resource families: every filter asserted to narrow results rather than merely return 200, ~40 pagination walks to exhaustion with zero duplicates or skips, every 2xx body validated against its published schema with
additionalPropertiesenforced, and no secret material in any credential response across 52 filter permutations.Full suite: 24,351 tests.
type-check,biome, and all 25 audits green withDATABASE_URLunset.For review
The UTC timestamp fix touches
packages/dband every pool including the realtime client. It rests on production already running UTC on both primary and replica — a no-op there, and it makes every other environment match. That premise is worth confirming before merge.Two defects on the live v1 surface were found and deliberately not fixed, since v1 is stable and unflagged:
POST /api/v1/workflows/{id}/rollbacksilently strips a typo'dversionand rolls back to the wrong version, andGET /api/v1/logs?startDate=abcreturns a 500.