Skip to content

Commit ab8a64f

Browse files
authored
fix(v2): close the defects live probing found (#6681)
* fix(v2): close the defects live probing found Staging finally deployed the merged release, so the surface could be exercised for real. Every fix already shipped held up. These are the defects only live traffic surfaced, plus the ones a static sweep had found and left. A cursor named a position in a sequence without naming the sequence. `cursorScopeKey` hashed only the caller's filters, so any two lists filtering on nothing but `workspaceId` produced one fingerprint and accepted each other's tokens: a tables cursor replayed against the knowledge list answered 200 and silently skipped a row. Table rows never reached that check at all, so a cursor from one table paged another. Identity now comes from the route's own contract — method plus resolved path — because a hand-written name is the step an author forgets, and forgetting it is invisible. An unresolved path placeholder throws rather than fingerprinting the template, so a misconfigured route fails on every request instead of an unlucky one. Every token minted before this is refused with an accurate message; they are single-walk and unpersisted. Knowledge search and the document list answered different questions. Search grouped same-tag filters by slot and joined them with OR while the list conjoined every filter, so `gte 9` and `lte 2` on one tag returned nothing from the list and a full billed page from search. Search now conjoins. The OR grouping replaced an explicit `|OR|` mechanism that was deleted outright, was never documented in any contract, and cost the ability to express a range on a single tag; the union it gave is still reachable as separate searches. The search body also accepted an unbounded query that was billed and then silently truncated to the embedding model's window, and ignored the tag-filter cap the list enforces. A body over ten mebibytes was reported as malformed JSON. Next's proxy truncates there, well under this app's fifty-megabyte ceiling, so the parse failed on a body the caller sent whole and the size branch was unreachable. The ceiling is now clamped to what the proxy will pass. Also: a group's output columns accepted a `workflowGroupId` and discarded it; an enrichment group could never gain an output, because a new output coordinate demanded workflow metadata a group with no workflow cannot have; `newOutputColumns` alone reported success and created nothing; a saved view stored layout references to columns that do not exist while refusing the same name in a filter; an MCP server stored `retries: 0` as three and overrode an explicit auth type; a disabled server answered tool discovery with an unclassified fault; rotating a header server's headers left it reading connected; and a run whose workflow was deleted reported the root folder path while also reporting the workflow deleted. Where the honest fix was out of reach, the contract was corrected instead of half-fixing the code: the polled run resource rebuilds `error.code` by matching the persisted message, so it can never report the two codes that need block attribution, and now says so. `OUTPUT_TOO_LARGE` is removed — no path ever emitted it. `triggers` was left alone deliberately. It reads as a closed enum but production holds 43 distinct values, because a webhook run stores its provider id; pinning the enum would refuse legitimate history a log search exists to find. The description now says the vocabulary is open. * fix(v2): clamp explicit body caps to the proxy ceiling too The previous commit clamped the default JSON body cap but left explicit per-route overrides alone, so a route declaring a larger `maxBodyBytes` still fell into the truncation it was meant to report: the four inline workspace-file routes at 70 MB and the deployed-chat route at 220 MB. Next attaches `proxyClientMaxBodySize` to every request and clones the body unconditionally for any non-GET method on a matched path, pushing EOF at ten mebibytes with only a warning, so the handler reads a truncated prefix. Those routes therefore already fail above that size — as a malformed-JSON 400. Clamping the effective limit inside the two body readers makes the same request fail as payload-too-large, quoting the limit actually in force. One existing test asserted the unreachable case, allowing a sixty-mebibyte base64 body; it now asserts what the proxy will forward intact. The inline-file path still advertises fifty mebibytes and cannot exceed the proxy ceiling until that ceiling is raised, which changes buffering for every route and belongs in its own change. * fix(v2): close the two holes the first review round found Both are places where a fix in this branch shut one door and left a smaller one open in the same wall. Letting an enrichment group gain an output meant skipping workflow resolution — but that resolution was the only thing validating a new output, so a PATCH began storing coordinates the runner can never fill. It fills a cell from `result[out.outputId]` and skips an output with no `outputId` at all, while the writer diffs on that same id and the sidebar reads and writes by it; the contract leaves it optional. The regression test added with that fix was itself asserting such a dead coordinate. Create's registry checks are now two shared helpers both paths call, and on update an output is exempt only when an identical binding already existed, so renaming a group whose enrichment has since changed still works while anything added or repointed must name a real output. `mappingUpdates` on an enrichment group now says it is inexpressible rather than resolving an empty workflow id into a missing workflow. The layout-reference check was handed the tolerant column set, so a placeholder minted to keep a dangling filter writable also whitelisted a brand-new layout reference — storing an entry the next read discards, which is the inconsistency the check was added to remove. Layout now resolves against the live columns, which is exactly what pruning keeps, while filters and sorts keep the exemption they need.
1 parent 44524d1 commit ab8a64f

53 files changed

Lines changed: 1826 additions & 278 deletions

Some content is hidden

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

apps/docs/openapi-v2-knowledge.json

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -728,9 +728,9 @@
728728
"name": "tagFilters",
729729
"in": "query",
730730
"required": false,
731-
"description": "A JSON-encoded array of at most 10 tag filters, using the same display-name shape as knowledge search: `[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]`. A name that is not defined in this knowledge base is rejected, never ignored.",
731+
"description": "A JSON-encoded array of at most 10 tag filters, using the same display-name shape as knowledge search: `[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]`. Every filter must hold, including two that name the same tag. A name that is not defined in this knowledge base is rejected, never ignored.",
732732
"schema": {
733-
"description": "A JSON-encoded array of at most 10 tag filters, using the same display-name shape as knowledge search: `[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]`. A name that is not defined in this knowledge base is rejected, never ignored.",
733+
"description": "A JSON-encoded array of at most 10 tag filters, using the same display-name shape as knowledge search: `[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]`. Every filter must hold, including two that name the same tag. A name that is not defined in this knowledge base is rejected, never ignored.",
734734
"examples": [
735735
"[{\"tagName\":\"category\",\"operator\":\"eq\",\"value\":\"billing\"}]"
736736
],
@@ -2820,9 +2820,10 @@
28202820
"examples": [["7c9e6679-7425-40de-944b-e07fc1f90ae7"]]
28212821
},
28222822
"query": {
2823-
"description": "Natural-language query; required when tag filters are omitted.",
2823+
"description": "Natural-language query; required when tag filters are omitted. At most 32768 characters — longer text exceeds the embedding model's per-input token ceiling and would be truncated before the billed search ran.",
28242824
"examples": ["How do I reset my password?"],
2825-
"type": "string"
2825+
"type": "string",
2826+
"maxLength": 32768
28262827
},
28272828
"topK": {
28282829
"default": 10,
@@ -2832,7 +2833,8 @@
28322833
"maximum": 100
28332834
},
28342835
"tagFilters": {
2835-
"description": "Structured tag filters. Each filtered tag must resolve to the same slot and field type in every knowledge base selected; one missing from any of them, or defined inconsistently across them, is rejected rather than ignored, and those knowledge bases must be searched separately. List the available names with `GET /api/v2/knowledge/{id}/tags`.",
2836+
"description": "Structured tag filters, at most 10 of them. Every filter must hold, including two that name the same tag: repeating one tag narrows the result rather than widening it, matching `GET /api/v2/knowledge/{id}/documents`. To match either of two values for one tag, issue a search per value. Each filtered tag must resolve to the same slot and field type in every knowledge base selected; one missing from any of them, or defined inconsistently across them, is rejected rather than ignored, and those knowledge bases must be searched separately. List the available names with `GET /api/v2/knowledge/{id}/tags`.",
2837+
"maxItems": 10,
28362838
"type": "array",
28372839
"items": {
28382840
"$ref": "#/components/schemas/V2KnowledgeSearchTagFilter"

apps/docs/openapi-v2-logs.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,10 @@
6464
"name": "triggers",
6565
"in": "query",
6666
"required": false,
67-
"description": "Comma-separated trigger types to include. An empty entry is rejected. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`.",
67+
"description": "Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`.",
6868
"schema": {
6969
"type": "string",
70-
"description": "Comma-separated trigger types to include. An empty entry is rejected. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`."
70+
"description": "Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`."
7171
}
7272
},
7373
{

apps/docs/openapi-v2-resources.json

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2492,7 +2492,7 @@
24922492
"description": "Whether the server tools are available to workflows."
24932493
},
24942494
"connectionStatus": {
2495-
"description": "Result of the most recent connection attempt. Registration and re-registration store a configuration without contacting the endpoint, so a server beginsand returns to `disconnected` until a tool discovery runs.",
2495+
"description": "Result of the most recent connection attempt. Registration and re-registration establish no connection — the auth-type probe they may send does not count as one — so a server begins, and returns to, `disconnected` until a tool discovery runs.",
24962496
"type": "string",
24972497
"enum": ["connected", "disconnected", "error"]
24982498
},
@@ -2694,8 +2694,7 @@
26942694
"description": "Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references. It determines server identity and is immutable: delete and recreate the server to change endpoints."
26952695
},
26962696
"authType": {
2697-
"description": "Authentication method. Applied server-side as `headers` when omitted; registration never contacts the server, so an omitted value is never detected from it.",
2698-
"default": "headers",
2697+
"description": "Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method.",
26992698
"type": "string",
27002699
"enum": ["none", "headers", "oauth"]
27012700
},
@@ -2881,8 +2880,7 @@
28812880
"maxLength": 2048
28822881
},
28832882
"authType": {
2884-
"description": "Authentication method. Applied server-side as `headers` when omitted; registration never contacts the server, so an omitted value is never detected from it.",
2885-
"default": "headers",
2883+
"description": "Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method.",
28862884
"type": "string",
28872885
"enum": ["none", "headers", "oauth"]
28882886
},

apps/docs/openapi-v2-tables.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6611,7 +6611,8 @@
66116611
"type": "boolean"
66126612
}
66136613
},
6614-
"required": ["name", "type"]
6614+
"required": ["name", "type"],
6615+
"additionalProperties": false
66156616
},
66166617
"description": "Columns created for producer outputs."
66176618
},
@@ -6750,7 +6751,8 @@
67506751
"type": "boolean"
67516752
}
67526753
},
6753-
"required": ["name", "type"]
6754+
"required": ["name", "type"],
6755+
"additionalProperties": false
67546756
}
67556757
},
67566758
"mappingUpdates": {

apps/docs/openapi-v2-workflows.json

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1423,19 +1423,19 @@
14231423
"name": "includeOutput",
14241424
"in": "query",
14251425
"required": false,
1426-
"description": "Include final and block outputs when true.",
1426+
"description": "Include the final workflow output when true. It does not gate `blockOutputs`, which `selectedOutputs` selects on its own.",
14271427
"schema": {
1428-
"description": "Include final and block outputs when true.",
1428+
"description": "Include the final workflow output when true. It does not gate `blockOutputs`, which `selectedOutputs` selects on its own.",
14291429
"type": "boolean"
14301430
}
14311431
},
14321432
{
14331433
"name": "selectedOutputs",
14341434
"in": "query",
14351435
"required": false,
1436-
"description": "Comma-separated block output references to include.",
1436+
"description": "Comma-separated block output references to include, as `blockId` or `blockId.path`. Block *names* are not resolved here — unlike the execute request, this resource reads a recorded run and matches ids only, so a name selects nothing and yields an empty `blockOutputs`.",
14371437
"schema": {
1438-
"description": "Comma-separated block output references to include.",
1438+
"description": "Comma-separated block output references to include, as `blockId` or `blockId.path`. Block *names* are not resolved here — unlike the execute request, this resource reads a recorded run and matches ids only, so a name selects nothing and yields an empty `blockOutputs`.",
14391439
"type": "string"
14401440
}
14411441
}
@@ -3882,21 +3882,20 @@
38823882
"INVALID_INPUT",
38833883
"BLOCK_EXECUTION_FAILED",
38843884
"CHILD_WORKFLOW_FAILED",
3885-
"OUTPUT_TOO_LARGE",
38863885
"EXECUTION_FAILED"
38873886
],
3888-
"description": "Stable machine-readable execution failure code."
3887+
"description": "Stable machine-readable execution failure code. `BLOCK_EXECUTION_FAILED` and `CHILD_WORKFLOW_FAILED` are reported only where block attribution is available; elsewhere a block-level failure is reported as `EXECUTION_FAILED`."
38893888
},
38903889
"blockId": {
3891-
"description": "Identifier of the failing block, when attributable.",
3890+
"description": "Identifier of the failing block. Present on the synchronous execute response only; the polled run resource and the resume response cannot attribute a block.",
38923891
"type": "string"
38933892
},
38943893
"blockName": {
3895-
"description": "Display name of the failing block.",
3894+
"description": "Display name of the failing block. Present on the synchronous execute response only.",
38963895
"type": "string"
38973896
},
38983897
"blockType": {
3899-
"description": "Integration or block type that failed.",
3898+
"description": "Integration or block type that failed. Present on the synchronous execute response only.",
39003899
"type": "string"
39013900
}
39023901
},
@@ -4292,7 +4291,7 @@
42924291
"type": "null"
42934292
}
42944293
],
4295-
"description": "Trigger type, or null before the run is recorded."
4294+
"description": "Trigger type that started the run. Backfilled as `api` for a run that is still queued, so it is populated from the first poll."
42964295
},
42974296
"startedAt": {
42984297
"anyOf": [
@@ -4303,7 +4302,7 @@
43034302
"type": "null"
43044303
}
43054304
],
4306-
"description": "ISO 8601 start timestamp, or null while queued.",
4305+
"description": "ISO 8601 start timestamp. A queued run reports the time it was enqueued, so it is populated from the first poll.",
43074306
"format": "date-time"
43084307
},
43094308
"endedAt": {
@@ -4453,7 +4452,7 @@
44534452
"type": "null"
44544453
}
44554454
],
4456-
"description": "Structured execution failure, or null when none occurred."
4455+
"description": "Structured execution failure, or null when none occurred. Reclassified from the persisted error message, so `blockId`/`blockName`/`blockType` are absent and a block-level failure reports `EXECUTION_FAILED` here even when the same run reported `BLOCK_EXECUTION_FAILED` on its synchronous execute response."
44574456
},
44584457
"output": {
44594458
"anyOf": [
@@ -4481,7 +4480,7 @@
44814480
"type": "null"
44824481
}
44834482
],
4484-
"description": "Selected block outputs when requested, otherwise null."
4483+
"description": "Outputs of the blocks named by `selectedOutputs`, or null when none were requested. Gated by `selectedOutputs` alone — `includeOutput` governs `output` only."
44854484
}
44864485
},
44874486
"required": [

apps/sim/app/api/v2/audit-logs/route.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs'
2-
import { cursorScopeKey, instantScopePart, unorderedScopePart } from '@/lib/api/cursor-binding'
2+
import {
3+
cursorRoute,
4+
cursorScopeKey,
5+
instantScopePart,
6+
unorderedScopePart,
7+
} from '@/lib/api/cursor-binding'
38
import {
49
defineV2JsonRoute,
510
v2ApiKeyAuth,
@@ -23,7 +28,7 @@ function auditLogCursorFilters(query: {
2328
startDate?: string
2429
endDate?: string
2530
}) {
26-
return cursorScopeKey({
31+
return cursorScopeKey(cursorRoute(v2ListAuditLogsContract), {
2732
organizationId: query.organizationId,
2833
includeDeparted: query.includeDeparted,
2934
action: query.action,

apps/sim/app/api/v2/billing/logs/route.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ vi.mock('@/lib/billing/application/list-billing-logs', () => ({
2424
listBillingLogs: { operation: { id: 'billing.logs.list' }, execute: mocks.execute },
2525
}))
2626

27-
import { cursorScopeKey } from '@/lib/api/cursor-binding'
27+
import { v2ListBillingLogsContract } from '@/lib/api/contracts/v2/billing'
28+
import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding'
2829
import { UNKNOWN_CURSOR_MESSAGE } from '@/lib/billing/core/usage-log'
2930
import { OrchestrationError } from '@/lib/core/orchestration/types'
3031
import { GET } from '@/app/api/v2/billing/logs/route'
@@ -35,7 +36,7 @@ function ledgerCursor(
3536
inner: string,
3637
filters: { source?: string; workspaceId?: string; period?: string }
3738
): string {
38-
return encodeScopedCursor(cursorScopeKey(filters), inner)
39+
return encodeScopedCursor(cursorScopeKey(cursorRoute(v2ListBillingLogsContract), filters), inner)
3940
}
4041

4142
const auth = {

apps/sim/app/api/v2/billing/logs/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { v2ListBillingLogsContract } from '@/lib/api/contracts/v2/billing'
2-
import { cursorScopeKey, instantScopePart } from '@/lib/api/cursor-binding'
2+
import { cursorRoute, cursorScopeKey, instantScopePart } from '@/lib/api/cursor-binding'
33
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
44
import { v2BillingErrorPolicies } from '@/lib/billing/api/route-policies'
55
import { listBillingLogs } from '@/lib/billing/application/list-billing-logs'
@@ -32,7 +32,7 @@ function billingLogCursorFilters(query: {
3232
startDate?: string
3333
endDate?: string
3434
}) {
35-
return cursorScopeKey({
35+
return cursorScopeKey(cursorRoute(v2ListBillingLogsContract), {
3636
source: query.source,
3737
workspaceId: query.workspaceId,
3838
period: query.period,

apps/sim/app/api/v2/credentials/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { V2Credential } from '@/lib/api/contracts/v2/credentials'
22
import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials'
3-
import { cursorScopeKey } from '@/lib/api/cursor-binding'
3+
import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding'
44
import {
55
defineV2JsonRoute,
66
v2ApiKeyAuth,
@@ -42,7 +42,7 @@ function credentialCursorFilters(query: {
4242
providerId?: string
4343
search?: string
4444
}) {
45-
return cursorScopeKey({
45+
return cursorScopeKey(cursorRoute(v2ListCredentialsContract), {
4646
workspaceId: query.workspaceId,
4747
type: query.type,
4848
providerId: query.providerId,

apps/sim/app/api/v2/custom-tools/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {
22
v2CreateCustomToolContract,
33
v2ListCustomToolsContract,
44
} from '@/lib/api/contracts/v2/custom-tools'
5-
import { cursorScopeKey } from '@/lib/api/cursor-binding'
5+
import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding'
66
import {
77
defineV2JsonRoute,
88
v2ApiKeyAuth,
@@ -22,7 +22,7 @@ export const revalidate = 0
2222

2323
/** Every param that changes which custom tools, in which order, this list returns. */
2424
function customToolCursorFilters(query: { workspaceId: string; search?: string }) {
25-
return cursorScopeKey({
25+
return cursorScopeKey(cursorRoute(v2ListCustomToolsContract), {
2626
workspaceId: query.workspaceId,
2727
search: query.search,
2828
})

0 commit comments

Comments
 (0)