Commit Graph
775 Commits
Author SHA1 Message Date
Waleed 0b4d34137b feat(secrets): add optional descriptions to workspace secrets (#6796)
* feat(secrets): add optional descriptions to workspace secrets

Workspace secrets already have a backing credential row with a description
column, but nothing surfaced it. Teammates had no way to record what a
secret is for.

- Add a Description field to the secret detail page, matching the
  integrations credential page, gated on workspace-secret admin
- Fold the value and description editors into one Save/Discard pair and
  one unsaved-changes guard; two guards cannot coexist, since each seeds
  its own same-URL history entry
- Match descriptions in the secrets settings search
- Expose description on GET/PUT /api/v2/secrets and in the CLI

Descriptions are workspace-only: env_personal credential rows are
per-workspace mirrors of one user-global secret, so one saved there would
exist in a single workspace, and a personal secret has no teammates to
inform. The API rejects a description on personal scope rather than
silently dropping it, and omitting it on PUT leaves any existing
description untouched so a value rotation cannot erase it.

* fix(secrets): address review findings on secret descriptions

- Patch the credential detail cache optimistically on update. `onMutate`
  cancelled the detail query but only patched the lists, so a detail-backed
  editor stayed dirty after a successful save until the refetch landed —
  long enough for Discard to restore the pre-save value over the committed
  one, and for Back to open the unsaved-changes guard.
- Memoize `useSecretValue`'s returned callbacks and object, per the hook
  convention, so the composed form's save/discard stop churning per render.
- Reject a description on a personal secret in the domain layer rather than
  only at the v2 boundary. The internal credential update path accepted one
  for any type, writing data every reader hides.
- Normalize an empty description to null so the API and UI agree.
- Correct the secrets documentation, which described a Display Name field
  the detail view does not have and omitted the scope rule.
- Drop the CLI's copy of the 500-character bound; it can't import the
  contract, so a copy only drifts from the message the API already returns.
- Collapse a redundant save guard and align the description write gate with
  the render gate.

Leaves the integrations credential page byte-identical to staging.

* fix(secrets): keep the API docs example and CLI column order stable

Backward-compatibility fixes for anyone who never sets a description.

- Move the blank-to-null normalization out of the contract and into the
  route. A Zod `.transform()` on any property drops the whole request
  schema's OpenAPI examples, which had silently removed the Set Secret
  request example from the published docs.
- Append the CLI `description` column instead of inserting it before
  `updated`. `--output text` is positional, so inserting would shift every
  field an existing script cuts.
- Reject a description on a personal secret with a message that says so,
  rather than dropping the field and falling through to the generic
  "no updatable fields" error.
2026-08-17 17:33:35 -07:00
Waleed 38075ad977 fix(sap_concur): align the integration with SAP Concur's documented API (#6790)
* fix(sap_concur): align the integration with SAP Concur's documented API

Validated all 70 tools, the block, and both proxy routes against SAP's
published API docs.

Auth:
- add the password and companyUuid to the token cache key so a request
  with the wrong password can no longer be served a cached token minted
  from someone else's
- wire the documented company-level flow (username = company UUID,
  credtype = authtoken) so companyUuid actually scopes a token
- expand the datacenter allowlist to the documented set (adds glz, apj1,
  usg, the impl hosts, and the www- twins) and drop the undocumented cn
  host; validate the returned geolocation by shape instead of membership
- coalesce concurrent token fetches so a fan-out mints one token
- forward Retry-After so 429 retries pace off Concur's own hint
- handle the errorMessageList, SCIM detail, and legacy Error.Message
  shapes instead of falling through to a generic HTTP message
- pin redirects and cap the response body

Block:
- collapse six contextType subBlocks that disagreed on their default, so
  a new block no longer seeds MANAGER for every operation
- clamp contextType to each operation's documented set
- stop requiring a userId and contextType that the default operation's
  tool does not accept, and scope the receipt fields to the upload ops
- reach six params that had no subBlock, and pass userId on travel
  request updates so a stale value cannot impersonate

Tools:
- correct response shapes that resolved to undefined: budget headers,
  budget categories, allocations, receipts, SCIM nextCursor, and the
  delete endpoints that return a bare boolean
- use the Travel Request Amount schema (currency, not currencyCode)
- narrow the four XML-only travel tools to a documented string payload
  and request application/xml
- surface real errors instead of a JSON parse failure when the proxy
  returns a non-JSON body
- cap receipt uploads at the documented sizes before downloading

Adds 106 tests covering the token cache, geolocation validation, path
traversal, and error extraction.

* fix(sap_concur): drop the removed forwardId subblock via a migration

Removing the `forwardId` subblock without a migration entry breaks
deployed workflows that still carry a value under that key.

It fed a `concur-forwardid` request header that is documented nowhere in
Concur's Receipts v4 or Image v1 references, so it was never honored.
There is no replacement subblock and the value is an opaque
caller-chosen string rather than a secret, so it is dropped outright.

* fix(sap_concur): stop swallowing upload response-read failures

The upload route caught every error from the bounded response read and
continued down the success path, so a size-limit breach or a stream
failure surfaced as an upstream success with a null or header-only body.

Concur returns Content-Length: 0 on a successful image-only upload, and
readResponseTextWithLimit already returns an empty string for that
without throwing, so dropping the catch keeps the legitimate empty-body
case working while letting real read failures reach the route's handler.

* fix(sap_concur): unblock company auth and correct the body wand prompt

The password grant marked username required, so the company-level flow —
which sends the company UUID as the token username and has no user login
— could not be configured at all, even though the request schema and
token fetch already accept companyUuid without a username. Username is
now optional for that grant and the server-side check reports which of
the two is missing. Relabels the password and companyUuid fields to say
what they carry in the company flow.

The shared body wand prompt also still described several payloads the
way they looked before this branch: quick expenses in PascalCase rather
than v4 camelCase, travel requests and expected expenses using
currencyCode where the Request v4 Amount schema uses currency, the
standard SCIM SearchRequest URN instead of Concur's, startIndex as a
search parameter when it is unsupported, and a cash advance shape that
does not match the documented request. A wand-generated body was
therefore rejected for most of the create operations it covers.

* fix(sap_concur): keep Concur's status when an error body fails to read

Removing the blanket catch from the upload read fixed one failure mode
and introduced its inverse: a cap breach or stream error while reading a
non-success body threw before the route reached the branch that
preserves Concur's status, so an upstream 4xx surfaced as a Sim 500 and
could trigger a retry the caller should not make.

Both routes now split the two cases. On a success status the body is the
result, so a read failure still propagates. On an error status the body
only supplies the message, so a read failure resolves empty and the
upstream status survives, with the message falling back to the generic
HTTP-status form.

Adds 21 tests covering both helpers over success, error, empty-body and
boundary statuses; inverting the status check turns 14 of them red.
2026-08-17 16:27:23 -07:00
Waleed 60097c89b4 fix(cli): default to the host that serves the API (#6791)
`sim.ai` answers /api/** with a 301 to `www.sim.ai`, and the client refuses
to follow redirects — a 301 rewrites a POST into a bodyless GET, so
following one turns a write into a silent no-op and hands the API key to
whatever host Location names. Defaulting to the apex therefore failed every
command for anyone who never set an endpoint. Before the refusal shipped it
was quieter and worse: reads succeeded while writes did nothing.

Also trims the provider catalogue from eleven inferred columns to seven.
`docsUrl`, `helpText`, `requiresClientGeneratedCredentialId` and the nested
`fields` are what you read once you have chosen a provider, not what you
scan to choose one, and they pushed the table well past a terminal. Both
ids stay: `credentials connect` names an OAuth provider by `serviceId`,
`credentials create` matches a service account on `providerId`.
2026-08-17 16:25:58 -07:00
Waleed ae2147645c fix(cli): resolve findings from a full command-surface audit (#6788)
* fix(cli): resolve findings from a full command-surface audit

Exercised all 147 commands against a live deployment. Fixes the defects
that surfaced, plus the docs and generator drift they exposed.

Transport
- Stop following redirects. A bare domain that 301s to www silently
  converted POST to GET and dropped the body, so reads worked while every
  write failed with a misleading validation error and login returned 405.
  Both the client and the device flow now explain the redirect and name
  the endpoint to configure, rather than carrying credentials off-origin.
- Report a non-JSON response as one instead of printing the HTML page.
- Name the personal-API-key remedy on a workspace-key refusal, reading the
  machine-readable code the API actually sends.
- Drop union-branch noise from validation errors that contradicted itself.
- Show paging progress on stderr for multi-page fetches.

Output
- Clamp record values for table only. text is the format built for pipes,
  and it was truncating signed URLs and tool source mid-value.
- Infer timestamp, duration, bytes and boolean formatting for API-owned
  keys so undeclared commands stop printing raw ISO and float ms. Skips
  user-defined table cells and leaves json/yaml on the raw payload.
- Render a declared-but-absent field as an em dash; billing credits were
  vanishing silently.

Paths, naming and validation
- Percent-encode folder paths per segment and decode them for display, so
  a folder reads and types as the name shown in the app.
- Reject a malformed endpoint where it is set and where it resolves,
  instead of crashing with a URL parse trace.
- Request the detail level logs list's own columns need; its workflow
  column could never populate.
- Rename three commands that described themselves wrongly and align two
  flags with their siblings. Old spellings still work: hidden, warned on
  stderr, and kept out of help and docs.
- Verify whoami against the API, separating a bad key from an unreachable
  endpoint, and report the workspace by name.
- Correct the --yes help text, which advertised skipping a prompt that
  does not exist.

Docs
- Teach the docs generator that a flag required by the runtime is required,
  and that hidden commands are not documented.

* fix(cli): clear the paging progress line when a page fails

Progress is written without a trailing newline so it can be overwritten in
place, and both paging loops cleaned it up only on success. A page that
threw part-way through left `fetched 1200…` on the line the error was then
printed onto, so the two ran together.

* fix(cli): name a working API root when an endpoint redirects

The suggested endpoint was the redirect target's origin, which drops a path
prefix. A self-hosted deployment reached at https://host/sim was told to set
https://www.host — not an API root, so following the advice replaced one
broken endpoint with another.

Derive it by stripping the request's own path from the target instead, so a
prefix survives, and say nothing about --set-endpoint when the target
resolves to the endpoint already configured: a trailing-slash or path
normalization redirect keeps the origin, and naming the value the caller
already has explains nothing. The login poll shared both faults and now
shares the helper.
2026-08-17 15:55:04 -07:00
Waleed 75718ab39f fix(execution): stop a cancelled run reporting success when its wait swallows the cancellation (#6775)
* fix(execution): stop a cancelled run reporting success when its wait swallows the cancellation

Cancellation reaches a running execution over Redis pub/sub, which is
at-most-once. The engine turns that into `status: 'cancelled'` via
`signalCancelled`. But the wait handler also polled the durable Redis
cancellation key itself, and on a hit it broke out of its sleep and returned an
ordinary successful block output. The engine's `cancelledFlag` stayed false, so
a cancelled run finished as `success: true` — and with a block after the wait,
kept executing.

Whichever detector fired first won. The engine's pub/sub path normally wins by
about one round trip; when the wait's own 500ms poll landed inside that window
the cancellation was lost.

Consolidate detection in the engine, which is the only component that can
project run status: extend the once-at-start durable backstop into a poll that
runs for the life of the run and routes through `signalCancelled`. The wait
handler and loop orchestrator now observe only `ctx.abortSignal`, which the
engine aborts, so no leaf can observe a cancellation the engine has not seen.

The loop orchestrator additionally used to ignore `abortSignal.aborted`
whenever Redis was enabled, so a mid-loop timeout or client disconnect was
invisible to it, and it awaited a Redis round trip on every iteration.

Handlers that abort their own I/O off `ctx.abortSignal` are unaffected: that
surfaces as a throw, which the cancelled branch of `run` already classifies.

* docs(wait): correct the in-line wait ceiling to 5 minutes

The Wait page claimed a 10-minute cap for a synchronous wait in three places.
`MAX_INPROCESS_WAIT_MS`, the block description, the sub-block hint, and the
validation error all say 5 minutes.
2026-08-17 01:55:48 -07:00
Waleed b38e4e2f91 docs(integrations): add missing manual intros; fix light brand tiles rendering white glyphs (#6774)
* docs(integrations): add manual intro sections to eight integration pages

* fix(styling): scan blocks and ee in the tailwind content globs

* docs(snowflake): correct the unload-data capability to a table source
2026-08-17 00:49:54 -07:00
Waleed 31521d6abf fix(connectors): validate and repair the knowledge-base connector fleet (#6757)
* fix(connectors): validate and repair all 61 knowledge-base connectors

Audits every KB connector against its provider's live API documentation and
fixes what the audit found. The dominant defect class is deletion
reconciliation: the sync engine hard-deletes any stored document absent from a
"full" listing, and most connectors had a path where a truncated or errored
listing failed to set `syncContext.listingCapped`.

Highest-impact fixes:

- linear: `getDocument` was dead code. The query declared `$id: ID!` where the
  schema is `issue(id: String!)`, so every call failed variable validation.
- salesforce: `v62.0` was substituted into a `{version}` template that already
  contains the `v`, so every REST call 404'd. SOQL `LIMIT` was also used as a
  page size, silently capping every sync at 200 records.
- notion: only the first level of blocks was fetched, so tables indexed empty.
- microsoft-teams: `/messages` returns messages without replies, so no threaded
  content was ever indexed.
- gmail: an empty page discarded `nextPageToken`, which reads as a complete
  empty listing and hard-deletes every stored thread.
- confluence: the CQL path paginated with `start` and `totalSize`, neither of
  which exists on that endpoint, so label-filtered syncs stopped after one page.
- box: `supportsRefreshTokenRotation` was unset, so Box's rotated refresh token
  was discarded and every credential died on its second refresh.

Removes the Evernote integration entirely: the classic EDAM API is deprecated,
its sandbox is decommissioned, and developer tokens are no longer obtainable.

Makes SFTP host-key verification mandatory, and adds an attendee-PII opt-out to
google-calendar and google-meet (default on, so existing sources are unchanged).

Bumps the contentHash namespace for notion, google-docs and hubspot so existing
documents re-hydrate once and actually receive the content fixes above.

* fix(connectors): close swallow-into-empty and cursor-taper regressions

Ship-gate pass over the connector audit. Every finding was re-verified
against the provider's live documentation or machine-readable spec before
being acted on; several pass-3 edits were reverted rather than extended.

Correctness fixes:
- fireflies: a 2xx with an unparseable body returned an empty listing
  instead of throwing. fireflies runs a full sync every time, so a fault
  persisting across two syncs would have tombstoned all indexed docs.
- linear: same shape via `data.issues || {}` on a non-nullable connection.
- greenhouse: a 403 from a key without scorecard permission was treated as
  transient, appending `:partial` to the hash. That never matches the list
  stub, forcing full re-hydration of every candidate on every sync forever.
- google-meet: `fetchParticipants` carried a 404 swallow copied from its
  transcript siblings, freezing every speaker as "Unknown".
- airtable, asana, ashby: reverted page-size tapers applied over opaque
  cursor tokens. The cap was already enforced server-side.
- google-docs: response byte cap resolved to 800MB and could never fire.
- google-forms, google-vault, notion, sharepoint, dropbox: `getDocument`
  now throws on transient failure instead of returning null, which the
  engine reads as absence.

Security:
- Retry headers are attached non-enumerably. TypeScript `private` is
  compile-time only, so `SecureFetchHeaders.setCookies` was an own
  enumerable property that the logger serialized into sync logs.

Docs and dead code:
- Corrected six fabricated doc citations (github, jira, jsm, linear,
  google-meet, dropbox) and removed the Evernote integration entirely.

* refactor(jira): type the ADF node helpers with unknown instead of any

* fix(connectors): throw on misconfigured typeform/zendesk sources instead of returning null

A null from getDocument reads as documented absence, so on an add the
document is dropped with neither a failure counter nor a log. Both
listDocuments paths already throw on the same missing config.

* fix(connectors): keep confluence CQL page size constant; unify monday API version

The CQL search endpoint paginates by opaque cursor, and Atlassian does not
document that a cursor issued against one limit survives a request asking
for a different one. Narrowing limit to the remaining budget was the same
pattern reverted on airtable, asana, and ashby. The page size is now
constant and the cap is applied by trimming the returned page, which keeps
the cap exact without varying the request.

Monday OAuth getUserInfo hardcoded API-Version 2024-10 while every other
monday surface reads MONDAY_API_VERSION, defeating the single-source pin.

* fix(connectors): act on the final validation sweep

Findings from a read-only /validate-connector pass over all 59 changed
connectors, verified against provider specs before acting.

Silent-drop fixes (a fulfilled null from getDocument records no failure and
no log, so the document vanishes):
- ashby: candidate.info returning success with an unusable payload. Ashby
  sets contentDeferred, so this path is live.
- azure-devops: an unresolvable branch, likewise live.
- dropbox: 409 covers the whole LookupError union, and restricted_content
  and locked both mean the file still exists. Only not_found is absence.
- docusign: fetchFormValues swallowed every non-OK status, baking a
  permanently incomplete document since the hash is metadata-only.

typeform: 'all' sent response_type=started,partial,completed, but Typeform
documents only partial and completed. An unknown enum member risks a 400
that fails the whole sync, and staging omitted the parameter entirely, so
this shipped as a regression. Now requests the widest documented set.

github: removes a utf-8 blob branch justified by a misattributed quote —
that sentence describes the encoding REQUEST parameter of Create a blob;
the GET response is documented as always base64. Also corrects two comments
that hid a real drop: >1 MB files under vnd.github+json 403 rather than
returning encoding: none.

hubspot: routes HTML detection through a shared anchored helper. The loose
pattern matched angle-bracketed prose such as an email address, and
htmlToPlainText deletes the span and collapses line structure. This matters
now because the hubspot:v2: bump rewrites every live document once.

youtube: drops an invented channel-ID format quote.

* fix(connectors): converge incidentio partial hash on settled statuses

A 403 from a key without incident_updates permission, or a 404, returns on
every sync. Marking those incomplete appended :partial to a hash that then
never matched the listing stub, so the incident re-hydrated forever without
converging. Only a transient failure may mark content incomplete now, which
matches how greenhouse already treats the same class.

* fix(connectors): flag WIQL truncation unconditionally; stop swallowing docusign form-data failures

azure-devops: the 20,000-item WIQL ceiling was probed by asking for a matching
item with an id beyond the largest returned. That probe is unsound — buildWiql
orders by ChangedDate DESC while ids are assigned in creation order, so the
highest-id match is almost always inside the returned window. The probe came
back empty for genuinely truncated projects, left the listing unflagged, and
let deletion reconciliation remove every indexed item outside it. Flag
unconditionally instead; the cost is a project sitting exactly at the ceiling
not reconciling deletions until a full resync.

docusign: fetchFormValues threw on a non-404 status and then caught its own
throw, returning []. The earlier fix was a no-op. The catch now rethrows, so a
transient failure produces a failed row instead of a permanently incomplete
document under a metadata-only hash.

* fix(connectors): restore airtable AI Text indexing; floor sentry maxIssues

airtable: staging rendered object cells with a JSON.stringify fallback, so AI
Text values and nested lookup arrays reached the index. This branch replaced
that with a fixed key-probe list to stop attachment-URL hash churn, but the
probe list has no fallback — aiText ({state,isStale,value}) and nested arrays
rendered to the empty string and vanished from every document, and the
content-derived hash never moved when the text regenerated. Read `value` last,
after the existing probes, and recurse on nested arrays. The generated text is
stable rather than an expiring signed URL, so this does not reintroduce churn.

sentry: maxIssues now feeds the request limit, and Sentry rejects a non-integer.
validateConfig accepts a fractional entry, so a config that saved cleanly would
fail every sync at listing time. Staging was immune only because it sent a
hardcoded page size.
2026-08-17 00:21:04 -07:00
Waleed cc1a278d73 fix(integrations): close defects found by an independent cold audit (#6767)
* fix(integrations): repair Update SLO and advanced OData filters

An independent audit — eight cold readers, one per integration, given no
prior findings — checked the eight integrations merged to staging today.
Two defects broke an operation outright; both are fixed here.

datadog: Update SLO rewrote every non-metric SLO to `metric`. The SLO Type
dropdown carried a `metric` default and its condition covered both create and
update, so an untouched control reached mergeSloUpdatePayload as an edit. A
metric SLO requires `query`, and the merged body carries monitor_ids or
sli_specification instead, so Datadog rejected it — Update SLO was unusable on
monitor-based and time-slice SLOs, with no way to express "keep the current
type". Update now has its own control defaulting to "Keep current".

microsoft_ad: list_users, list_groups and list_service_principals emitted
$count=true only alongside $search, so any $filter using an advanced operator
(ne, not, endsWith, startsWith on non-indexed properties) returned 400. Graph
requires $count=true with ConsistencyLevel: eventual for those. list_devices
already did this correctly; the other three now match it.

Also from the same audit: Datadog path IDs are trimmed before encoding in all
20 URL builders rather than 2, matching the existing get_monitor test.

* fix(integrations): cloudflare, crowdstrike, and mssql audit findings

From the independent cold audit. Cloudflare: DNS analytics no longer emits
fabricated min/max telemetry (Cloudflare documents both as always empty);
purge_everything defaults to specific-purge and errors when combined with
target lists; three unsourced description claims corrected; Array.isArray
guards on four older list transforms.

CrowdStrike: IOC sort placeholder corrected to the dot form (created_on.desc,
not the nonexistent created_timestamp); the 500-indicator cap relabelled as a
Sim bound rather than a CrowdStrike one; credential failures return 401 rather
than 500; prevent_no_ui noted as unenumerated.

MSSQL: introspect no longer lets the model choose the database; row and byte
caps on reads; introspection collapsed from 4N+2 to 6 fixed queries; WHERE and
identifier guards run before the connection opens so rejections are 400 not
500; SAVE TRANSACTION, OPEN/CLOSE key, DEALLOCATE and ADD SIGNATURE added to
the statement screen as two-token phrases; encrypt wording corrected to say
TDS 7.4 encryption is negotiated, not guaranteed.

* fix(splunk): publish the real output tables and read the errors Splunk sends

The docs generator parses tool source text and resolves a shared `outputs`
const only from the family's `types.ts`, so Splunk's helpers in `utils.ts` were
invisible to it: seven operations published the block's union of every output
instead of their own. Run Search and Get Search Results each shipped a ~50-row
table naming savedSearches, alerts, indexes, and apps they never return, Cancel
Search Job lost `messages`, and the four list tools lost `total`/`offset`.
Inline the four helpers into each consuming tool and delete them, since
relocating a shared const only moves the trap.

Also:

- Add a `splunk-errors` extractor for the documented `{messages: [{type, text}]}`
  envelope and set it on all twelve tools. A rejected SPL string, the most common
  failure, previously fell through to the status text and reported "Bad Request".
- Read `searchEarliestTime`/`searchLatestTime` with `asNumber`. The job entry
  documents them as bare epoch numbers, so `asString` returned null for every
  `output_mode=json` response.
- Project the `<messages>` block of the XML job-control response. It is the only
  payload that endpoint returns, so `cancel_search_job.messages` was always empty.
- Mark the nullable job outputs optional, matching the transform.
- Default `run_search` to `max_count=1000`. A oneshot search has no paging escape
  hatch and Splunk's own default is 10000 rows in one buffered response.
- Add suggested skills to `SplunkBlockMeta`, grounded in `tools.access`.

The regenerated tool metadata also picks up the Cloudflare and MSSQL output
changes from the previous commit, which were never synced.

* fix(okta,servicenow): apply integration audit findings

Cherry-picked from fix/okta-servicenow-audit-followups (6db0f54), whose base
predated the earlier audit round; the duplicate isOktaFlagEnabled that produced
is resolved in favour of the existing richer helper, which already accepts
'yes'/1/'on' as well as true/'true'.

okta: get_logs no longer advertises hasMore forever. A System Log query with no
'until' is a polling query, and Okta always returns a next link for one, even on
an empty page — so any loop driven by hasMore never terminated, including the
one our own shipped skill instructs the agent to run. errorCauses is now
surfaced, so a failed write reports the real reason instead of the useless
'Api validation failed: profile'. sendEmail routes through one coercion helper
across all four lifecycle tools. update_group's declarative fallback throws
rather than silently truncating an extensible group profile.

servicenow: attachmentLimit and limit no longer overwrite each other. Neither
assignment was scoped to an operation, so all 12 paginated operations could
silently return a row count the user never asked for — defeating the block's own
design, which gave attachmentLimit a unique id precisely to avoid this. All
seven approval states are published by ServiceNow and are now reachable from the
filter, with the space-vs-underscore punctuation documented. The five legacy
generic tools route through the shared response helpers, and the folder's only
'any' is gone. Block skills now name the semantic operations.

* chore(integrations): regenerate catalog and docs artifacts

* fix(integrations): disclose MSSQL truncation and keep Okta's poll cursor

Three defects the review round found in the audit fixes themselves.

MSSQL capped a recordset and then reported it as complete: `executeQuery`
computed `truncated`/`truncationReason` but all five statement routes returned
only `message`, `rows`, and `rowCount`, so a caller could not tell paging was
required. A shared `toRowsResponseBody` now folds the reason into `message`
for an agent reading the status line and exposes the two fields for a caller
that branches on them.

The byte ceiling also admitted a single oversized row as a lone exception, so
one `nvarchar(max)` value serialized an unbounded body — the ceiling bounded
everything except the case it exists for. A row is now admitted only when it
still fits, and the drop is disclosed rather than read as an empty table.

Okta's `get_logs` nulled `nextCursor` alongside `hasMore` on an empty polling
page. Terminating the loop is right, but the cursor is the resume handle Okta
tells callers to persist, so a scheduled workflow that hit one quiet interval
restarted from `since` and re-delivered events it had already processed. The
two answer different questions and now diverge.

Cloudflare's purge block no longer lets the invalid combination be built: the
four target fields are hidden once Purge Everything is selected, so the tool's
guard is a backstop rather than a reachable hard error.

* fix(okta,servicenow): stop sending requests the APIs reject

Okta documents `since` and `after` on the System Log as mutually
exclusive, so `get_logs` lets the cursor win rather than sending both —
the shape a scheduled poll that persists the cursor would otherwise send.

Seven boolean query params reached Okta interpolated raw, so an agent
tool call supplying `yes` was rejected. Each now routes through
`isOktaFlagEnabled`, keeping its existing send-or-omit behavior.

A cleared ServiceNow limit/offset/quantity stayed `''` through the block
mapper and was appended as a valueless `sysparm_limit=`. The mapper now
resolves a blank to undefined, and the tools skip a blank as well.

* fix(integrations): mssql guard gaps and Entra query, scope, and output findings

MSSQL read-only screen
- Screen RENAME, documented T-SQL DDL for Azure Synapse dedicated SQL pools and
  Analytics Platform System, which are reachable over TDS with exactly the
  connection fields this block exposes. `SELECT 1 RENAME OBJECT dbo.t TO t2`
  was a schema change passing an operation advertised as read-only.
- Screen the Service Broker family: RECEIVE as a word, and END/MOVE/GET
  CONVERSATION and SEND ON CONVERSATION as two-token phrases, since END closes
  every CASE. RECEIVE is a destructive read and END CONVERSATION WITH CLEANUP
  drops a conversation's messages.

MSSQL routes and block
- Build the insert statement before connecting, matching update and delete, so a
  bad identifier answers 400 instead of burning a TLS+login and returning 500.
- Declare `truncated`/`truncationReason` on the block, which the tools declare
  and the routes emit but the block left unreferenceable.

Microsoft Entra ID
- Pair `$count=true` with `ConsistencyLevel: eventual` conditionally. Graph
  documents `hasMembersWithLicenseErrors`, `isLicenseReconciliationNeeded`, and
  `identities/any(i:i/issuer)` as filterable only *without* advanced query
  parameters, and documents advanced queries as unsupported in Azure AD B2C
  tenants, so the unconditional pair broke filters that previously worked. When
  continuing from a nextLink the pairing is read off the link itself.
- Request `LicenseAssignment.Read.All` instead of `Directory.Read.All`. The
  latter was needed by `GET /subscribedSkus` alone, whose permission table names
  the former as least privileged and does not list the ReadWrite scope we hold.
- Enumerate the block's real output keys instead of a single `response` object
  no tool emits.

* fix(splunk,datadog): stop truncating searches and send mute/unmute as query params

Splunk run_search: revert the `max_count=1000` default added last pass. It was
wrong on both halves. Splunk documents the parameter as "the number of events
that can be accessible in any given status bucket. Also, in transforming mode,
the maximum number of results to store" — so for a non-transforming oneshot it
bounds status buckets, not the response, and for a transforming search (`| stats`,
`| timechart`, which is what the block's own skills generate) it capped results
at 1000 where Splunk would have stored 10000, silently. The block's `maxCount`
placeholder already read `10000`, contradicting the code. Send `max_count` only
when the caller sets it and restate the description in Splunk's own terms,
matching create_search_job. The real guidance — a oneshot buffers the whole
result set, so use Create Search Job + Get Search Results for anything large —
moves into the tool description and the search-splunk-logs skill.

Datadog mute/unmute: send `scope`, `end`, and `all_scopes` as query parameters.
`MuteMonitor` and `UnmuteMonitor` declare no `requestBody` in the authoritative
spec (docs.datadoghq.com/resources/json/full_spec_v1.json — the generated
datadog-api-client-go v1 schema omits both operations and is a subset, not the
authority); all three parameters are `in: query`. Sent as a JSON body they are
dropped, so a scoped, time-boxed mute becomes an indefinite mute across every
scope and unmute's "all scopes" never applies — answered with a 200 and the full
monitor object, so nothing surfaces.

Datadog list_monitors: imply `page=0` when a page size is set without a page.
Datadog "returns all monitors without a `page_size` limit" when `page` is absent,
so Page Size was inert from a control that reads as a bound. `page` is not
defaulted when neither is set — that would silently truncate a caller relying on
the documented return-everything behavior.

Also:

- Note in get_fired_alerts that `name=-` returns every saved search's fired
  alerts and the endpoint documents "Request parameters: None", so there is no
  count/offset to bound it.
- Fix the Splunk block's `messages` output blurb: `[{type, text}]` holds for the
  search and job-control operations, but get_search_job returns an object.
- Generalize the Datadog block's numeric coercion (`datadogPageNumber` →
  `datadogNumber`) over all 32 bare `Number()` mappings, so a typo or unresolved
  reference is omitted rather than sent as `NaN`/`null`, and an explicit `0`
  survives the old truthiness guard.
- Disclose create_event's documented 18-hour `date_happened` ceiling, and that
  send_logs' `ddsource: "custom"` is a Sim default rather than a Datadog one.

* chore(integrations): regenerate tool metadata and docs

* fix(integrations): resolve confirmed findings from cold block audit

Cloudflare: clear purge_cache advanced targets across operations; send
action_parameters/ref/logging on rate-limit rule updates; migrate off the
deprecated batch zone-settings endpoint; correct MX/URI priority wording;
stop coercing blank numerics to 0.

CrowdStrike: seed includeHidden to match Falcon's documented default.

Microsoft Entra ID: resolve a UPN to an object ID for app role assignment;
wrap 21 array outputs in items.properties so nested paths resolve.

Okta: route assign_user_role's notification flag through isOktaFlagEnabled.

ServiceNow: drop the triage skill's claim of a default limit that does not exist.

Splunk: always assign coerced numerics so raw values cannot leak through the
executor's raw-input merge.

* fix(editor,credential-group): mask secrets outside short-input and stop a per-option abort from failing a shared query

config.password only reached the short-input renderer, so eight credential
fields rendered in plaintext: private keys on ssh/sftp/pi/kalshi, the
Secrets Manager payload, the STS web-identity and SAML assertions, and the
Browser Use variables table. long-input, code, and table now honor the flag.
Code fields mask through the highlighter because react-simple-code-editor
paints its textarea transparent; the table masks every column but the first
so key/value rows stay distinguishable. A registry-walking audit test fails
both on a password flag sitting on a type that cannot honor it and on any of
the eight fields losing its flag.

credential-group threaded a per-option AbortSignal into the fetch registered
under the workspace-wide credential group list key, so closing one option
panel rejected every co-observer with an AbortError that is not a React
Query cancellation. The shared fetch now runs on its own lifecycle signal.

* fix(mssql,editor): measure the response cap in UTF-8 and stop search from unmasking secrets

capRecordset sized rows with JSON.stringify(row).length, which counts UTF-16
code units while the emitted body carries raw UTF-8. CJK is the worst case at
3 bytes per unit, so a recordset admitted as 10 MB serialized to 28 MB. Rows
are now measured with Buffer.byteLength, serialized once each, with array
punctuation charged exactly and a reserve held back for the response envelope.

Workflow search revealed masked credentials without the user touching the
field: the search panel keeps focus in its own input and only scrolls the
match into view, so typing a guess painted a private key on screen. The index
is built client-side from values already in page memory, so this was never a
privilege boundary, but masking exists to prevent incidental display and a
screenshare-visible reveal defeats it. Focus is now the only reveal, applied
through one shared policy across all four renderers.

* test(editor): drop PEM-shaped fixtures from the masking tests

The masking fixtures carried a literal OPENSSH private key header, which
GitGuardian flags as a committed secret even though the body was only the
base64 of "openssh-key-v1". The fixtures now use an obvious marker string,
and the assertions derive their match text and dot counts from the fixture
instead of restating its bytes.

* refactor(editor): drop the dead isSearchHighlighted prop

No renderer consumed it. The editor computed it at two call sites and
sub-block passed a hardcoded false into renderLabel's slot for it, so even
the one function that declared a parameter never saw the real value. Its
only live effect was in the memo comparator, where an unconsumed value
changing forced a re-render for nothing.

The name stays in the masking audit's forbidden-inputs list, which guards
against a search signal being wired back into a masking decision.
2026-08-16 23:25:16 -07:00
Waleed 0844d4166b feat(jotform): add Jotform integration (#6772)
* feat(jotform): add Jotform integration

Adds 43 tools covering forms, questions, submissions, reports, webhooks,
labels, and account operations, plus the block, icon, and generated docs.

Request shapes are pinned against the API's own curl samples and the
official SDKs: PUT /form/{id}/properties and PUT /form/{id}/questions each
take a named envelope while PUT /form and the bulk-submission PUT take
their payload bare, and submission answers accept both the nested object
and the documented {qid}_{subfield} shorthand.

Skips the deprecated folder endpoints in favor of labels, and leaves out
endpoints whose response shape the docs do not publish.

* fix(jotform): harden the error envelope against quoted codes and non-JSON bodies

Jotform quotes `responseCode` on some endpoints and not others, so a
typeof-number test skipped the check on the quoted ones and turned an auth
failure into a successful tool result with empty output. Also caps the raw
body fallback, since an upstream gateway can answer with an HTML page
instead of the documented envelope.

* fix(jotform): stop duplicate question labels overwriting derived answers

Question labels are not unique — a form can carry two questions both
labelled "Email" — so keying the derived `values` map on the label alone
dropped all but the last and handed downstream workflows a confidently
wrong answer.

Every occurrence of a repeated label is now suffixed with its question ID,
rather than only the later ones, so the result does not depend on answer
order and a newly duplicated label reads as absent instead of as an
arbitrary winner. The id-keyed `answers` record was already complete and
is unchanged.

* fix(jotform): make the label-keyed answer map collision-proof

Question labels are free text, so the disambiguation key added in f9026cf
was not itself safe: a question literally labelled "Email (3)" lands on the
key generated for a duplicate "Email" at qid 3, dropping one of them. Any
key already taken is now widened again until it is free.

Accumulates in a Map rather than an object literal on the way out, since a
question labelled `__proto__` assigned onto `{}` sets the prototype instead
of an own property and disappears from the map entirely.
2026-08-16 23:13:28 -07:00
Waleed aeb5624cc8 fix(integrations): close regressions found in the final validation sweep (#6764)
* fix(integrations): close regressions found in the final validation sweep

An independent read-only audit of the eight integrations merged to staging
today found defects in every one, most of them side effects of the surgery
those PRs performed on already-shipped code.

Data loss and destructive paths:
- cloudflare: restore the shipped subBlock ids on read filters so existing
  workflows keep their DNS/zone/purge filters. Losing them made
  list_dns_records return the entire zone with success: true, which a
  downstream delete fan-out would then target. The colliding write controls
  are renamed instead, chosen by blast radius.
- cloudflare: refuse an update_ruleset_rule that would tear down the rule it
  edits. PATCH is a replace, so an omitted action_parameters unbound the WAF
  managed ruleset and every override under it.
- cloudflare: split the hidden `enabled` control so a value set while drafting
  can no longer disable a live WAF or rate-limiting rule.
- cloudflare: stop `name` leaking into update_dns_record and renaming a live record.
- okta: stop a blank name overwriting a stored group name via the LLM path.
  The block guard covered only the UI.

Broken on the default path:
- microsoft_ad: update_user sent accountEnabled: "" on its own default, so
  every call left at "No Change" failed. Same tri-state defect already fixed
  for forceChangePasswordNextSignInWithMfa; `visibility` fixed alongside it.
- cloudflare: `domain` is required for self_hosted (the default app type),
  ssh, vnc and rdp; add saas_app/target_criteria and drop dash_sso, which has
  no request variant.

Silent wrong results:
- datadog: list_monitors inherited Create Monitor's tag filter and returned a
  filtered list as if complete.
- servicenow: `fields` carried both a JSON body and a projection on the three
  legacy generic operations. The regression test for this fed already-JSON and
  could not fail; it now feeds a real projection.
- splunk: cancel_search_job reported failure on success by parsing an XML body
  as JSON; readSplunkJson now tolerates it.
- okta: sendEmail === true dropped a string 'true', silently skipping the
  deactivation email.

Security:
- mssql: add writetext/updatetext/readtext to the statement screen. \bupdate\b
  cannot match UPDATETEXT, so both were reachable through the read-only path.
- crowdstrike: chunk repeated-query ids. At the published caps a single request
  built a ~68 KB query string, past typical proxy limits.

Also: splunk count=0 unbounded read, splunk pagination totals, the `nobody`
placeholder that reintroduced the namespace bug by copy-paste, okta cursor and
activate controls split per operation, servicenow sysparm_having syntax and two
required controls no longer pre-seeded with consequential values, datadog block
outputs reconciled with tool outputs, and 16 escaped apostrophes that corrupted
the published Entra docs.

One scope removed from microsoft_ad (User.Read.All). Directory.Read.All and
GroupMember.ReadWrite.All were proposed for removal and verified still required;
a test now asserts they stay.

* fix(integrations): surface corrupt Splunk bodies and partial CrowdStrike deletes

Narrows readSplunkJson's non-JSON tolerance to XML. The dispatching and
job-control endpoints answer in XML, but a body that is neither empty nor XML
was meant to be JSON, so swallowing its parse failure handed get_search_results
an empty envelope and reported a lost result set as a search with zero events.

Annotates a batched CrowdStrike delete that fails partway with the IDs its
earlier batches already removed. Falcon cannot roll those back, so a bare
failure left the caller unable to tell what was gone and a blind retry
re-targeted IDs that no longer existed.

Registers the Cloudflare subblock-ID migration the registry-stability check
requires. The suffixed read-filter IDs never shipped in a release and every
block already materializes the restored IDs, so they are dropped rather than
renamed onto values the collision guard would discard anyway.

* fix(integrations): close the three defects Bugbot found in the sweep

An execute rule sent an explicit empty action_parameters object past the new
guard, because presence was checked rather than emptiness. `{}` is the same
payload Cloudflare's schema default produces, so it unbound the managed ruleset
the guard exists to protect.

Datadog's new List Monitors pagination used a bare `Number()`, so a typo or an
unresolved reference in either advanced field reached Datadog as a literal NaN
— the pattern this same sweep fixed for Entra `top` and the Splunk numerics.

Okta's block still marked the group name required on update, blocking a
description-only update that the tool, its merge helper, and the API all accept.

* fix(integrations): confine the Splunk XML tolerance and the CrowdStrike commit list

Splitting the XML tolerance out of readSplunkJson into readSplunkDispatchJson
puts it only on the three dispatching and job-control tools that need it. The
results path can no longer read any non-JSON body as an empty envelope, so a 2xx
HTML interstitial surfaces instead of reporting a search that matched nothing.
The dispatch reader anchors on the one documented `<response>` root, so an
interstitial fails there too.

A batched delete now records the IDs Falcon echoed in `resources` rather than
the IDs that were requested. A batch can answer 200 while reporting per-ID
failures, and naming those as deleted told the caller to drop still-live
indicators from the retry.

* test(crowdstrike): pin batched partial-delete parity with an unbatched request

A 2xx envelope carrying per-ID errors is a partial success, not a failure —
failedWithoutResources fails the operation only when nothing came back at all.
The batched path already reports it exactly as a single request does, with
deletedIds naming what Falcon confirmed and errors naming what it refused. Pin
that so the contract is not mistaken for a swallowed failure.

* fix(splunk): read the dispatch XML envelope instead of discarding it

A dispatch answering in the documented XML form was replaced with an empty
object, so create_search_job and dispatch_saved_search threw a missing-sid error
after the remote job had already been created — stranding a job the caller could
no longer poll or cancel. The envelope is now projected onto the same `{ sid }`
shape output_mode=json produces, so the search ID survives.

Matching only the opening tag also accepted a body cut off mid-transfer, which
on a cancellation reported a truncated response as a successful cancel. The
pattern now spans the closing tag, so a truncated envelope falls through to
JSON.parse and throws.
2026-08-15 21:25:23 -07:00
Waleed 025ea4d2bd fix(docs): serve JSON-LD in the HTML, fix sidebar spacing, and tighten the CLI guides (#6763)
* docs(cli): use -g for the install, and cut the prose that was not pulling weight

`--global` is valid but `-g` is what every comparable CLI documents, and the
long form only came from the package README. Also drops the yarn tab: it read
`yarn global add sim`, which works on Yarn 1 only — Yarn 2 removed global
installs, so that command fails for anyone on a modern Yarn. Adds `npx sim` for
running without installing.

The guides had accumulated design rationale that belongs in code comments rather
than user docs — why the filter grammar is JSON, why the config section naming
is asymmetric, why an unexpected error keeps its stack trace. Surveying how gh,
Vercel, Turborepo, Deno, Bun and Supabase write theirs, none carry that kind of
justification, and callouts are reserved for content whose absence produces a
wrong result rather than for general asides.

So: 1016 lines to 763, and 12 callouts to 3. The three that remain are the
pairing-code check, that `sim logout` does not revoke the key, and the
`--limit 100` default on `batch-delete`/`batch-update`, which silently truncates
a larger match. Troubleshooting drops the entries whose error message already
contained its own fix and keeps the seven whose cause is not obvious.

* fix(docs): render JSON-LD as native script tags so it reaches the HTML

All four structured-data blocks — WebSite, TechArticle, BreadcrumbList,
SoftwareApplication — were rendered with `next/script`, which never emitted a
script tag. Measured on a production build, `/api-reference/getting-started`
contained zero `<script type="application/ld+json">` elements; the payload
existed only in the `__next_s` client-injection queue and the RSC flight data,
so anything reading the served HTML saw no structured data at all. React was
also logging "Encountered a script tag while rendering React component" on every
page.

`next/script` is for loading and executing JavaScript. JSON-LD is data, and
Next's own guidance is a native `<script>` in the component. `serializeJsonLd`
already escapes the `<` character to its unicode form, which is the
sanitization that guidance calls for, so only the element changes.

Same build, after: three valid tags per page with `WebSite` in `<head>`, and the
injection queue gone entirely.

* fix(docs): scope the flush-separator rule to a container's first separator

`[data-separator]:not([data-separator] ~ [data-separator])` was meant to keep the
first sidebar group flush against the top padding, but `~` only reaches siblings,
so it also matched the first separator inside every expanded folder. Under
Self-Hosting, "Install" lost its top margin and crowded the "Architecture" link
above it — 25px of gap where "Configure" and "Operate" below it had 40px.

`:first-child` expresses the intent directly. Only the four sidebar roots open
with a separator; every nested folder starts with a page, so the intended case
still goes flush and nothing else changes.

* fix(docs): move the flush-separator rule onto the separator component

Keeps the styling with the component that owns it, per the repo standard, and
lets the global rule be deleted outright rather than corrected — `global.css`
now only loses a rule in this PR. Tailwind's `first:` variant compiles to the
same `:first-child` selector, so behavior is unchanged: the build emits
`.first\:mt-0:first-child{margin-top:0}` and the prerendered HTML carries the
class on the separator.
2026-08-15 20:39:34 -07:00
Waleed 257029a60c feat(microsoft_ad): licensing, security, audit, role, and device operations (#6742)
* feat(microsoft_ad): licensing, security, audit, role, and device operations

Deepens the Microsoft Entra ID block from 12 to 36 tools against the Microsoft
Graph v1.0 reference: license assignment and tenant SKUs, password set/reset,
sign-in session revocation, authentication methods, sign-in and directory audit
logs, app role and directory role assignments, service principals, device reads,
and conditional access policy reads.

Device write (device-update, device-delete) is deliberately excluded. Both
document Directory.AccessAsUser.All as their only delegated scope, with the
higher-privileged read documented as unavailable, so supporting them would mean
requesting tenant-wide act-as-the-user directory access for two operations that
additionally require the caller to hold Intune Administrator.

Also drops an undocumented ?$select= from create_user that was silently nulling
department and accountEnabled in the response.

* fix(microsoft_ad): resolve OData filter and search by owning operation

The params mapper assigned result.filter from each filter subBlock in turn, so
the last non-empty one won regardless of the selected operation. Because a
subBlock keeps its value after the operation changes, a filter written for one
endpoint was sent to every other collection operation — invalid OData against a
different Graph resource, or a silently wrong page.

Resolves the filter and search terms from an explicit operation-to-field map
instead, so each operation reads only the field it owns.

* fix(microsoft_ad): clear non-owning filter and search on the merged inputs

The executor merges { ...inputs, ...transformedParams }, so declining to copy a
stale filter is not enough — the serialized value survives the merge and still
reaches the tool. Advanced-mode subBlocks are serialized on non-emptiness alone
and never have their condition evaluated, so the value is present even when the
field is hidden.

Write filter and search on every operation, as undefined when the operation owns
neither, so the merge clears them.

* fix(microsoft_ad): clear the MFA flag and let paged user operations continue without a User ID

The set_password MFA dropdown only wrote its key when non-empty, so the "No Change"
empty string survived `{ ...inputs, ...transformedParams }` and reached Graph in place
of a boolean. Assign it explicitly, including as `undefined`, the same way `filter` and
`search` are handled.

`list_user_app_role_assignments` and `list_user_devices` page by `@odata.nextLink`, and
both tools already treat `userId` as optional once a continuation URL is supplied. Drop
them from the required set when Next Page is filled in so pagination-only runs pass block
validation.

Also note on the reset_password output that a generated password reaches workflow outputs,
run history, and the model, matching how other tools that return secrets document exposure.

* fix(microsoft_ad): require the service principal ID only on the first page

Every other single-resource ID field pairs its condition with a matching required
rule; servicePrincipalId had none, so a first-page run could pass block validation
with an empty ID and fail inside the tool instead. Require it unless a continuation
URL is supplied, matching the paged per-user operations.

* fix(microsoft_ad): reject a continuation URL from a different collection

Every paged operation reads the one shared Next Page field, and a subBlock keeps
its value after the operation changes. Paging /users and then switching the block
to /devices short-circuited back to the user page, silently returning the previous
collection instead of the selected one.

Assert the continuation URL's terminal path segment against the collection the tool
actually reads, which also rejects a nextLink pasted from an unrelated response.
2026-08-15 19:39:40 -07:00
Waleed fed891f69d docs(cli): add a CLI docs section generated from the command tree (#6762)
* docs(cli): add a CLI section, generated from the command tree

The `sim` CLI shipped with no coverage in the docs site. Adds a fourth
top-level tab for it, and moves Academy last.

The command reference is generated. `sim` exposes 147 leaf commands across
33 groups, most of them derived at runtime from the v2 route contracts, so a
hand-written reference would be wrong the week after it was written. The
generator walks the command tree `buildProgram()` hands to commander — the
same tree the terminal parses — rather than re-deriving it from the contract,
which would be a second implementation free to describe commands nobody can
invoke. `check:cli-docs` is a zero-arg `check:*` script, so the existing audit
runner picks it up and stale pages fail CI.

Generating against the real tree surfaced a collision it had been hiding:
`bulkUpdateKnowledgeDocuments` and `updateKnowledgeDocument` both derived to
`sim knowledge documents update`. Commander resolves a duplicate to the first
match, so the bulk form shadowed the single-document one and its flags were
unreachable while still appearing in `--help`. The bulk form is now
`batch-update`, matching how `tables rows batch-delete`/`batch-update` already
handle the same REST overload, and the generator fails on any duplicate path
so the next one cannot land silently.

Five hand-written guides cover install, auth, configuration, output formats,
and scripting. Also corrects two commands in the package README that do not
exist as documented (`tables columns <tableId>`, and `--sort score:desc`,
which is JSON).

* docs(cli): document every flag from the contracts, add troubleshooting and a single-page reference

The command reference was structurally complete but said almost nothing: 223 of
377 flags rendered as "Set sort by" because the CLI only ever read flag help
from its own contract overrides, and fell back to restating the flag name.

The prose already existed. The v2 route contracts carry 931 `.describe()` calls
and the OpenAPI specs publish all of them — 327 parameters and 282 body
properties, 100% coverage — but the generated operation table dropped every one,
carrying only a per-operation summary. It now carries the field descriptions,
the path-parameter descriptions, and positional help, so `--help` and the docs
explain a flag the same way the API reference does. Placeholder descriptions are
now zero, and 147/147 commands, 377/377 flags and 130/130 arguments are
documented.

`check:cli-docs` fails on a request field with no `.describe()` rather than
letting it render as documentation that says nothing.

Also in this pass:

- Commands are root-level sidebar entries under a Commands heading rather than
  a folder, and headings are the command's description, so the table of
  contents distinguishes entries at the first word instead of repeating
  "sim knowledge documents …" fourteen times. A guard fails the build if two
  descriptions on a page collide, since they would share an anchor.
- A single-page `Complete reference` carrying all 147 commands, for in-page
  search and for agents fetching `/cli/reference.mdx`. It keys on exact command
  paths because descriptions are only unique within a group.
- A troubleshooting page, with every message copied from the source.
- Table columns are sized by a local component; the flag column was starved
  while descriptions kept most of the row empty.
- The prerelease install channels are dropped from the docs and the package
  README, which is what npm renders.

* fix(docs): match the CLI tab by path segment, and escape backslashes before pipes

`pathname.includes('/cli')` also matches `/integrations/clickup` and
`/integrations/clickhouse`, so both existing integration pages lit the CLI tab
and unlit Documentation. Matching is now per path segment. Anchoring to the
start would not work either — a non-default locale prefixes the path, as in
`/ja/cli` — so the segment is matched wherever it sits.

Table cells now double a backslash before escaping pipes. A value ending in one
turned `a\` + `|` into `a\\|`, which the table parser reads as an escaped
backslash followed by an unescaped pipe, splitting the cell early. Nothing in
the command surface contains a backslash today, so this was latent rather than
visible.

The reference page's global options table is two-column and was being wrapped in
`CommandTable`, which sizes the second column for the `Required` cell of the
three-column tables and crushed the description into 5.5rem. It now matches the
overview page, which leaves that table unsized.
2026-08-15 19:25:34 -07:00
Waleed 6a29a9e2f4 feat(mssql): add Microsoft SQL Server integration (#6739)
* feat(mssql): add Microsoft SQL Server integration

Add a Microsoft SQL Server block backed by six tools (query, execute,
insert, update, delete, introspect), mirroring the existing PostgreSQL
and MySQL integrations.

Connections go through the `mssql` (Tedious) driver: `connectionTimeout`
is top-level while `encrypt`, `trustServerCertificate`, and
`instanceName` live under `options`, and `port` is omitted when a named
instance is used. Values are bound as `@paramN` via `request.input()`;
no user value is interpolated into SQL. Identifiers are bracket-quoted
after validation and WHERE clauses run through the shared injection
guard.

Introspection reads INFORMATION_SCHEMA plus the `sys.indexes` catalog
views for tables, columns, primary keys, foreign keys, and indexes.

The icon is a placeholder database cylinder drawn with `currentColor`
until the real brand mark lands.

Requires `bun install` for the new `mssql` / `@types/mssql` deps.

* feat(mssql): use the SQL Server brand mark on a white tile

* fix(mssql): pin the validated IP and correct the introspection catalog reads

Tedious exposes `options.connector`, a hook that replaces its own
resolve-and-connect path, so the connection can be pinned to the address
`validateDatabaseHost` already approved instead of re-resolving the
hostname. `server` stays the hostname because tedious derives the TLS
`servername` from it independently of the connector, so SNI and
certificate validation survive the pin. This brings MSSQL in line with
the PostgreSQL and MySQL tools.

Named instances are dropped: tedious resolves them with a UDP SQL Server
Browser lookup issued outside the connector, and node-mssql deletes
`port` whenever `instanceName` is set, so no configuration leaves a
named instance pinned. A named instance is reachable through its static
TCP port.

Introspection fixes:
- index key columns now filter on `key_ordinal > 0`; INCLUDEd columns
  and partitioning columns both report `0` and were being returned as
  key columns, ordered ahead of the real ones
- foreign keys resolve through `sys.foreign_keys` /
  `sys.foreign_key_columns` rather than
  `INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS`, whose join to
  `TABLE_CONSTRAINTS` has no row when a key references a unique index
  and so dropped the key entirely
- `is_unique` is a `bit`, which tedious maps to a boolean, so it is
  coerced rather than compared
- schemas come from `sys.schemas`, which needs only `public` and carries
  no metadata-visibility caveat

The WHERE-clause guard also covers `WAITFOR TIME`, `OPENQUERY`,
`OPENXML`, the legacy `master..sys*` compatibility views, and extended
and OLE-automation procedures beyond `xp_cmdshell`.

Regenerates the docs and catalog artifacts the icon change left stale.

* chore(mssql): commit the lockfile entries for mssql and its tedious dependency tree

* fix(mssql): make the Query operation genuinely read-only

The block label, tool description, and docs all present Query as SELECT-only
while the route ran whatever T-SQL it was given, so an agent picking
mssql_query because "it is only a SELECT" could delete rows. Screen the
statement for mutating keywords with string literals stripped, which also
catches the WITH ... DELETE form that a leading-token check would miss.

Also switch the tool barrel to absolute imports per the repo convention.

* fix(mssql): compose the shared WHERE guard and close the semicolon-less batch gap

The local validateWhereClause re-derived an older copy of the shared patterns
and scanned raw text, so it missed a bare 1=1 and false-positived on prose in a
quoted value. Delegate to validateSqlWhereClause, which masks string literals
first, and keep only the SQL Server surfaces it has no reason to know about.

T-SQL needs no statement terminator, so every semicolon-anchored stacked-query
check reads straight past `id = 1 DROP TABLE dbo.users`. Screen for a bare
statement-introducing keyword to close that; word boundaries leave ordinary
column names like updated_at and deleted_at untouched.

Export maskSqlStringLiterals so the dialect layer masks the same way the shared
guard does rather than carrying a weaker single-quote-only copy.

* fix(mssql): screen administrative T-SQL and reject batches in the read-only path

The previous round left two keyword lists maintained separately, and both were
short: DBCC, KILL, CHECKPOINT, USE, and DENY were in neither, so
`SELECT 1; DBCC SHRINKDATABASE(...)` and `id = 1 DBCC SHRINKDATABASE(...)`
both got through. Collapse them into one MSSQL_STATEMENT_KEYWORDS shared by the
query and WHERE screens so a keyword cannot be covered in one place and missed
in the other, and add the administrative commands.

Also reject any second statement after a semicolon in the Query path outright.
That closes SELECT 1; <anything> structurally instead of by naming the anything,
so the blacklist no longer has to be exhaustive to hold.

* fix(mssql): reject SQL comments in the read-only query path

A block comment placed inside a keyword splits it as far as a lexical scan is
concerned, so keyword coverage cannot settle whether the server rejoins the
halves. Refuse comments in the Query path instead of modelling the tokenizer.
A SELECT sent through this operation has no need for one, and Execute Raw SQL
still accepts them. Masking leaves comment markers intact, so a literal
containing -- still passes.

* fix(mssql): close the masker-desync bypasses and correct the catalog reads

Every T-SQL screen runs over the shared literal masker, which was written for
the MySQL dialect. Three ways to desynchronise it let real SQL hide inside what
the masker believes is a string, two of which survived the existing even-quote
check:

- a backslash before a quote. T-SQL has no backslash escape, so the server
  closes the literal where the masker swallowed the quote and runs the rest as
  code. `a='x\' DELETE FROM dbo.t WHERE b='y'` holds four quotes and masks the
  DELETE out of the keyword screen entirely, so the read-only Query operation
  would run it.
- a double quote inside a bracketed identifier, which the bracket rule missed
  because it only looked for single quotes.
- any unbalanced double quote or backtick, which the parity check did not cover.

All three now fail closed. Introspection also filters hypothetical and disabled
indexes, which were reported as if they were live, and resolves the referenced
side of a foreign key through sys.schemas so a cross-schema reference is no
longer an ambiguous bare table name. Values bound through request.input are
serialized when they are nested JSON, which the driver otherwise rejects with a
bare "Invalid string.".

* test(mssql): cover the block param merge and the operation-to-tool map

Asserts on the merged `{ ...inputs, ...buildParams(inputs) }` the generic tool
handler forwards rather than the mapper's return, since a key the mapper omits
keeps its raw subBlock value through that merge. Pins the TLS toggles to their
string form end to end — a switch subBlock would serialize `'false'`, which is
truthy, and the route contract would coerce the user's off into on — and checks
that duplicate subBlock ids agree on their seeded default.

* fix(mssql): release a pool whose connect failed, and allow a keyword with no trailing space

Only a pool handed back to the route reaches its `finally`, so a pool whose
connect rejected leaked its tarn resources — one per attempt when a bad
credential is retried. It now closes itself, and a failure to close cannot mask
the connect error the caller needs.

The read-only screen also anchored on `\s` after the opening keyword, which
refused valid reads like `SELECT*FROM dbo.users` and `SELECT(1)`. A word
boundary accepts those while still refusing `SELECTX`, and cannot loosen the
screen — the keyword and batch checks run over the whole statement regardless.

* chore(mssql): regenerate tool metadata and the integration catalog after rebase

Artifacts rebuilt with the generators rather than hand-merged, so they carry
both the mssql entries and the tools that landed on staging in parallel.

* fix(mssql): reject a parenthesised or negated constant tautology in a WHERE clause

The shared guard recognises `OR 1` but not `OR (1)`, `OR ((1))`, `OR NOT 0`, or
`OR NOT (FALSE)` — a parenthesis or a NOT between the operator and the constant
hides it. Both patterns require the constant to be the whole parenthesised term,
so a real disjunct such as `OR (1 = priority)` is untouched.

This narrows the gap rather than closing it, and is not meant to close it: an
always-true expression is not lexically decidable in general, which is why the
WHERE screen stays documented as defense-in-depth rather than a boundary.

* chore(mssql): regenerate tool metadata after rebase onto staging

Rebuilt with the generators so the artifacts carry the servicenow and
crowdstrike tools that landed on staging alongside the mssql entries.

* fix(mssql): screen trigger and state statements, and drop the space anchor on Execute

DISABLE and ENABLE were missing from the shared statement list, so
`SELECT 1 DISABLE TRIGGER dbo.audit ON dbo.users` passed the read-only screen as
a semicolon-less batch and turned auditing off. SET, BEGIN, COMMIT, and ROLLBACK
are added with them, since session and transaction state are reachable the same
way. FETCH is deliberately left out: OFFSET ... FETCH NEXT is the standard paging
clause, and screening it would reject the ordinary paged SELECT.

Execute Raw SQL anchored its allowlist on `\s`, which refused `EXEC(@sql)` —
the ordinary form of dynamic SQL, on the one operation meant to run it. It now
uses `\b`, matching the read-only screen.
2026-08-15 19:13:45 -07:00
Justin Blumencranz 7f936dc02a feat(tooling): enforce docs freshness and modernize agent skills (#6756)
* feat(docs): fail CI when generated integration docs are stale

* fix(docs): don't flag delete-then-recreated trigger pages in check mode

* docs(skills): require docs:check in the integration authoring skills

* chore(skills): migrate agent commands to native skills

* fix(skills): clean orphaned Claude projections
2026-08-15 19:08:47 -07:00
Waleed 8a44621382 feat(cloudflare): add WAF rulesets, rate limiting, Zero Trust Access, R2, Workers, and Tunnels (#6740)
* feat(cloudflare): add WAF rulesets, rate limiting, Zero Trust Access, R2, Workers, and Tunnels

Extends the Cloudflare integration past DNS/zones/cache with the security and
Zero Trust surface:

- Rulesets engine (zone-scoped): list rulesets, get a ruleset, read a phase
  entry point, and create/update/delete rules. WAF managed-rule overrides are
  surfaced through the http_request_firewall_managed entry point, since
  Cloudflare has no dedicated overrides endpoint.
- Rate limiting (zone-scoped) via the current Rulesets-based http_ratelimit
  phase, not the deprecated rate_limits endpoint.
- Cloudflare Access (account-scoped): applications, application policies,
  groups, identity providers, and service tokens.
- R2 buckets, Workers scripts/routes, and cloudflared Tunnels.

Destructive operations (delete application, delete policy, revoke service
token, delete rule, delete bucket) spell out their blast radius, and every
tool branches on the envelope's success flag rather than the HTTP status.

Security events are intentionally omitted: Cloudflare exposes them only
through the GraphQL firewallEventsAdaptive dataset, whose field list is not
documented outside schema introspection.

* fix(cloudflare): correct docs drift and remove any from the tool layer

Validation pass over all 47 Cloudflare tools against developers.cloudflare.com.

- Two tool descriptions still escaped a quote as \'. That reaches the model
  verbatim and truncates the generated MDX cell — the get_zone_settings
  `value` output row was missing from the published docs entirely. Both are
  now template literals, and the row is back.
- list_rulesets ignored pagination. The endpoint pages by cursor via
  result_info.cursors.after (not page/per_page), so a zone with many rulesets
  silently truncated with no way to page. Expose per_page + cursor and return
  the next cursor.
- The managed-ruleset override description claimed action and enabled were
  the overridable properties. They are the ones the Rulesets engine documents
  at every level, but individual managed rulesets add more: an OWASP Core
  Ruleset rule override also takes score_threshold. Corrected in both the
  tool output description and the block's action-parameters wand prompt.
  (sensitivity_level is a DDoS override, not a WAF one — deliberately absent.)
- list_tunnels/get_tunnel dropped the documented `metadata` field.
- list_r2_buckets appended order=name whenever any filter was set. `order`
  only qualifies `direction`, and `name` is its sole documented value.
- Path-interpolated IDs are trimmed, so a pasted ID with trailing whitespace
  no longer 404s.
- Replaced every `any` in the integration with checked types: a shared
  CloudflareEnvelope plus per-resource raw payload interfaces, read through
  readCloudflareResponse. The mappers in utils.ts were the widest hole —
  typing them caught four real output-shape mismatches (identity provider
  read_only, service token enabled, DNS record meta/priority, certificate
  geo_restrictions) that `any` had been hiding.
- BlockMeta only described DNS and zone work. Added templates and skills for
  the WAF, rate limiting, and Zero Trust Access surfaces the block now has.

Confirmed against the docs and left unchanged: rulesets/rate limiting are
zone-scoped and Access/R2/Workers scripts/Tunnels are account-scoped while
Workers routes are zone-scoped; tunnels live under /accounts/{id}/cfd_tunnel;
the ratelimit object is a sibling of action/expression, not nested in
action_parameters; every rate limiting period and mitigation_timeout option
matches the documented set; R2 delete returns an empty result so echoing the
requested bucket name is correct; app-nested Access policy endpoints are
current, not deprecated; and every tool fails on a 200 carrying success:false.

* fix(cloudflare): stop per-operation subblock defaults colliding on a shared id

Subblock initial values are seeded into block state keyed by subblock id —
both stores/workflows/utils.ts and lib/workflows/defaults.ts assign
`subBlocks[subBlock.id]` in a plain forEach — so two controls sharing an id
leave one stored value and the last definition in file order wins. Four ids
were duplicated with differing defaults:

- `type` was defined four times. The Access "Application Type" control is
  last, so every new block seeded `type = 'self_hosted'` and the three DNS
  record controls inherited it — Create DNS Record sent a Zero Trust
  application type as its record type. The subblock added on this branch
  broke a default on tools that shipped long before it.
- `status` was defined three times. The empty tunnel filter is last, so
  List Certificates lost its `all` default.
- `proxied` was defined three times. An empty filter is last, so Create DNS
  Record lost its explicit `false`.
- `action` was defined twice. The rate limiting dropdown is last, so the
  ruleset-rule action input was seeded `block`, quietly making "block live
  traffic" the default for a WAF custom rule the user never configured.

Give the colliding controls their own ids and map them back to the tool
params per operation, ahead of the coercions that read them, so each
operation keeps its own default. The other 17 duplicated ids agree on their
value and are left shared.

Adds tests covering each separated default plus a sweep asserting no id
carries two different seeded values, so a future duplicate goes red.

* fix(cloudflare): generate array include rules and allow bootstrapping a phase ruleset

The Access policy include wand asked for a JSON object while the tool parses
the field with parseJsonArrayParam, so generated rules failed validation.
Switch it to json-array, whose prompt reinforcement omits the object braces.

Rate limiting and WAF custom rules could only be appended to an existing
ruleset, but a zone that has never had a rule in a phase has no entry point
ruleset and returns 404, leaving no way to add the first rule. Add
cloudflare_create_ruleset for the documented POST /zones/{id}/rulesets
bootstrap, seeded with optional initial rules.

* fix(cloudflare): correct verified API defects and stop filters leaking into writes

Independent re-validation of all 48 tools against developers.cloudflare.com
turned up defects that the shipped tools would have hit on their happy path.

Delete DNS record reported every success as a failure. That endpoint is the
one Cloudflare v4 response with no envelope — its documented body is
`{"result":{"id":...}}` with no `success` — so `!data.success` was always
true. Branch on an explicit `=== false` instead.

The two replace-semantics PATCH endpoints could silently destroy live config.
Update rate limit rule defaulted a missing action to `block`, converting an
existing `log` or challenge rule into a hard block on real traffic; update
ruleset rule left action and expression optional and had no `ratelimit` or
`logging` passthrough, so updating a rate limiting rule stopped it rate
limiting. Both now require the fields the replacement needs, and the ruleset
rule carries the two nested objects through.

Access applications were unbuildable for most types: `domain` was required,
but it does not exist on the saas, app_launcher, warp, biso, dash_sso,
infrastructure, mcp, mcp_portal, or proxy_endpoint request variants. The
application type enum was also six values behind. Access group `is_default`
is an array of rule objects, not a boolean.

Purge cache merged every supplied target into one body, but the purge body is
a one-of over the five target kinds; it now names the conflict instead.

The remaining fixes are documentation drift: the priority field is MX and URI
only (an SRV record carries priority inside its content), the certificate
status filter documents only "all", the Worker tag filter takes tag:allowed
pairs, and the managed-rule override list conflated the DDoS-only
sensitivity_level with the WAF rule-level set.

Separately, controls that share a subBlock id share one stored value, and
`shouldSerializeSubBlock` short-circuits on `mode: 'advanced'` before it
evaluates `condition` — so a hidden list filter was reaching a write. A
`list_dns_records` content filter could overwrite a record's content, cache
tags could be written onto a DNS record, and the zone status enum could reach
the tunnel list, whose enum is disjoint. Filters that differ from the value
they collided with now carry their own id, remapped through one table before
any coercion. Sharings that mean the same thing everywhere are unchanged.

Aliases are cleared by explicit assignment rather than destructuring, because
the executor merges the mapper's output over the raw inputs and a merely
omitted key survives as its raw subBlock string. The tests assert on that
merged result, and three mechanical invariants now go red on a new collision:
no id spans a read filter and a written value, no dropdown id carries two
option sets, and no hidden advanced control feeds an operation that cannot
render it. That last one found the name filter reaching three list operations.

* docs(cloudflare): point self_hosted_domains at its replacement

Cloudflare deprecated the field in favour of destinations, which the tools
already surface. The output stays — Cloudflare still returns it — but the
description now says which one to read.

* refactor(cloudflare): drop a dead exception from the empty-type guard

create_dns_record now takes its record type from the recordType control,
whose dropdown has no empty option, so the operation can never reach this
guard with an empty type. Clear it unconditionally.

* fix(cloudflare): point the canvas sentences at the renamed filter controls

The list filters that were split off their write-side twin kept their old ids
in canvasPresentation, so seven clauses referenced a control that is no longer
visible for that operation — check:canvas-sentences catches exactly this, and
a broken clause fails silently on the card rather than throwing.

* fix(cloudflare): stop the rate limiting action defaulting on a replacing update

Making action required on update_rate_limit_rule was only half the fix: the
Action dropdown still seeded block for the update operation too, so an update
that edited only the threshold kept sending block and converted a live log or
challenge rule into a hard block — exactly the harm the required flag was
meant to prevent. The update now has its own control with no seeded value, so
the action is something the caller states rather than inherits.

The certificate status filter also still offered Active and Pending, which
Cloudflare does not document for that endpoint; the only documented value is
all, and omitting it returns active packs.

* fix(cloudflare): stop the Access replacements seeding a type and a decision

Same class as the rate limiting action: both Access updates are full
replacements, and the shared controls seeded self_hosted and allow for the
update operations too. Editing only a policy's include rules would silently
convert a live deny, bypass, or non_identity policy to allow — widening who
gets in — and editing an application would rewrite what it IS.

Each update now has its own required control with no seeded value, so the
type and the decision are stated rather than inherited. Regression tests
cover both, and the canvas sentence follows the renamed decision control.
2026-08-15 18:50:05 -07:00
Waleed cabd2e2fc1 feat(datadog): extend to 40 tools and align every operation with the published OpenAPI specs (#6745)
* feat(datadog): add incidents, SLOs, dashboards, synthetics, Cloud SIEM, and APM tools

Extends the Datadog block from 12 to 39 operations, all verified against
Datadog's published OpenAPI specs:

- Incidents (v2, public beta): list, get, create, update, add todo
- SLOs (v1): list, get, create, update, delete, history
- Dashboards (v1): list, get, create, delete
- Synthetics (v1): list tests, get test, latest results, trigger, pause/resume
- Cloud SIEM (v2): search signals, get signal, update triage state, assign,
  list detection rules
- APM: search spans (v2), list Service Catalog definitions (v2)

Adds tools/datadog/utils.ts so every tool builds its URL from the configured
site/region and shares the JSON:API-aware error extraction, and handles the
v1 flat vs v2 envelope shapes and cursor pagination per endpoint.

* fix(datadog): align every operation with the published OpenAPI specs

Validated all 39 shipped operations (plus the 12 pre-existing ones that had
never been spec-checked) against the DataDog v1 and v2 OpenAPI schemas.

- `POST /api/v2/downtime` requires `monitor_identifier`, so a downtime created
  without a monitor id was rejected. Default to the `*` monitor tag.
- A one-time downtime schedule declares `additionalProperties: false` and
  accepts only `start`/`end`; the timezone moves to `display_timezone`.
- `GET /api/v2/downtime` has no `monitor_id` filter, and the response carries
  no `disabled` attribute. Downtime ids are UUID strings, not numbers.
- Drop scaffold types for operations that do not exist (metric metadata, event
  query, monitor update/delete/unmute, host listing) along with their fields.
- Note that monitor mute is no longer published in the v1 specification.
- Add browser Synthetic test results, which the browser-specific endpoint
  returns with its own camelCase step-count shape.
- Replace every `any` with a spec-derived interface, keeping the polymorphic
  service-definition schema opaque.

* fix(datadog): remove remaining any types and declare every returned output field

Replace the six surviving `Record<string, any>` request-body and response-cast
sites with concrete spec-derived shapes, and declare the output fields that
transformResponse already returned but outputs omitted:

- create_downtime / list_downtimes: timezone, created, modified
- create_monitor / get_monitor: options, creator
- list_monitors: message, priority, options, created, modified, creator
- query_logs: content.attributes, content.tags
- update_security_signal_state / _assignee: type; assignee also gained the
  archiveReason/archiveComment pair its sibling already declared
- query_timeseries: series gained the items shape it never described

* fix(datadog): stop dropping downtime targeting inputs in the block mapping

create_downtime accepts monitorTags, timezone and muteFirstRecoveryNotification,
but the block exposed no inputs for them and never forwarded them. Monitor-tag
targeting silently fell back to the `*` tag, so a downtime meant for one team's
monitors muted every monitor in scope. Adds the three advanced sub-blocks and
wires them through.

Also routes list_downtimes' currentOnly through toSwitchBoolean. A switch yields
the strings 'true'/'false', and 'false' is truthy, so turning the toggle off
still sent current_only=true. Every other switch in the block already used the
helper; this was the last raw one.

* fix(datadog): correct metric type codes, stop SLO update data loss, drop unpublished mute

Independent re-validation of all 39 operations against the DataDog/datadog-api-client-go
generator specs (v1 and v2 openapi.yaml) rather than the client-rendered docs site.

Correctness:
- submit_metrics sent inverted MetricIntakeType codes (gauge as 0/unspecified, rate as 1/count,
  count as 2/rate), silently changing how Datadog aggregated every submitted series. The spec
  enum is 0 unspecified, 1 count, 2 rate, 3 gauge; an unrecognized type is now omitted so
  Datadog infers it. Also stops stamping an invented `resources: [{name:'host'}]` default and
  now forwards `interval`, which Datadog requires for count and rate metrics.
- update_slo replaced the whole SLO with only the fields the caller filled in, so editing one
  field erased description, tags, query, monitor_ids, groups, thresholds, and timeframe.
  PUT /api/v1/slo/{slo_id} is a full replacement, so the stored SLO is now read first and the
  supplied edits are overlaid onto it, with the read-only fields stripped.
- update_incident admitted empty strings, so a blank input could blank a stored incident title
  or fail as an invalid date-time.
- query_timeseries reported a failed query as success: Datadog returns 200 with a non-ok
  `status` and the reason in `error`.
- create_monitor swallowed malformed options JSON and created a monitor with no thresholds.
- send_logs rebuilt each entry from a fixed field list, discarding the custom attributes
  Datadog accepts as additionalProperties, and padded absent optional fields with empty strings.

Removed:
- mute_monitor. /api/v1/monitor/{monitor_id}/mute is absent from the v1 spec entirely, there is
  no unmute counterpart to reverse it, and downtimes are the supported mechanism.

Contract accuracy:
- Security signal search advertised relative times ("now-1h"); the spec types filter.from/to as
  format: date-time. Descriptions, placeholders, and wand prompts now produce ISO-8601.
- list_incidents advertised an `include` value ("integrations") that is not in the spec enum,
  and neither incident tool trimmed the comma-separated list, so "users, attachments" 400d.
- Invalid "ok" group state dropped from both monitor descriptions.
- time_slice removed from SLO create input, which cannot build one without an SLI specification.
- DatadogSite gains ap2, uk1, and us2.ddog-gov.com.

Pagination and errors:
- list_downtimes silently truncated at Datadog's default 30 with no way to page; adds
  page[limit]/page[offset] and surfaces totalCount.
- query_logs returned a cursor it had no way to accept back.
- Error extraction consolidated onto datadogErrorMessage, which now also reads the
  dictionary-shaped errors of the SLO delete conflict. Ten tools were reading `.detail` off
  plain strings or the raw entry off objects, degrading every failure to a bare status line.
- Debug logging removed from list_monitors.

Adds 29 regression tests, each verified to fail when its fix is reverted.

* fix(datadog): add SEV-0, document page-size caps, drop unsourced output defaults

- The severity dropdown omitted SEV-0, which IncidentSeverity allows and both incident
  tool descriptions already advertised.
- Page-size descriptions now state Datadog's documented default of 10 and cap of 100
  instead of an arbitrary example, so an agent does not request an out-of-range page.
- trigger_synthetics_tests emitted an explicit null for a string-typed optional output,
  and update_synthetics_status reported 'live' on the error path regardless of what the
  caller actually requested.

* fix(datadog): keep mute_monitor and add the missing unmute counterpart

Reverses the removal in the previous commit. Absence from the datadog-api-client-go
generator spec showed the endpoint is unpublished there, not that it is retired:
Datadog's official Python client still implements it on master as
`Monitor.mute(id, scope=, end=)` and `Monitor.unmute(id, scope=, all_scopes=)`
(datadogpy datadog/api/monitors.py), which `_trigger_class_action` resolves to
`POST /api/v1/monitor/{id}/mute` and `/unmute` with exactly those body fields.

mute_monitor has also been in the block since #2175 in December, so dropping it would
have broken existing workflows for an endpoint that two independent sources agree is live.

The genuine defect was that muting was a one-way trapdoor: Sim could mute a monitor but
had no way to reverse it. Adds datadog_unmute_monitor, sharing the monitor ID and scope
inputs with mute, so the operation is recoverable from the same block.

Also: mute no longer discards the response body (it now reports the monitor id, name, and
state), routes errors through datadogErrorMessage, encodes the monitor ID in the path, and
stops dropping an explicit `end` of 0.

* fix(datadog): make downtime targeting explicit and reach downtime pagination from the block

Addresses the review findings on the previous round.

- create_downtime accepted both a monitor ID and monitor tags but `monitor_identifier` is a
  oneOf, so it silently kept the ID and dropped the tags, muting a different set of monitors
  than the caller asked for. It now rejects the ambiguous combination.
- create_downtime ran Number.parseInt on the monitor ID with no validation, so a non-numeric
  value became NaN and serialized as null inside monitor_identifier. It now uses the same
  parseMonitorIds guard the SLO path already had, naming the offending value.
- list_downtimes gained limit/offset in the tool but the block exposed neither, so no
  block-driven call could page past Datadog's default. Adds the two sub-blocks and wires them
  through the params mapper.
- The block did not declare the totalCount the tool now returns, so nothing downstream could
  bind to it.

* fix(datadog): tolerate non-string list inputs and keep the shipped mute subblock ids

Both defects were introduced by this branch.

- splitCommaList called .split on its argument, so routing create_downtime's monitorId
  through it turned a legitimate numeric input into a TypeError before the request was
  built. A <Block.output> reference to get_monitor or list_monitors resolves to a number,
  and an LLM tool call can pass a number or an array, so the helper now normalizes all
  three shapes. The previous Number.parseInt path had accepted a number by coercion.
- Adding the unmute operation renamed the mute subblock ids scope/end to muteScope/muteEnd.
  Workflow state is persisted by subblock id, so every existing Mute Monitor block would
  have kept the old keys and silently lost its scope and end time. Restored the shipped ids;
  both are still unique block-wide and no operation reads another operation's value.

* fix(datadog): compare downtime targets after parsing, not before

A whitespace-only Monitor ID is truthy as a raw string but parses to no monitor, so
the oneOf conflict guard rejected a valid tag-targeted downtime whenever the untouched
Monitor ID field carried blank text. Both sides are now compared after parsing.
2026-08-15 18:48:46 -07:00
Waleed 9e67655b23 feat(servicenow): semantic incident, change, catalog, approval, CMDB, and knowledge tools (#6747)
* feat(servicenow): add semantic incident, change, catalog, approval, CMDB, knowledge, and directory tools

The ServiceNow block only exposed generic Table API CRUD, so every real task
started with "which table is that on?". This adds 27 semantic tools that wrap
the same Table API plumbing under the names customers actually use.

- Incidents: create, get by number or sys_id, search, update, resolve, close,
  and append a work note or customer-visible comment.
- Change: create, get, list, update, move state, and list change tasks through
  the documented Change Management API.
- Service catalog: browse items, order one via the Service Catalog API
  order_now endpoint, and list or get requested items.
- Approvals: list pending approvals for an approver, approve, and reject.
- CMDB: search CIs on any class, read a CI with its inbound and outbound
  relations through the CMDB Instance API, and list cmdb_rel_ci rows.
- Knowledge: search and read articles through the Knowledge Management API.
- Directory: find a user by email or user name and list group members, which
  is what fills assigned_to and assignment_group.

Reference fields are the usual source of confusion, so every semantic read
defaults to sysparm_display_value=all — a reference comes back as both its
sys_id and its label — and every semantic write exposes
sysparm_input_display_value so a display name can be written instead of a
sys_id. Coded state values are exposed as labelled dropdowns built from one
constants module rather than raw integers.

The shared instance-URL, Basic Auth, sysparm, envelope, and error handling now
live in tools/servicenow/utils.ts, and the existing eight generic tools were
moved onto it rather than keeping their own copies.

* fix(servicenow): stop per-operation subblock defaults colliding on a shared id

Subblock initial values are seeded into block state keyed by subblock id, so
two subblocks sharing an id leave one stored value and the last definition
wins. Three ids were duplicated with differing defaults:

- `displayValue` was defined twice, unset for the generic Table API tools and
  `all` for the semantic ones. The semantic definition won, so a new block set
  to Read Records or Aggregate Records sent `sysparm_display_value=all` — a
  wire change to two already-shipped tools.
- `state` was defined four times. The Approval State definition won, so every
  new block carried `state=requested`, which Create Incident wrote to the
  incident and Move Change State used instead of its own `-5` default.

Give the colliding controls their own ids and map them back to the tool params
per operation, so the generic tools keep their original request shape and each
semantic operation keeps its own default.

Also correct descriptions that overstated what the API does: the LIKE operator
is not documented as case-sensitive, List Requested Items has no requester
filter, and the Change Management API task shape differs from the Table API.

Adds tool tests covering the refactor invariants for the eight pre-existing
Table API tools and the display-value separation.

* feat(servicenow): read a change request's real next states from the instance

The change tools describe state transitions using the base-system codes, which
only hold on an instance that has not customized its change model. ServiceNow
publishes an endpoint that answers the question directly for the record in
hand, so use it rather than keep assuming.

GET /api/sn_chg_rest/change/{sys_id}/nextstates returns the states reachable
from the change request, the instance's own state-value-to-label map, and, for
model-driven changes, each transition with the conditions it has and has not
met. The tool flattens the per-target-state grouping ServiceNow returns (each
transition already carries from_state and to_state, so nothing is lost) and
derives the states whose conditions currently pass.

Also record the sourcing for the coded values in constants.ts: the change
states and close codes are published as a table, but the incident state codes
are not — only 6 (Resolved) appears in the docs — so mark the rest as defaults
rather than guarantees. Note that sysparm_input_display_value also reinterprets
date and time values in the caller's timezone instead of GMT, which matters for
the change start and end dates.

* docs(servicenow): stop asserting undocumented coded values in placeholders

The additional-fields examples used hold_reason with a coded value of "1".
ServiceNow documents the On hold reason choices by label only — Awaiting
Caller, Awaiting Change, Awaiting Problem, Awaiting Vendor — and publishes
neither the column name nor the codes, so the example was asserting something
unsourced. Use a field whose value is caller-supplied instead, and record the
On Hold requirement on the incident state control using the labels the docs
actually give, including that Awaiting Caller makes Additional Comments
mandatory.

* fix(servicenow): drop phantom parent fields from the catalog order output

order_catalog_item read parent_id and parent_table off the order_now response.
Those fields belong to submit_producer, a different Service Catalog endpoint;
the documented order_now result is sys_id, number, request_number, request_id,
and table. Both outputs were therefore always null.

* fix(servicenow): correct what knowledge search returns as an article id

Search results carry a table-prefixed identifier — "kb_knowledge:9e528db1..."
— not a bare sys_id, while GET /knowledge/articles/{id} accepts only a bare
sys_id or a KB number. The output described it as a sys_id and the tool
description told callers it was what they needed to fetch the article, so
chaining the two tools on that field would fail. Point callers at the KB
number instead. Relevancy score is documented as a number, not a string.

* docs(servicenow): cite the page that actually documents approval statuses

The approval state constants pointed at the classic-approvals landing page,
which does not list the statuses. Approval status is documented separately and
names four — Requested, Approved, Rejected, and Not Requested.

* fix(servicenow): stop constant interpolation leaking into tool descriptions

The docs generator and the client-facing integration catalog read tool
descriptions from source rather than from the evaluated module, so a
template literal like `state ${INCIDENT_STATE.RESOLVED}` shipped to users
verbatim: `apps/sim/lib/integrations/integrations.json` and the published
ServiceNow integration page both rendered `${INCIDENT_STATE.RESOLVED}`
instead of `6`. Inline the base-system coded values in the description
text; the constants stay in use everywhere behavior depends on them.

Also drops an escaped `\'` in the `inputDisplayValue` description for the
same reason, and adds a standing guard test asserting no subBlock id
carries two different seeded defaults — the invariant behind the
per-operation defaulting bug, now checked structurally rather than only
through the four per-operation cases.

* refactor(servicenow): type the shared response boundary instead of any

`parseServiceNowResponse` returned `any`, so every tool reading `data.result`
did unchecked property access — a shape change on the instance side would have
produced a wrong-typed output silently rather than a type error.

Introduces `ServiceNowEnvelope` (`result?: unknown`) as the parser's return
type and narrows the record index signatures from `any` to `unknown`. Adds
`toRecordObject`, `readString`, and `readNestedNumber` so the tools that read
individual fields narrow deliberately at the point of use.

This surfaced five genuinely unchecked reads: Order Catalog Item, Get Knowledge
Article, and Search Knowledge were declaring `string | null` / `number | null`
outputs while emitting whatever the instance sent, and Get Change Next States
assigned an unvalidated object to `Record<string, string>`. Each now coerces or
drops a non-matching value rather than passing it through.

* fix(servicenow): publish the shared tool params and stop offering inert controls

The docs generator reads tool source rather than importing it, so the shared
`params.ts` consts the semantic tools spread were dropped from every published
Input table — 27 of 35 ServiceNow tools listed no instance URL, username, or
password at all. Follow a spread into the module it is imported from so those
rows are published; ten other integrations gain the rows they were missing for
the same reason.

Two controls were dead on arrival: Additional Fields was offered on Move Change
State and Add Incident Comment, and neither tool read it. Wire it through the
change transition, which needs it, and drop it from the comment tool, whose body
is exactly one journal field.

Every coded-value control was a select-only dropdown, so a customized instance's
state or close code was unreachable — sharpest on Move Change State, whose
target state is required and whose real codes come from Get Change Next States.
Make them comboboxes.

Also correct two doc claims ServiceNow does not publish (the incident state
citation pointed at a page that does not exist and compares the legacy
incident_state field; closing an incident is not documented as requiring
itil_admin), replace Record<string, any> with checked narrowing that surfaced
two unsound widenings, and document that List Change Tasks returns a fixed
{value, display_value} shape under `tasks` rather than `records`.

* fix(servicenow): stop one subblock id from carrying two value spaces

Subblock values are stored per block keyed by id, so an id reused across
operations keeps its value when the operation changes. Incident and change
shared `state`, and `closeCode`, `closeNotes`, `comments`, and the knowledge
search phrase were each reused for a different value space — so an incident
state could be written onto a change request, an incident close code sent as a
change close code, or an encoded query searched as knowledge text.

Give each value space its own subblock and republish it to the tool param from
the operation that owns it, the way targetState and approvalState already work.
The generic Table API ids stay exactly as they are, since renaming one would
orphan the stored value of every workflow already using those shipped tools.

The previous guard only compared seeded defaults, which is why this class stayed
hidden; the new one asserts against the merged params a tool actually receives.

* fix(servicenow): point the canvas sentences at the renamed subblocks

The split of the colliding subblock ids left the operation sentences anchored on
ids that no longer exist, so those clauses would silently drop from the card.

* fix(servicenow): validate collection members and split the fields projection

toRecordArray cast every member of a successful response, so a null or scalar in
a collection was handed to the next block as a record while the tool reported
success and its declared output said that could not happen. Members that are not
plain objects are now dropped, and knowledge articles and change transitions get
the same narrowing. The two response types that described an unverified inner
shape now say what is actually checked.

The 'fields' subblock also carried two value spaces: a JSON body on Create and
Update Record, a comma-separated projection everywhere else. Operations added
since read a separate returnFields control, so a body can no longer arrive as a
projection or the reverse. The shipped ids are untouched, since renaming one
orphans the stored value of every workflow already using those tools.
2026-08-15 18:33:30 -07:00
Waleed 4bc89c9256 feat(crowdstrike): add alerts, host response, IOC, Spotlight, RTR, and case tools (#6746)
* feat(crowdstrike): add alerts, host response, IOC, Spotlight, RTR, and case tools

CrowdStrike Falcon shipped only three read-only Identity Protection sensor
tools. This adds 20 tools across the response and investigation surface SecOps
teams actually automate against.

Alerts (current Alerts API): query, get details, update status/assignment/
tags/comment/visibility. Hosts: contain, lift containment, hide, unhide. Host
groups: query, get details, add/remove hosts. IOC Management: query, get, create,
update, delete. Spotlight: query vulnerabilities, get vulnerability details. Real
Time Response: init session, execute a read-only command, poll command status,
delete session. Case Management: query cases, get case details.

Every endpoint, request field, and response field is taken from CrowdStrike's
published surface (developer.crowdstrike.com API reference, FalconPy endpoint
definitions, and the swagger-generated gofalcon models). Required API scope is
documented in each tool description.

Deliberately not implemented:
- Detects API: decommissioned 2025-09-30, superseded by Alerts.
- CrowdScore Incidents API and behaviors: decommissioned 2026-03-09 and removed
  from the developer center entirely. Case Management is CrowdStrike's
  replacement, so its two documented read operations are implemented instead.
- Case create/update/merge: the swagger types case `status` and
  `severity_info.level` as bare strings with no enum, so a correct write cannot
  be built without guessing.

CrowdStrike answers 200 with a populated `errors` array for partial failures.
Responses now surface those per-item errors, and an empty result set carrying
errors is reported as a failure rather than silently succeeding.

The route's shared Falcon client, response normalizers, and operation dispatch
move into colocated modules so the handler stays readable at 23 operations.

* fix(crowdstrike): correct the RTR read-tier commands and stop dropping the IOC delete filter

Validation pass over all 23 tools against CrowdStrike's swagger-generated SDKs
(gofalcon falcon/models + falcon/client, FalconPy _endpoint/*.py) turned up four
real defects.

The Execute RTR Command dropdown offered `csrutil` and a bare `reg`. Neither is
a read-tier base command: CrowdStrike's own swagger description for
RTR_ExecuteCommand enumerates cat, cd, clear, env, eventlog, filehash, getsid,
help, history, ipconfig, ls, mount, netstat, ps, and "reg query". `csrutil`
appears nowhere in CrowdStrike's published surface, and `reg` alone is not a
base command — the registry variants are "reg query" (read) and "reg set"/"reg
delete" (Active Responder). Both entries are corrected everywhere they were
repeated: dropdown, tool description, and param description.

Delete Indicators showed a Filter input, declared the param, accepted it in the
contract, and implemented CrowdStrike's documented filter-takes-precedence rule
in the route — but the block never mapped the field into the tool call, so the
filter was silently discarded and a filter-only delete failed validation. The
`filter` case is now mapped alongside the ID list.

A 200 carrying only envelope errors was reported as HTTP 200 with success:false,
which reads as a success to anything inspecting status. Failures now adopt the
per-item error code the envelope supplies, falling back to 502.

Alert updates gain a first-class Remove Tags By Prefix field. The spelling was
previously unresolvable, so it was left to the raw action-parameter escape
hatch; CrowdStrike's swagger settles it as `remove_tags_by_prefix` in both the
PatchEntitiesAlertsV2 and PatchEntitiesAlertsV3 descriptions.

Case Management and Spotlight scopes now name the OAuth scope string
(case-templates:read, spotlight-vulnerabilities:read) alongside the label the
Falcon API client UI shows, so either rendering is findable.

* fix(crowdstrike): stop blank sensor filters reaching Falcon and expose the RTR outputs

The falcon.ts/normalize.ts/operations.ts split routed query_sensors through the
shared buildUrl helper, which skips only undefined. An empty filter or sort
string therefore emitted `?filter=` / `?sort=` where the pre-split route omitted
the param, sending Falcon an empty FQL expression. Reject blank values in the
contract instead, matching the newer operations.

Also surface the ten RTR fields the tools already return but the block never
declared, and broaden the block metadata past the original sensor-only surface.

* fix(crowdstrike): fail query operations on error-only envelopes and guard IOC pagination

Falcon can answer 200 with an errors array and no resources. The detail
operations already treated that as a failure, but the five query branches
returned an empty successful result, so a failed alert query read as a valid
no-match to the calling workflow.

Blank FQL rejection now covers the alert, host-group, indicator, vulnerability,
and case contracts too, not just sensors, and Query Indicators rejects offset
combined with after instead of forwarding a pagination pair CrowdStrike refuses.

* fix(crowdstrike): stop blank inputs reaching Falcon and restore the dropped output docs

The executor merges `tools.config.params` over the raw block inputs, so a key the
mapper omitted kept its raw subBlock value — and an untouched subBlock is stored
as `null`, which the route contract rejects. Query Alerts with an empty Filter,
Update Alerts without every optional field, and Delete Indicators without an
audit comment all 400'd before reaching CrowdStrike. Seed every optional key as
`undefined` so omission is authoritative, which also stops a value left over from
another operation riding along.

Shared output consts in `outputs.ts` were silently dropped from the generated
docs: the generator scans tool source and resolves consts only from `types.ts`,
so `errors`, `affected`, and `pagination` rows vanished from 17 tool pages and
every nested property row with them. Inline the literals.

Against CrowdStrike's own generated SDKs and developer portal:
- add csrutil, ifconfig, users, and the eventlog subcommand forms to the
  read-tier RTR base commands, matching PSFalcon's ValidateSet
- add detection_suppress/detection_unsuppress and cap host actions at the
  documented 100 ids
- cap the IOC search limit at the documented 500, not 2000
- correct the Cases scope to "Cases: Read"; case-templates guards a different
  collection
- type the IOC payload so a blank string cannot clear a stored field on PATCH
- send `MsaRangeSpec` bounds capitalized, as the spec serializes them
- fail the sensor and RTR-session-close paths on a 200 whose envelope carries
  only errors, and surface partial sensor errors
- give Delete Indicators its own filter so a stale alert query cannot widen it
- drop the pre-selected network-isolating host action

* fix(crowdstrike): correct the RTR command tier, IOC update contract, and US-3 region

Independent re-validation against gofalcon's swagger-generated models and
CrowdStrike's developer center turned up several wire-level errors.

- Real Time Response advertised "eventlog backup"/"export"/"list", "reg query",
  ifconfig, and users as base commands. base_command names a command family and
  subcommands belong in command_string; the eventlog write variants are Active
  Responder commands that would fail on scope under this Read-scoped tool, and
  ifconfig/users appear in neither authoritative list. The block now offers the
  16 documented read-tier families and the contract enforces them.
- Indicator updates accepted an entry with no id, which cannot name a record,
  and accepted type/value, which the update model does not expose. Creates
  accepted an entry with no type, value, or applied_globally -- the one property
  CrowdStrike marks required, and the one that decides fleet-wide scope.
- CrowdStrike documents that PATCH overwrites any omitted field with a blank
  value. The contract can only catch blanks, so the update tool now tells the
  caller to read the indicator first and resend its full field set.
- Added the US-3 commercial region, which was missing from every cloud list.
- Aggregate queries silently dropped percents and filters_spec.
- Deleted the response-envelope body unwrap: no endpoint this integration calls
  returns that shape, and getFalconErrorMessage never honored it anyway.
- Softened the Detects and Incidents claims to what the sources actually state.

A tool description longer than the docs generator's 600-character id-search
window silently publishes as an empty string; three descriptions had crossed it.
Shortened them and added a test that fails before the catalog goes blank.

* docs(crowdstrike): name the endpoint and Identity Protection scope on the sensor tools

The three sensor tools were the only ones in the family that named neither their
endpoint nor their OAuth2 scope, and none of them said these are the domain
controllers Falcon Identity Protection monitors rather than Falcon endpoint
sensors -- a distinction an agent choosing between them and the Hosts tools has
no other way to make. Identity Protection Entities: Read is also a separate
product entitlement from Hosts and Alerts.

* refactor(crowdstrike): say which ID caps are CrowdStrike's and which are Sim's

Every bulk-ID limit claimed CrowdStrike as its source, but only the sensor
(5000), host action (100), indicator batch (200), and Spotlight (400) caps are
published. The alert, host group, indicator, and case caps are Sim's own bound
on request size, and the validation message now says so instead of attributing
a limit CrowdStrike does not document.
2026-08-15 18:19:20 -07:00
Waleed cbbcca970c fix(okta): stop partial updates erasing stored profile data (#6751)
* fix(okta): stop partial updates erasing stored profile data

Post-merge audit of the Okta integration (follows #6741), verified against the
OpenAPI spec bundled in okta-sdk-golang/.generator.

Two updates could silently destroy data:

- `update_group` targets `PUT /api/v1/groups/{groupId}`, which Okta documents as
  `replaceGroup` — it swaps the profile wholesale. Sending only the two fields
  the tool exposes erased the stored description on every rename, and dropped
  every org-defined custom attribute along with it. The tool now reads the group
  and overlays the supplied fields before replacing, matching the read-modify-
  write `salesforce_update_custom_field` already uses for the same hazard.
- `update_user` gated its profile fields on `!== undefined`, so an empty string
  reached Okta and blanked the stored value. The block strips blanks before they
  get there, but the tool is `user-or-llm` and a model routinely emits `""` for a
  field it has nothing to say about, so the guard belongs on the tool.

Also corrected:

- `forgetDevices` defaults to true at Okta, so the unseeded switch rendered off
  while remembered factors were in fact being cleared.
- Group rules take a plain keyword on `search`, not the SCIM-style expression the
  shared Search field's wand generates, so they get their own field.
- `get_logs` dropped `limit=0`, which the spec documents as valid.
- `get_user` emitted an activation timestamp under `activated`, which the block
  declares as the lifecycle boolean; the timestamp is now `activatedAt`.
- Descriptions that overstated what an endpoint does: `list_users` omits
  DEPROVISIONED users, `delete_user` deactivates before it deletes,
  `delete_group_rule` answers 202, and `excludedGroupIds` is always empty because
  Okta does not support group exclusions.

* fix(okta): forward the abort signal through the group read-modify-write

* test(okta): rename the shared body-builder helper

* fix(okta): key the send-email and search mappings off the operation

* docs(okta): use TSDoc for the new block annotations
2026-08-15 17:18:28 -07:00
Waleed 852906ec91 feat(splunk): add Splunk Enterprise and Cloud integration (#6743)
Adds a Splunk block with 12 REST operations: run search (oneshot), create/get/cancel search job, get search results, list/get/dispatch saved searches, list/get fired alerts, list indexes, and list apps. Bearer-token or basic auth, with optional /servicesNS namespace scoping.

Every tool was validated against the Splunk REST reference. Results use search/v2/jobs/{sid}/results because the v1 endpoint is deprecated and disabled from Splunk Enterprise 9.0.1. A half-specified namespace fills the missing node with the documented - wildcard rather than nobody/search, which would have hidden user-private objects. Dispatching endpoints fail loudly instead of reporting success with a null sid, and Create Search Job rejects exec_mode=oneshot since that mode returns results rather than a search ID. The results and control endpoints tolerate an empty body. saved/searches sends the f field filter the reference prescribes for it.
2026-08-15 17:07:59 -07:00
Waleed d45dad7e8b feat(okta): add System Log, MFA, sessions, apps, roles, and group rules (#6741)
Expands the Okta block from 18 to 44 operations, covering the System Log, MFA
factors, sessions, applications, administrator roles, and group rules.

Adds shared helpers for the SSWS auth header, Okta error parsing, and the
Link-header `after` cursor, and routes every tool through them so there is one
auth and error path. All eight list operations now return `nextCursor` and
`hasMore`.

Makes the block's param transform authoritative over the serialized inputs: the
executor merges it on top of them, so a key the transform omits keeps the raw
subBlock string. Assigning `undefined` is what actually drops it, which is what
keeps a non-numeric `limit` from reaching Okta verbatim and stops a blank field
in a partial `update_user` from overwriting the stored value with an empty
string.
2026-08-15 16:47:02 -07:00
337a53f12c feat(cli): Sim CLI with AWS-style profiles and a platform key exchange (#6147)
* improvement(api): pull in the v2 external endpoint surface

Cherry-picks improvement/v2-endpoints (98c85677f5) onto the current base.

The v2 surface standardizes one response family across every endpoint:
`{ data }`, `{ data, nextCursor }`, and `{ error: { code, message, details? } }`,
rendered through apps/sim/app/api/v2/lib/response.ts. v1 auth and rate limiting
are reused as-is; the workspace-access and enterprise-audit checks are split into
`resolve*` cores returning structured failures, with thin v1 wrappers that render
the old `{ error }` body so v1 behavior is unchanged.

The branch's own /api/v2/tables/** is dropped. Staging's tables v2 (#6067,
typed predicate grammar + POST /api/v2/tables/[tableId]/query) supersedes it and
lands in the following merge; the two are reconciled onto the shared envelope
separately.

Conflict resolutions:
- v1/middleware.ts: keeps resolveWorkspaceRequestActor alongside the new
  resolveWorkspaceAccess/resolveWorkspaceScope split
- v1/audit-logs/auth.ts: keeps the newer targetOrganizationId parameter and
  isOrganizationBillingBlocked check inside the structured resolver
- bun.lock: taken from HEAD; the branch's lock churn was unrelated lucide-react
  hoisting

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj

* feat(usage): accept X-API-Key on usage-logs list + export

/api/users/me/usage-logs and /export now use checkHybridAuth — the same
auth /api/users/me/usage-limits already accepts — so external monitors
can read summary.bySourceCredits (the source breakdown of usage-limits'
aggregate currentPeriodCost) instead of estimating Copilot spend by
subtraction. Workspace-scoped keys are pinned to their own workspace's
slice of the ledger: the filter defaults to the key's workspace and an
explicit mismatch 403s. Both endpoints documented in openapi-core.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(cli): sim CLI with AWS-style profiles and a platform key exchange

Adds `packages/sim-cli` (`@sim/cli`, bin `sim`) and extends the existing CLI key
handoff so it can mint the credential the public API actually accepts.

## Key exchange

The handoff already existed but only minted *copilot* keys, which do not
authenticate `/api/v1` or `/api/v2` — those want a Sim platform key. The
approval now carries a `scope`:

- `copilot` (the default, so terminals built against the original flow are
  unaffected) mints as before
- `platform` mints a Sim API key: workspace-scoped when the approver is a
  workspace admin, personal otherwise

Scope and workspace are fixed at *approval*, not at poll: the poll is
unauthenticated by necessity, so the browser is the only moment a human is
present to consent and the only place a permission can be checked. The poll
echoes back what was granted rather than what was asked for, so the CLI cannot
file a copilot key under a platform profile and fail later with an opaque 401.

Picking a workspace and scoping a key to it are kept separate. The terminal has
no key yet, so it cannot list workspaces — the browser picker is the only place
that choice can be made, and the pick comes back as the profile's default
whether or not the key is bound to it. Otherwise a non-admin would pick a
workspace by name and then have to go find its id by hand.

Personal-key creation moves into `lib/api-key/orchestration` so the settings
route and the exchange share one issuer.

## CLI

Profiles work like the AWS CLI: `~/.sim/config` for settings (`[profile dev]`),
`~/.sim/credentials` for keys at 0600 (`[dev]`), selected via `--profile` /
`SIM_PROFILE`. Each setting resolves flag → env → file → default, and
`sim whoami` reports the winning source so a surprising value is explainable.
CI can skip login entirely with `SIM_API_KEY` + `SIM_WORKSPACE`.

Commands cover the v2 surface pulled in earlier: workflows, logs, files, and
knowledge, with `--output json` passing the API's own shapes through for `jq`.
`sim tables` is deliberately absent — that surface is still in flux.

## Drift fixes

The v2 routes were authored a month ago and had fallen behind their services:
`checkActorUsageLimits(userId, workspaceId)` → the billing-attribution flow
(which also restores correct payer attribution for workspace keys on KB upload
and search), `processDocumentsWithQueue` gained a required argument, and the
deploy/rollback param objects had stale fields. Caught by a cold type-check —
an incremental run had reported these files clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj

* feat(billing): dedicated v2 usage endpoints; keep internal usage routes session-only

Replaces the earlier X-API-Key enablement on /api/users/me/usage-logs
with a dedicated public surface, so the internal Billing-settings
endpoints can evolve with the UI while external monitors get a stable
versioned contract:

- GET /api/v2/billing/usage — current-billing-period summary with
  bySourceCredits (the source breakdown external monitors need to watch
  e.g. Copilot consumption without estimating by subtraction), plus
  limitCredits and plan
- GET /api/v2/billing/usage/logs — cursor-paged credit ledger in the v2
  envelope
- workspace-scoped keys are pinned to their own workspace's slice;
  personal keys read the account ledger

The public wire is credits-only: usage-logs rows now carry a hasCost
boolean instead of dollarCost (the Billing UI only needed the >0
signal), and the rateLimit block is removed from the usage-limits
response and docs (deploy-modal tab relabeled accordingly).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(cli): generate the CLI's v2 API from the route contracts, add tables

The same endpoint was being described in three hand-maintained places: the Zod
contracts the routes validate against, the OpenAPI documents, and the CLI's own
TypeScript interfaces. Two of those are now derived.

## Generation

`scripts/generate-v2-cli-api.ts` reads `apps/sim/lib/api/contracts/v2/**` and
emits `packages/sim-cli/src/generated/v2-api.ts`: request/response types for all
44 operations plus an operation table (method, path, path params) the client
dispatches through, so a route that moves or changes verb moves the CLI with it.

The contracts are the right source because the routes validate against them — a
shape that disagrees with a contract is a shape the server would reject. Zod
4's `z.toJSONSchema()` handles all 110 schema slots; the JSON-Schema-to-TS
emitter is hand-rolled over that known-narrow subset and throws on anything
unrecognized rather than degrading to `any`, since silence is how a generated
client drifts.

`packages/*` must not import `apps/*`, so the generated file is plain type
declarations with no imports and the script does the crossing at build time.

`check:cli-api` fails CI when the file is stale. The generated directory is
excluded from biome: the pre-commit hook runs `check --write`, which would
otherwise reformat generated output and fail that check with an unrelated
message.

## OpenAPI: checked, not generated

The docs specs carry ~1000 hand-written descriptions and ~400 examples that Zod
schemas do not encode, so generating them would trade real documentation for
mechanical accuracy. `check:openapi-drift` reconciles structure instead — every
v2 path and method must exist on both sides — keeping the prose while still
failing on divergence. Both currently agree on all 44 operations.

## Tables

`sim tables list|get|columns|rows|insert|delete-rows`, built on the generated
types. Rows go through the POST query endpoint even unfiltered, since it is the
only shape carrying the predicate. Row columns are discovered at runtime and
unioned across the page, so a sparse row cannot hide a column.

Deletion requires an explicit `--row`/`--filter` selector *and* `--yes`; an
argument-less call would otherwise empty the table. Path params are
percent-encoded — an id containing `/` or `?` would otherwise retarget the
request.

The four existing command groups drop their hand-written interfaces for the
generated ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj

* fix(cli): make the generated v2 API a fixed point of the formatter

The pre-commit hook rewrote the generated file immediately after it was
committed, so `check:cli-api` then failed in CI reporting contract drift that
had not happened — the only difference was quote style.

The biome.json exclusion added alongside it does not help: lint-staged runs
`biome check --write` on explicit paths, which bypasses `files.includes`. It
implied protection it never provided, so it is removed.

The generator now pipes its output through `biome format --stdin-file-path`
instead, making the emitted file conformant by construction. The hook has
nothing left to change, and the check compares like with like. A formatter
failure throws rather than emitting unformatted output, since falling back
silently would reopen the same loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj

* fix(cli-auth): wait for the workspace list before allowing approval

The picker fell back to "No workspace (personal key)" while the workspace query
was in flight, and Connect stayed live through that window. A fast click
approved a personal key with no default workspace — when the same click a moment
later would have issued a workspace-scoped key. The fallback read as an answer
rather than a pending state, so the card could promise one outcome and deliver
another.

Connect is now disabled until the list resolves, the trigger shows a loading
label (a placeholder would not show, since the fallback always counts as a
selection), and the explanatory line no longer asserts the personal-key outcome
before it is known.

Failure is treated as degraded rather than fatal: the picker disables but
Connect stays enabled and the copy says a personal key will be issued, so a
transient list failure cannot strand a waiting terminal.

Tests cover the pending, loaded, admin-binding, and error states; the two
loading assertions fail against the previous implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj

* fix(cli-auth): name minted keys by timestamp, not date

A second login on the same day failed with `A workspace API key named "CLI
(2026-07-30)" already exists` — after the user had already approved in the
browser, so the whole handoff was wasted and there was no way to complete it
without renaming the existing key.

Key names are unique per owner, so the name has to be unique per login. Now
`CLI (2026-07-30 15:42:07Z)`: second precision, UTC so it is unambiguous in a
shared workspace key list and sorts chronologically.

The comment claiming a same-day collision was desirable (so logins would reuse
one key) was wrong — nothing reuses the key, the mint just fails. A collision at
second precision now means something genuinely unexpected, so it is still
surfaced rather than retried under a suffixed name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj

* feat(docs): validate OpenAPI specs against the Zod contracts in CI

The specs in apps/docs are hand-authored because they carry what Zod
never defines — error envelopes, status codes, prose, examples — so
they can't be generated; check:openapi validates them instead:

- spec integrity: $refs resolve, operationIds unique, 2xx documented,
  no orphaned component schemas
- v2 conventions: every /api/v2 operation documents 401 + 429 and every
  4xx/5xx resolves to the canonical { error: { code, message } } envelope
- contract cross-check: contracts are auto-discovered from
  lib/api/contracts/v2 (each carries its method + path); doc<->contract
  coverage both ways, query/body/response field diffs via z.toJSONSchema
- examples: documented request/response examples must parse with the
  matching contract's actual Zod schemas

First run caught real drift, fixed here: 16 stale orphaned schemas in
the core spec, the v2 billing ops referencing v1-shaped error
components, deploy/rollback examples missing the required nullable
lifecycle keys, CreateTableBody missing folderId, a legacy-grammar
delete-rows example, and four knowledge document ops missing their
required workspaceId query param.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* fix(docs): recursive field diff in check:openapi + the deep drift it found

A mutation test showed the doc<->contract field diff only compared
top-level properties, so a typo inside the { data } envelope passed.
The diff now descends through matching object properties and array
items (both sides must expose a property set — passthrough contracts
and prose-only docs end the descent instead of false-positive), with
the Zod JSON-schema root doubling as the $defs context.

Deep drift it immediately caught, fixed here: select-column config
(options/multiple) missing from every tables column schema, AddColumnBody
hand-rolling a third column shape (now composed from ColumnInput, with
position/workflowGroupId as the per-op extensions the contracts actually
admit), chunking strategyOptions undocumented, and the deployment
lifecycle fields (activeDeployment/latestDeploymentAttempt) missing from
DeploymentState.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* fix(security): close the triggerType rate-limit bypass on workflow execute

Caller-supplied triggerType flowed unchecked into preprocessExecution,
whose checkRateLimit default turns OFF for 'manual'/'chat' — so any
API-key caller, and any anonymous public-API caller billed to the
workspace owner, could execute unthrottled by sending
{"triggerType":"manual"} (async runs also skipped the worker-side check
via admissionCompleted). External callers may now only send the
redundant 'api' value; internal JWT callers ('workflow'/'mcp') are
unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* refactor(execution): extract enqueue/status/cancel into shared libs

Prepares the v2 execution surface: handleAsyncExecution's queue logic
moves to lib/workflows/executor/enqueue-execution.ts (slot/claim
semantics encoded in a discriminated outcome, not HTTP statuses), the
execution-status read to execution-status.ts, and the order-sensitive
cancel machinery to lib/execution/cancel-workflow-execution.ts. The v1
routes re-render identically — their suites pass unmodified.

Also: preprocessExecution gains rateLimitCounter ('sync'|'async') and
its 429 now carries code RATE_LIMIT_EXCEEDED + retryAfterMs (previously
indistinguishable from the concurrency 429 and Retry-After was
discarded); and the duplicate cancel contract in contracts/logs.ts is
unified on the full 5-value reason enum — its narrower copy made
requestJson throw a client ZodError when cancelling a paused HITL run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(execution): callable execution service + structured error classifier

executeWorkflowService composes the same libs the v1 route holds inline
(call-chain guard, execution-id claim, LoggingSession, preprocessing,
deployed-state load + file-field processing, timeout-bound
executeWorkflowCore, output hydration/compaction) for the deployed-state
caller class — the seam the v2 execute route and in-process internal
callers share, making the HTTP endpoint syntactic sugar.

classifyExecutionError stops discarding the block context that
buildBlockExecutionError already attaches at throw sites: failed runs
now yield {message, code, blockId, blockName, blockType} with a stable
append-only code enum (TIMEOUT/CANCELLED/USAGE_LIMIT_EXCEEDED/
INVALID_INPUT/BLOCK_EXECUTION_FAILED/CHILD_WORKFLOW_FAILED/
OUTPUT_TOO_LARGE/EXECUTION_FAILED), so callers route on error class
instead of substring-matching messages — the single place raw errors
are interpreted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(api): POST /api/v2/workflows/[id]/execute

Thin route over executeWorkflowService: X-API-Key or anonymous
public-API auth (sync/stream only for anonymous), strict body with
body-flag async (no mode headers on v2), SSE passthrough for stream,
and the execution resource response — executionId always present,
in-band run failures are status:'failed' with the structured
{message, code, blockId, blockName, blockType} error, sync timeout is
status:'failed' + TIMEOUT instead of v1's 408, and a Response block's
payload stays inside output (authors never control response
status/headers on this origin). Async debits the async bucket and the
202 statusUrl points at the v2 executions resource. Adds
CLIENT_CLOSED_REQUEST/SERVICE_UNAVAILABLE to the v2 error codes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(api): v2 executions status + cancel with queued backfill

GET /api/v2/workflows/[id]/executions/[executionId] is the single
status URL for sync and async runs: before the async worker writes the
durable log row, status is backfilled from the job queue (deterministic
job id) as 'queued'/'running' — closing v1's 202-to-pickup 404 window —
and failed runs carry the structured error object. POST .../cancel
renders the shared cancellation lib in the v2 envelope with the
tightened 5-value reason enum. Both authenticate via the shared
resolveV2WorkflowAccess (X-API-Key, authz masked as 404,
allowPersonalApiKeys honored).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(execution): workflow tool + MCP bridge run in-process

workflow_executor (workflow-as-agent-tool) short-circuits in executeTool
through WorkflowBlockHandler — the same invocation boundary canvas child
workflows use — mirroring the deployed_block_executor precedent. The
MCP serve bridge calls executeWorkflowService directly instead of
fetching its own execute endpoint; deployment-version pinning, MCP
response-size rejection, and the actor override become typed options
instead of header sniffing. Both callers drop the double admission slot
and duplicate top-level log row the HTTP hop cost, and failed child
runs now surface the structured error + child executionId so parents
and MCP clients can route on error class and hand providers a
reproducible handle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(infra): CORS + CSP coverage for the v2 execute path

/api/v2/workflows/:id/execute gets the same wildcard-origin,
credential-free CORS policy as v1 (the default credentialed policy
would block browser API-key calls and open a cookie CSRF surface) with
X-Sim-Stream-Protocol allowed and no X-Execution-Mode (async is
body-selected on v2), plus the COEP/COOP/CSP header block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(ui): deploy modal + copilot advertise the v2 execute surface

All 20 API-tab snippets move to POST /api/v2/workflows/{id}/execute with
the nested {"input": ...} body, async as the "async": true body flag
(X-Execution-Mode gone), status polling against the v2 executions
resource, the third tab renamed Usage and pointed at
/api/v2/billing/usage, and {data} envelope unwraps in the printed
responses. Fixes the latent baseUrl derivation
(endpoint.split('/api/workflows/')) that would have silently built
garbage URLs under a v2 endpoint, and deletes dead code (exampleCommand
across 3 sites, getAsyncExampleTitle). Copilot deploy/manage/serializer
endpoint builders and the api_trigger bestPractices example follow (the
latter also drops its hardcoded staging host).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* docs(api): document the v2 execution surface

Adds execute, execution status, and cancel to openapi-v2-workflows.json
with the structured ExecutionError schema (append-only code enum + block
attribution) and the ExecutionResource contract, documenting the rules
that differ from v1: modes are body-selected, a failed run is HTTP 200
with status 'failed', an executionId always means data (never the error
envelope), queued status is visible immediately, and Response-block
payloads stay inside output. Registers the three pages in the generated
workflows meta.json and bumps the route-count baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(api): gate the whole /api/v2 surface behind one flag; UI stays on v1

Every v2 route now runs exactly one check immediately after auth —
v2ApiGateError — and answers 404 when the `v2-api` flag is off, so the
surface is invisible until it is deliberately rolled out. The gate is
keyed on userId only: a workspace/org-keyed check would have to read
membership for a caller-supplied id before authorization runs, and its
404-vs-403 split would leak cohort membership (the trap the per-domain
table gate worked around by running late). The two executions routes
inherit it from the shared access resolver; the tables-specific gate is
removed so no route checks twice.

`tables-v2-api` stays, now gating only the internal predicate-grammar
route /api/table/[tableId]/query — note v2 tables routes move to the
unified flag, so enabling them is a `v2-api` decision now.

Reverts the deploy modal, copilot handlers, and api_trigger example to
the v1 execute endpoint: v1 works unchanged, and the UI must not
advertise a surface most users would get a 404 from.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(cli): CLI contract for the v2 surface, incl. execution

Adds `packages/sim-cli/src/contract` — the declarative definition of how the
terminal maps onto the API — and folds in the v2 execution endpoints that just
landed on improvement/v2-endpoints.

## The contract

Read it as a diff against what is already derivable, not a listing. Method,
path, path params, field types, enum values, defaults and required-ness all come
from the generated operation table (which comes from the Zod contracts), and the
command name derives from `<resource> [sub-resource] <verb>`. 23 of 47
operations therefore need no entry at all.

The 24 that do carry only what a schema cannot express:
- names, where REST overloads one path — `DELETE /rows` vs `DELETE /rows/[rowId]`
  becomes `batch-delete` vs `delete`, and `DELETE /deploy` becomes `undeploy`
- flags, where a field's type misdescribes its meaning — `workflowIds` is
  `z.string()` that the route splits on commas; no generator can infer that
- columns, which are editorial
- confirm, for the 8 destructive operations

## Execution

`executeWorkflow` / `getWorkflowExecution` / `cancelWorkflowExecution` derive
badly (`/execute` and `/cancel` are verbs the deriver reads as nouns), so all
three are named explicitly: `workflows run`, `workflows executions get|cancel`.

`stream` is marked `omit`: it switches the response to SSE, which the JSON
client would try to parse. Advertising a flag that breaks the response is worse
than not offering it — a `--follow` command that renders the stream is separate
and hand-written, like `files download`.

## Also

- Drops `check:openapi-drift`. The branch landed `check:openapi`, which does the
  same path/method reconciliation plus a recursive field diff and validates doc
  examples against the real Zod schemas — mine was a strict subset.
- Surfaces the new v2 rollout gate in the CLI: it answers 404 for callers
  outside the cohort, indistinguishable from a missing resource, so a 404 now
  carries that as a possibility rather than a diagnosis.
- `executor/utils/errors.ts` widens instead of casting through `unknown`, which
  is both more honest (the value is an Error) and keeps the double-cast ratchet
  at 8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj

* fix(executor): restore child-cost aggregation dropped by the staging merge

Staging's custom-block rewrite deleted `aggregateChildCost` from
workflow-handler.ts, and git merged that file cleanly — but this branch's
workflow-tool-runner.ts, added for the v2 execute migration, still imports it.
A silent semantic conflict: no marker, broken build.

Taking staging's rewrite is correct, so the helper is defined locally in its
one remaining consumer rather than resurrected in the file staging just
rewrote. Same four lines over the still-exported `calculateCostSummary`, so a
failed child workflow keeps billing the hosted-key spend it consumed instead
of reporting $0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(cli): yaml and text output formats

`--output` now takes table | json | yaml | text, settable per-command, via
SIM_OUTPUT, or persisted per profile as before.

`yaml` joins `json` in rendering the API's raw values rather than the table's
formatted cells, so a duration stays `1500` instead of becoming `"1.5s"` —
switching format changes the encoding, never the data. Line folding is disabled:
valid YAML, but it breaks line-oriented greps and is miserable to read.

`text` is tab-separated with no header and no colour — the shape `cut -f2` and
`while IFS=$'\t' read` expect, so shell plumbing works on a box with no JSON
tool. It uses the rendered cells rather than raw values, since it is a human-ish
format for pipelines rather than something to parse. An absent value collapses
to an empty field instead of the table's em-dash: `cut` returning a literal `—`
would read as a value to every downstream emptiness test.

A bad `--output` is now an error (commander `.choices`) rather than a silent
fall back to `table`. The environment variable and the config file stay tolerant
— those are ambient and set once, so a bad value should not break every command,
but a flag just typed should not be quietly disregarded.

Uses js-yaml 4.3.0, already a direct dependency of apps/sim, rather than adding
a second YAML library to the monorepo.

Also drops a stale README reference to check:openapi-drift, which the v2-endpoints
merge superseded with the deeper check:openapi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj

* refactor(tables): make lib/table/orchestration the single implementation (#6134)

* refactor(orchestration): move the shared error contract out of lib/workflows

OrchestrationErrorCode and statusForOrchestrationError are the contract every
lib/[resource]/orchestration module returns against, but they lived inside the
workflows module, so resource-neutral code (lib/folders) already had to import
from a workflow path. Moved to lib/core/orchestration/types.

Adds a 'locked' class mapping to 423. Both tables and workflows have a lock
that forbids a mutation, and each caller was translating that to a status
itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(tables): make lib/table/orchestration the single implementation

Column update was implemented four times — the UI route, v1, v2, and the
copilot table tool — each calling the same column services but owning its own
guards, error mapping, and audit. The copies had drifted, and the drift was the
bug: v2 was missing both guards, only the copilot copy minted stable option
ids, and only v1/v2 audited.

performUpdateTableColumn, performDeleteTable, and performDeleteTableRow now own
that logic; all ten call sites reduce to auth, parse, call, render. The guards
are asserted once in lib/table/orchestration rather than four times against
four routes.

Behavior this consolidates, previously true on only some paths:

- The typeChanging guard. updateColumnType early-returns on an unchanged type
  and drops any options sent with it, so restating the current type alongside
  new options silently discarded them. v2 had no guard at all and, since its
  contract shares v1's body schema, accepted options and ignored them.
- The select-unique guard. Each write is its own locked transaction, so a
  rename or type change paired with a constraint write that is going to fail
  commits first and then throws, half-applying the schema change.
- Stable select-option ids. Cells reference the option id, so an edit that
  re-sends an option by name has to reuse it or every cell holding it is
  orphaned. Only the copilot path did this; normalizeSelectOptionsInput moves to
  lib/table/select-options and now covers every caller. It preserves a supplied
  id, so it is a no-op for the fully-formed options the HTTP contracts accept.
- required forwarded into the type and options writes, so a conversion
  validates against the constraint the same request is setting.
- An audit on every successful update. The UI route and the copilot tool
  emitted none.
- Single-row delete through the row service. v2 did a raw db.delete, skipping
  assertRowDelete and deleteOrderedRow, so a delete-locked table returned 200
  and the row-count bookkeeping never ran.
- The delete actor handed to deleteTable, which audits only when a row was
  actually archived. v1 and v2 omitted it and audited themselves outside that
  check, emitting TABLE_DELETED for a no-op delete of an archived table.

Failure classes come back as OrchestrationErrorCode; v2 renders them through a
new v2ErrorForOrchestration, mirroring statusForOrchestrationError on the v1
and UI surfaces, so a given failure maps to the same status everywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(tables): bind the column-update tests to the orchestration function

The base's route tests assert which column service each payload reaches — the
behavior that now lives in performUpdateTableColumn. They mocked the `@/lib/table`
barrel; the orchestration module imports the service directly, so they mock that
too and keep asserting the same thing through the extracted implementation.

The orchestration tests move onto the base's semantics: writes address the
stable column id, a rename rides inside the write it accompanies rather than
running first, and the currency guards replace the non-select options guard the
service now owns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(copilot): drop the column-type import the delegation made dead

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(tables): move the audit log out of the table service

`lib/table/service.ts` wrote its own audit rows, so whether an operation was
audited depended on which function a caller reached for rather than on a user
having performed it. That is what let v1 and v2 audit a no-op delete, and what
made `deleteTable`'s optional `actingUserId` double as an audit opt-out flag.

Worse, most sites fell back to `actingUserId ?? createdBy`, so an unattributed
call was logged against the table's *creator*. The copilot `mv` path passed no
actor at all: renaming someone else's table recorded them as the renamer.

Audit now lives in the orchestration functions — performDeleteTable,
performRenameTable, performMoveTableToFolder, performUpdateTableLocks — and
the services just write. Internal callers (folder cascade, import rollback)
keep calling the service and are silent by construction rather than by
remembering to omit an argument.

Two services now return what the audit needs: `deleteTable` reports whether it
actually archived a row, so a repeat delete logs nothing; `updateTableLocks`
returns the before/after locks, since only the locked write can observe the
transition its description names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tables): restore audit provenance and conflict status in orchestration

Moving the audits into the orchestration functions dropped three things the
routes had been carrying, and added one the orchestration now owns twice.

- The v1 and v2 column-update routes passed `request` to `recordAudit`, so
  their audit rows recorded the caller's IP and user-agent. The orchestration
  function had no way to receive it. Every table orchestration function now
  takes an optional `OrchestrationRequestContext` and every HTTP route
  forwards it; the copilot and VFS callers, which have no request, omit it.
- `classifyTableMutation` matched `TableConflictError` on "already exists"
  appearing in the message and reported it as `validation`, turning the UI
  route's 409 on a duplicate table rename into a 400. It now matches the type,
  the way `performRestoreTable` already did.
- `captureServerEvent` ran on every delete while the audit was gated on a row
  actually being archived, so a repeat delete of an archived table still
  reported `table_deleted`. Both now hang off the same evidence.
- The copilot delete path kept its own `captureServerEvent` from when the
  service did not emit one, double-counting every copilot table delete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a

* fix(tables): say which type a no-op column update restated

A copilot `update_column` payload whose only content was the column's current
type used to return success with the live schema, while the v1, v2, and UI
routes rejected the same payload with "No updates specified". Delegating to
`performUpdateTableColumn` unified them onto the routes' rejection — correct,
but the message tells the caller its request was empty when it named a type.

The orchestration function now reports the same thing `updateColumnType` reports
when it loses this race concurrently: the column is already that type, re-issue
without the type change. An empty payload still reads "No updates specified".

Drops the copilot's `outcome.table ?? tableForUpdate` fallback with it — the
comment described the no-op that can no longer reach that line, and a success
always carries a table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a

* refactor(tables): classify failures by type instead of by message text

The table module decided HTTP statuses by searching error messages for
phrases. `VALIDATION_MESSAGE_FRAGMENTS` and `ROW_WRITE_ERROR_PATTERNS` held 32
substrings between them, and fifteen more lists were inlined in routes — 83
matchers over 17 files, each its own copy of the guesswork and already drifted
apart. It made message wording load-bearing: `TableRowLimitError`'s own doc
comment noted that its text had to contain "row limit" for a route to answer
400, and adding "already exists" to a rename message silently demoted a 409 to
a 400 (the bug fixed one commit ago, by adding another special case).

Services now throw `OrchestrationError`, which carries the transport-neutral
`OrchestrationErrorCode` the layers above already speak. Classification is one
`instanceof` in `orchestrationErrorResponse` (UI + v1) and
`v2CaughtOrchestrationError` (v2). Every pattern list is gone. Wording is free
to change; an unclassified error still becomes a generic 500, which is what an
unexpected fault should be.

`asOrchestrationError` walks the `cause` chain rather than testing the caught
value directly: drizzle wraps a throw raised inside a transaction callback in a
`DrizzleQueryError` whose own message is the failed SQL, so a bare `instanceof`
would drop every failure raised inside `withLockedTable`. That is the same
reason `rootErrorMessage` had to dig for a root cause before.

Three throws stay bare `Error` deliberately — `Table ID mismatch`, `Workspace
ID mismatch`, and `Failed to build upsert conflict predicate` are internal
invariants no consumer classified, and they keep falling through to a 500.
`Insufficient capacity` was in the pattern list with no producer anywhere in
the codebase.

Status changes, all deliberate:

- `'forbidden'` joins the code union so the table-row-limit ceiling keeps its
  403; without it this refactor would have flattened it to 400.
- import-async's table-limit rejection: 400 -> 403, matching the two other
  create routes it had drifted from.
- Renaming a table to an invalid name: 500 -> 400. `validateTableName`
  messages don't contain "Invalid", so no matcher ever caught them.
- Restoring a table that isn't archived, or into an archived workspace:
  500 -> 400.
- A duplicate *column* name stays `validation`/400 rather than becoming a 409
  like a duplicate table name. Both v1 and the orchestration have always
  answered 400 for it; changing a published status is not this refactor's job.

The twelve tests that changed were asserting the substring mechanism itself,
constructing plain `Error`s with magic strings. They now assert the real
contract, plus new cases pinning that identical wording carrying no
classification stays internal and keeps its message off the wire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* refactor(cli): output format is a profile setting, not a flag

Drops `-o, --output`. Format is set once per profile with
`sim configure --set-output <format>`, or overridden ambiently with SIM_OUTPUT
for a one-off (`SIM_OUTPUT=json sim logs list | jq`) and for CI, which already
runs file-less on env alone.

Both remaining sources are ambient — set once, then read by every later command
— so an unrecognized value falls back to `table` rather than breaking the CLI.
There is no longer a strict tier, because there is no longer anything typed
per-invocation to be strict about.

Frees `-o` for `sim files download -o <path>`, which previously had to share the
short flag with a global that meant something else entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj

* feat(cli): runtime that builds every command from the contract

Turns the CLI contract into working commands. 43 leaves across 7 groups, up
from the 6 hand-written ones — every v2 operation the contract does not hide is
now reachable, including `sim tables upsert`, `sim workflows run`, and the whole
tables surface.

## What the generator now emits

`V2_OPERATIONS` carries a field→slot map per operation: each query/body field's
kind, whether it is required, its enum values, and its server-side default.
Types alone could not drive this — the runtime has to *iterate* fields to build
flags, and everything from argv arrives as a string, so it needs the kind to
turn "50" into 50 and '{"a":1}' into an object.

It also lifts each operation's one-line `summary` from the OpenAPI specs. The
contracts carry validation, not prose, so `--help` had been showing raw URLs;
the specs already hold a written summary per operation and `check:openapi`
guarantees one exists, so this reuses documentation rather than inventing a
second place to describe the same endpoint.

## The runtime

`derive.ts` names a command `<resource> [sub-resource] <verb>` from the route,
covering 41 of 47. `request.ts` assembles the call: path params from positional
args, `workspaceId` injected from the profile into whichever slot declares it,
everything else coerced and validated locally — so a bad enum, malformed JSON,
missing required flag, or absent workspace fails before any network call.
`build.ts` constructs the commander tree, auto-pages cursor lists up to
`--limit` (0 for everything), and renders through the contract's columns or, for
runtime-shaped rows, keys unioned across the page.

Fixed while wiring: `new Command('upsert <tableId>')` makes the *whole string*
the command name, so `sim tables upsert` never matched and fell through to the
group's help. Arguments have to be declared with `.argument()`.

## What stays hand-written

Two leaves, each for a reason generation cannot satisfy in principle:
`files download` streams binary rather than the JSON envelope, and
`tables rows list` discovers columns from user-defined row data nested under
`data`. They attach onto the generated groups, so `sim files --help` lists them
alongside the rest. The five previous command files are deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj

* fix(cli): review round 1 — flag lookup, terminal controls, download safety

## CLI flags silently dropped (Cursor, High)

Commander camelCases every multi-word flag, so `--min-duration-ms` is stored as
`minDurationMs`. `buildRequest` looked flags up by their own kebab name, found
nothing, and dropped the field — no error, it just never reached the API. That
was every multi-word flag on every generated command.

The unit tests passed because they fed flag values already keyed by flag name,
which is not what commander produces — they validated a fiction. Added
`build.test.ts`, which parses real argv through the built commands; three of its
assertions fail against the previous code. The old tests now use camelCase keys
with a comment saying why.

## Terminal control sequences (Greptile, P1 security)

`stripAnsi` matched only SGR (`ESC [ … m`), so a knowledge document, table cell,
or workflow name could carry OSC, non-SGR CSI, or `ESC c` through to an
interactive terminal — setting the window title, moving the cursor to overwrite
what was already printed, or resetting the terminal. Replaced with a `sanitize`
covering OSC (BEL- and ST-terminated), CSI, any ESC + printable, and the bare
C0/C1 range, keeping tab and newline. Applied where API values become display
text, so the colour the CLI adds afterwards still works.

## Downloads (Greptile, P1 ×2)

`createWriteStream` truncated silently, and the destination name usually comes
from the server's content-disposition rather than anything the caller typed —
so a download could irreversibly replace an unrelated local file. Now opens `wx`
and fails with a message naming `--force`, which was added for the deliberate
overwrite.

The stream's error listener was attached after the read loop finished, so an
EEXIST/EACCES/ENOSPC during writing was an unhandled 'error' event that took
down the process. It is now registered before the first write and raced against
the pump.

## Personal-key caption (Cursor, Low)

With "No workspace (personal key)" picked, the caption still promised a default
workspace the approval does not send. It now distinguishes no-pick from
picked-but-not-admin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj

* fix(cli): review round 2 — body-cursor paging, timestamp sanitization

## `tables rows query` printed nothing (Cursor, High)

`isCursorList` only looked for `cursor` on the query slot, but `queryRows` is a
POST whose whole filter — cursor included — is in the body. It therefore took
the single-request path, which handed an array of rows to `printRecord` and
printed an empty record, and it never auto-paged past the first page.

Replaced with `cursorSlot`, which checks both slots and tells the pager where to
put the cursor back. Added a defensive branch so an array reaching the
single-resource path renders as a list with inferred columns rather than
silently printing nothing.

## Invalid timestamps bypassed sanitization (Greptile, P1 security)

`timestamp()` echoes an unparseable value verbatim, and that value is still
server-supplied — so the branch was a way past every other formatter for the
control sequences round 1 closed. Now sanitized on that path too. Audited the
remaining formatters: no other path returns a server value unsanitized.

Both fixes have tests that fail against the previous code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj

* fix(cli): review round 3 — poll retry, download flush errors

Both findings are flaws in round 1's fixes rather than in the original code.

## A redeemable login was thrown away (Cursor, High)

`pollForKey` treated every non-429 status as terminal. But the poll route
releases its mint reservation on any mint failure — its own comment says "a
later poll can retry" — so a transient 5xx or a same-second name conflict ended
the login after the user had already approved in the browser, forcing a full
restart for something the server had deliberately left recoverable.

Retryable is now 409, 429, and 5xx. Everything else stays terminal: 400 means a
malformed request id or verifier and 401/403/404 mean the server is refusing on
purpose, so retrying those would just spin to the 15-minute timeout.

## A failed download reported success (Greptile, P1)

`file.end(resolve)` passes the flush error to the callback as its argument, so
the pump fulfilled *with* the error and the command printed "Saved" for a
truncated file. Confirmed against node directly — `end`'s callback receives the
errno. It now rejects on that argument, which is the path an ENOSPC actually
takes, since the bytes may not reach disk until the final flush.

Adds `device-flow.test.ts` (11 tests: the retry matrix, transport failure,
terminal refusals, and that the poll secret never enters the browser URL) and
`hand-written.test.ts` covering the download's overwrite guard and flush
failure. The two retry tests fail against the previous code; the flush test
needs `/dev/full` and so runs in CI rather than on macOS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj

* fix(cli): review round 4 — repeated flags encode per field kind

`coerce` comma-joined every `list` flag, but that is only correct for the three
fields whose wire type is a `string` the route splits (`workflowIds`,
`folderIds`, `triggers`). The others genuinely want an array:

- `rowIds` and `selectedOutputs` are `array`, so joining sent a string where the
  schema expects a list — `sim tables rows batch-delete --row a b` failed
  validation, and so did a single `--row a`
- `knowledgeBaseIds` is a string-or-array union whose array branch is the right
  one; joining made `kb_1,kb_2` a single bogus id, so multi-`--kb` search
  silently searched nothing

`list` now means only "accept the flag more than once" — the encoding follows
the field's kind, which the generator already records. The two questions were
conflated under one contract field and the `FlagSpec` doc now says so.

Four tests, three of which fail against the previous code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj

* fix(cli): review round 5 — header sanitization, auth ordering, stale suggestion

## Table headers stayed executable (Greptile, P1 security)

Round 1 sanitized cell *values* but not the column *names*, and a table's
columns are user-defined — so the same control sequences were still executable
one row higher, in the header. Sanitizing is now done inside `renderTable`
rather than at each call site, so a future column source cannot reopen it, with
the two key-derived column builders covered as well.

## Fresh install was told the wrong first step (Cursor, Low)

Generated commands read `profile.workspaceId` directly, bypassing
`requireWorkspace()` — which checks the key first precisely so a new user is
told to log in rather than to set a workspace they cannot use yet. That ordering
was fixed for the hand-written commands earlier and reintroduced by the runtime.
`sim tables list` on an empty profile now says "Not logged in" again.

## A stale suggestion shadowed the fallback (Cursor, Medium)

The picker took `selected ?? suggestedWorkspaceId ?? lastActiveWorkspaceId`. The
suggestion comes from a profile the CLI wrote earlier, so it can name a
workspace the user has since left — and merely being truthy, it blocked the
last-active fallback and left the card on "no workspace" with a perfectly good
one available. It now counts only when it resolves against the loaded list.

Two of the three have tests that fail against the previous code; the third is
verified end-to-end (`sim tables list` on an empty profile).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj

* feat(api): add v2 endpoints for MCP servers, skills, custom tools, folders, and credentials (#6150)

* feat(api): add v2 endpoints for MCP servers, skills, custom tools, folders, and credentials

* fix(api): correct credential role, skill permission bar, MCP url identity, and custom-tool conflict mapping

* fix(api): align credential mutation gating, provider-outage status, and unique-violation conflicts

* fix(api): close unique-violation, revival, orphan-write, and env-rename gaps

* fix(api): treat every provider-outage code as unavailable on create and update

* fix(credentials): use the shared outage predicate on the session update path

* fix(contracts): anchor the predicate double-cast annotation to the cast

`check:api-validation:strict` counted 9 unannotated double-casts against a
baseline of 8, failing CI. The predicate leaf schema was annotated, but the
annotation sat above the declaration while the checker anchors on the line
carrying the cast — five lines below, at the close of the object literal. The
scanner walks back at most three lines and stops at the first non-comment one,
so it hit `value: z.unknown().optional(),` and never saw the reason.

Splitting the object schema from the cast puts them adjacent, so the existing
reason binds. No behavior change — the cast, the schema, and the reasoning are
unchanged.

Also lowers the rawJsonReads ratchet 6 -> 5 to match the current count, which
had drifted down; leaving it high lets a removed raw read silently come back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(skills): point the orchestration error contract at its moved module

#6150 branched before #6134, so skill-lifecycle.ts imports
@/lib/workflows/orchestration/types — the module #6134 moved to
@/lib/core/orchestration/types. Git merged a file deletion on one side with a
new file referencing it on the other: no textual conflict, broken build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(cli): pick up the new v2 domains; discover modules instead of listing them

Merges `v2-api-spec` (#6150 — v2 endpoints for MCP servers, skills, custom
tools, folders, credentials) and the newer `improvement/v2-endpoints`.

## The generator was list-driven, so none of it would have appeared

`DOMAINS` and `SPEC_FILES` were hardcoded. Five new contract modules and a new
`openapi-v2-resources.json` had landed, and the generator would have skipped
every one — silently, with `--check` still passing, because the generated file
matched a generator that never looked. Both are now discovered from disk.

That is the same silent-drop class the review rounds kept surfacing, and it is
the property the whole pipeline rests on: a new v2 domain should reach the CLI
by regenerating, not by remembering to edit a list.

Result: 47 → 72 operations, 13 contract modules, and 25 new commands
(`sim skills list`, `sim mcp-servers get`, `sim folders delete`, …) with no CLI
change beyond the discovery fix. Summaries for the new domains now resolve too,
so their `--help` reads properly instead of falling back to `METHOD /path`.

## Confirmation gates for the new destructive operations

Five new DELETEs arrived ungated. `deleteFolder` is the sharpest — the route
archives the folder *and cascades to its contents* — so its message says so
rather than reading like a single-item removal.

Added a test asserting every DELETE carries a confirmation, with
`undeployWorkflow` the one documented exception (reversible by redeploying). It
fails against this commit's own starting state, so the next domain to arrive
cannot land ungated the way these did.

## One fix outside the CLI

`lib/skills/orchestration/skill-lifecycle.ts`, added by #6150, imports
`OrchestrationErrorCode` from `@/lib/workflows/orchestration/types`, which does
not exist — the type lives in `@/lib/core/orchestration/types`, where every
other consumer reads it. The branch does not type-check without this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj

* fix(cli): render single-key resource envelopes, and column the new domains

`sim mcp-servers create` created the server, exited 0, and printed nothing.
The v2 route answers `{ data: { mcpServer: {...} } }`, and the record renderer
keeps only scalar fields — one key holding an object left it with none. Unwrap
a lone object-valued key before rendering; a payload with siblings (`{ row,
operation }` from upsert) is a real result and is left alone.

The five domains that arrived with the last generation had no contract columns,
so `mcp-servers list` inferred 20 including `hasOauthClientSecret`. Give each a
column set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU

* refactor(knowledge): make lib/knowledge/orchestration the single implementation (#6154)

* refactor(knowledge): make lib/knowledge/orchestration the single implementation

Knowledge base create was implemented four times — the internal route, v1, v2,
and the copilot tool — and the orchestration around the shared write had
drifted. Extract it the same way lib/table/orchestration was: services write,
orchestration decides which writes run, guards them, audits them, and returns a
transport-neutral failure.

Behavior converged, not preserved:

- One chunking default (DEFAULT_CHUNKING_CONFIG). The agent defaulted minSize to
  1 against the API's 100, so identical input produced differently-chunked
  knowledge bases depending on who created it. The agent path now chunks at 100.
- Every successful mutation is audited inside the orchestration function. The
  copilot tool called recordAudit zero times, so agent-created knowledge bases,
  document uploads, updates and deletes left no audit trail at all.
- Failures classify by class, not by message text. The knowledge service errors
  are OrchestrationError subclasses and storage-quota rejections throw a shared
  StorageLimitExceededError, replacing four separate message greps for
  "already exists" / "does not have permission" / "storage limit".

delete_connector reported the opposite of what happened. It reached the route
through an internal HTTP self-call that sent no query string, so the route's
keep-documents default always applied while the agent told the user the
documents had been removed. The self-call is gone — all four connector
operations run in-process — and the orchestration returns the real counts.

Also:

- OrchestrationErrorCode gains 'payload_too_large' (413 / PAYLOAD_TOO_LARGE).
  Without it, dropping the storage-limit message match would have regressed the
  documented 413 on knowledge base create and document upload to a 500.
- messageForOrchestrationError renders a route's own wording for an unclassified
  fault, so a driver's message no longer reaches the client on a 500.
- v1 and v2 knowledge base update now forward actorUserId, which the service
  requires for a workspace move; both omitted it.
- The connector DELETE route reads deleteDocuments through parseRequest. Its
  contract declared z.boolean(), which would have rejected the string a query
  param actually is.
- Drop the 409 from POST /api/v2/knowledge/{id}/documents in the OpenAPI spec.
  Nothing on the upload path throws a conflict; it was only ever reachable by
  the message match this change removes.

Behavior change worth noting: a v1/v2 PUT carrying only the workspaceId scope
field and no actual updates now returns 400 rather than 200 with the unchanged
knowledge base.

Deliberately deferred: document update remains internal-only. Extracting
performUpdateKnowledgeDocument makes exposing it on v1/v2 a contract and a route
away, but that is a new public surface rather than part of this consolidation.

* fix(knowledge): make connector create atomic and stop flattening failures

Review round 1 on #6154.

- Resolve the billing payer before the connector is committed, not after. A
  malformed attribution header rejected post-commit left a live connector behind
  a 500, and a retry created a duplicate plus duplicate sync work. Manual sync
  resolves before writing its audit for the same reason.
- Let the source-config validator carry its own failure class. Collapsing every
  rejection to `validation` flattened the connector PATCH route's 401 (stale
  stored credential) and 409 (missing workspace context) into a 400.
- Add `unauthorized` to OrchestrationErrorCode. It is the class that 401 was
  already expressing on this route, and the v2 vocabulary already had
  UNAUTHORIZED; only the shared union was missing it.
- Report a knowledge base that exists but failed to archive as failed, with the
  reason, rather than as not found. The copilot delete loop folded every
  non-not-found failure into `notFound`, telling the user it was never there.
- Route copilot failures through the same message helper the HTTP surfaces use,
  so an unclassified fault's raw text (a driver's failed SQL) no longer reaches
  the agent verbatim while the UI and public APIs get the generic wording.

* fix(cli): stop dropping nested fields, and emit exports as documents

`sim workflows export <id>` printed `version` and `exportedAt` and nothing
else. The record builder kept only scalar fields, so `workflow` and `state` —
the entire export — were discarded with nothing to say they had been. Same for
`workflows get`, which silently dropped `variables` and `inputs`.

Record views now render every field. Nested values serialize to one line and
are cut at 160 chars: visibly partial beats silently absent, and json/yaml
output still prints them whole.

Export is a document, not a record — it exists to be redirected to a file and
fed back to `import`, and table/text flatten and truncate, so neither can
round-trip it. `document: true` in the contract makes those formats fall back
to JSON; yaml is honoured because it round-trips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU

* feat(cli): JSON flags accept @file and @- alongside inline JSON

A workflow export is hundreds of lines, and `--workflow` only took it inline.
The shell makes that miserable: unquoted `$(cat wf.json)` word-splits into
broken JSON, and nothing in the help said passing a file was an option.

Every JSON flag now reads `@path`, or `@-` for stdin, so the round trip is
`sim workflows export <id> > wf.json` then `import --workflow @wf.json` — or
one pipe. `@` cannot collide with a real value because JSON only ever starts
with `{ [ " -`, a digit, or t/f/n.

Stdin drains with a readSync loop rather than readFileSync(0): a pipe is opened
non-blocking, so the single-read form returned EAGAIN and died with a raw stack
trace exactly when the upstream process had not written yet.

Parse failures that look like a filename now say so — naming @path, or the file
itself when the bare value turns out to exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU

* feat(api): expand the public v2 files surface (#6160)

* feat(api): expand the public v2 files surface

Adds folder support, rename/restore, move, bulk archive, share, and content
replace to /api/v2/files, so managing files by API no longer stops at
upload + download + archive-one.

Routes are thin: auth -> parse -> perform* -> serialize. Share and content
replace get their orchestration extracted first so the session routes and
the public ones cannot diverge on the effective-authType resolution, the
EE public-sharing gate, or the storage-quota classification.

Presigned upload stays session-only: presign does an advisory quota check
and the real debit happens in the separate register step, so a caller that
never registers leaves unaccounted bytes with no reaper. The buffered
multipart path debits inside uploadWorkspaceFile's own transaction.

* fix(files): classify folder and content failures instead of 500ing them

Bugbot round 1. The v2 routes map errorCode straight to a status, so every
manager failure that arrived unclassified became a 500 for what is really a
caller-fixable 400 or 404.

- Folder manager throws OrchestrationError: missing target/folder -> not_found,
  reparent cycle / self-parent / restore-into-archived-workspace -> validation.
- File manager does the same for the in-transaction 'File not found' paths that
  the earlier pass missed.
- updateWorkspaceFileContent's outer catch re-wrapped everything in a bare
  Error, which stripped the class off StorageLimitExceededError and the new
  not_found alike. It now rethrows a classified failure untouched and attaches
  cause to the generic wrap, so asOrchestrationError can still walk the chain.
- Every remaining perform* gained the asOrchestrationError branch.
- renameWorkspaceFile returned the pre-update read, so the v2 PATCH reported a
  stale updatedAt; it now returns the timestamp it actually wrote.

Docs: upload auto-suffixes a duplicate name rather than rejecting it, matching
the in-app uploader. The description claimed 409 and was simply wrong.

* fix(files): surface a failed upload read-back as the real error

getWorkspaceFile swallows a query failure and returns null unless throwOnError
is set, so a transient blip on the post-upload read reported as 'file could not
be read back'. Distinguish the two: a real null after a just-committed write is
an invariant break, a query failure is itself.

* revert(api): drop the dedicated v2 file-folder routes

File folders already live in the shared folder table as resourceType 'file'
(#6045 cut them over, #6051 dropped workspace_file_folders), and the remaining
file-specific folder machinery is being folded into the generic folder engine.
Publishing /api/v2/files/folders/** would pin that transitional split into a
public contract we'd then have to keep or break.

Files stay folder-aware — folderId/folderPath on the projection, folderId on
upload, and the move route — because a folder id is a folder.id and survives
the unification untouched. Folder management belongs on /api/v2/folders once
that surface serves resourceType 'file'; until then there is no v2 way to
enumerate file folders, which is the deliberate gap.

The orchestration classification fixes stay: the internal routes and the
copilot file-folder tools still call those perform* functions.

* fix(files): classify upload failures instead of matching their wording

Bugbot round 2. uploadWorkspaceFile had the same outer-catch rewrap that
updateWorkspaceFileContent did, so a blown storage quota reached the route as a
bare Error and the v2 handler recovered the status by substring-matching the
message. Any rewording silently demoted a 413 to a 500.

- uploadWorkspaceFile rethrows a classified failure untouched and attaches cause
  to the generic wrap.
- FileConflictError is now an OrchestrationError('conflict'), so a duplicate name
  classifies like every other conflict. Its 'FILE_EXISTS' discriminator had no
  readers and is gone; the instanceof checks elsewhere still hold.
- The v2 upload handler uses v2CaughtOrchestrationError, dropping all three
  string matches.

Also documents that bulk-archive is best-effort: unknown or already-archived ids
are skipped rather than failing the call, and deletedItems is what actually
happened. That asymmetry with the single-id DELETE was undocumented.

* feat(cli): wire the expanded v2 files surface

Regeneration picked up seven new operations (72 → 79), every one of which
derived badly. `/files/move` and `/files/bulk-archive` put a verb where the
deriver expects a sub-resource, so each became a group holding a lone `create`;
`GET /files/[id]/share` fetches one share and was read as a collection and
named `list`; and `PATCH /files/[id]` derived to `files update` while its own
summary said "Rename File".

Named them: batch-archive (matching tables rows batch-delete), move, rename,
restore, set-content, share get, share set. Bulk archive is gated behind --yes
like the other batch destructives.

`files list` gained --scope active|archived, and its rows now carry folderPath —
added as a column, since which folder a file sits in is what distinguishes two
rows sharing a name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU

* feat(cli): sim files upload

The counterpart to `files download`, and hand-written for the same reason:
POST /api/v2/files is multipart, which the generated flag surface cannot
express, so `uploadFile` has been hidden since the start.

Reads the file with openAsBlob so it stays on disk while the request is
written, rather than buffering the whole upload in memory. Size is checked
against the route's own 100MB ceiling before anything is sent. Content type
comes from the extension, since the stored type decides whether the workspace
later renders a file or offers it for download.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU

* fix(cli): make tables rows query show the rows

Three things stacked up so the command appeared to do nothing.

A row's cells live under `data`, and column inference skips object-valued
fields — so the table came back listing an id and two timestamps per row and
none of the content the query was run for. `expand` names the wrapper whose
keys become columns, unioned across the page like the top-level ones. A cell
key that shadows a top-level field is shown by its full path, so two different
values never share a header.

A cell containing a newline pushed the rest of its row onto the next line and
every column after it lost alignment; in text mode a tab invented a field that
`cut -f` reads as real. Display cells are now flattened to one line. `sanitize`
still keeps \t and \n — json and yaml must round-trip them, and this is applied
only to finished cells.

A single cell holding an LLM response set the column width for the whole table
and pushed everything after it off-screen, so table cells clamp at 60 columns.
text/json/yaml are untouched: those exist for the whole value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU

* fix(cli): make boolean flags able to say false

`--is-active false` turned sharing ON and reported success. Booleans were
declared presence-only, so the flag meant `true` and commander dropped the
`false` as an argument the command had no use for — silently, because excess
arguments are ignored by default.

A required boolean now takes its value (`--is-active <true|false>`): it is a
state to set, not a switch to flip on, and as a presence flag it could only
ever send one of the two values it needs to express.

Optional booleans stay presence-flags — `--deployed-only` reads better than
`--deployed-only true` — but each also gets `--no-<name>`. Omitting one means
"leave it alone", which is not the same as setting it false; without the
negation there was no way to disable an MCP server or unlock a folder.

Excess arguments are now an error on every generated command, so a value
attached to the wrong flag stops rather than being silently discarded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU

* feat(api): add search, filtering, and sorting to the v2 list endpoints (#6189)

* feat(api): add search, filtering, and sorting to the v2 list endpoints

One convention across every v2 list, documented on lib/api/contracts/v2/shared.ts:
`search` (case-insensitive substring on the resource's natural name field),
`sortBy` + `sortOrder` (per-resource enum, never a free string), and enumerated
resource-specific filters. Reuses the sortBy/sortOrder pair v2 logs and v2
knowledge-documents already ship rather than inventing a third dialect
alongside the Logs filters and the Tables predicate grammar.

Every filter and sort is pushed into SQL. GET /api/v2/files previously read the
whole scope and sorted/sliced it in JS; it now goes through a new
queryWorkspaceFiles that filters, orders, and bounds the page in one query.

Cursors are stamped with the sort they were minted under, so replaying one
under a different sort is a 400 instead of silently duplicated or skipped rows.

* fix(api): validate v2 cursor key values and compare timestamps at ms precision

Two review findings, fixed at the root by making a keyset key own its cursor
codec instead of hand-writing a decoder per sort.

Cursor key values are caller-controlled, and matching the sort stamp and key
count was not enough: an unparseable timestamp or a non-numeric size reached
the query as an Invalid Date or NaN and surfaced as a 500. Each key now type-
checks its own value and rejects a cursor it cannot hold, which both routes
render as the documented 400.

Timestamp keys now order and compare on date_trunc('milliseconds', col).
Postgres keeps microseconds and defaultNow() populates them, but a cursor value
round-trips through a millisecond-only JS Date — comparing the raw column
against the truncated value re-admitted the page's own last row, duplicating it
and stalling pagination outright at a page size of one. Reachable today via
workspace_files.updated_at, which insertFileMetadata leaves to defaultNow().

* feat(api): complete the v2 workflows resource with versions and CRUD (#6184)

* feat(api): complete the v2 workflows resource with versions and CRUD

Adds version listing/detail plus create, update, and delete to the v2
workflows surface, which previously covered only execution and deployment.

- GET /api/v2/workflows/[id]/versions — cursor-paginated, newest first
- GET /api/v2/workflows/[id]/versions/[version] — version + pinned state
- POST /api/v2/workflows, PATCH and DELETE /api/v2/workflows/[id]

All six delegate to the existing orchestration and persistence helpers;
no new domain logic.

* fix(api): check folder containment before lock state; reject malformed version cursors

assertFolderMutable walks a folder's ancestor chain without filtering on
workspace, so inspecting it before containment let a caller tell a locked
folder in someone else's workspace (423) from a nonexistent one (400).
Create and update now assert containment first, matching the ordering
import-workflow.ts already uses.

A version cursor that decodes to JSON without a numeric version filtered
every row out and returned an empty page with nextCursor null, which reads
as a clean end-of-list. Malformed cursors are now a 400.

* refactor(api): page workflow versions in the persistence helper

listWorkflowVersions read every version row and the route filtered and
sliced the result in memory, so the response was bounded but the query
was not. It now takes optional limit/afterVersion, turning the cursor
into a real keyset query; the route asks for limit + 1 and only trims
the has-more probe. Both params are optional, so the internal, v1 admin,
and copilot callers are unchanged.

Also restores the untouched GET handler in [id]/route.ts to its original
formatting — collapsing its signature had re-indented the whole body and
buried the actual additions in whitespace churn.

* feat(api): expand v2 tables with stateless multipart transfers (#6188)

* feat(api): expand the public v2 tables surface

Adds 16 operations so a v2 caller can do what the internal surface can:
rename/move/lock a table, restore it, manage saved views, run enrichment
columns, look up rows, and import/export with observable job control.

Extracts lib/table/orchestration/import.ts (performTableCsvImport,
performCreateTableFromCsv) and lib/table/export-stream.ts from the
first-party routes, then repoints those routes at them, so v1 and v2
cannot drift on what an import or export actually does.

events/stream, metadata and dispatches stay internal — they are editor
state, not public API.

* fix(api): make v2 table PATCH all-or-nothing and name the lock in every 423

Greptile P1: PATCH applied locks, rename and move as three sequential
transactions, so a folder rejected mid-request left the earlier writes
persisted while the response reported failure — and the schema-changed
signal was skipped, leaving open clients on stale state. Every rejectable
condition now runs before the first write, and the signal fires whenever
anything did land.

Cursor: v2TableLockError dropped the lock kind, so async import, column
run, enrichment and table mutations returned a bare LOCKED. A table has
four independent locks, so the caller could not tell which to clear.

* fix(api): report the lock kind on classified 423s too, not just thrown ones

The previous commit named the lock only where the rejection was thrown and
caught at the route boundary. Where it instead arrives as a classified
`errorCode: 'locked'` outcome — delete table, delete row, update column,
and the table mutations — the kind was dropped, so those 423s stayed
unactionable while their neighbours improved.

The orchestration results now carry `lock`, and a shared
`v2TableOrchestrationError` renders both arrival paths into the same
`{ code, message, details: { lock } }` body. `details` is omitted rather
than sent null when the kind is unknown, so a caller branching on it sees
absence instead of a phantom value.

* fix(api): make async table imports observable, not just startable

`POST /import-async` pointed callers at `GET /api/v2/tables/jobs` to track
progress, but that endpoint filters to `type = 'export'` — imports are
derived onto the table itself, one write job at a time, and exports get a
separate list precisely because they are excluded from that derivation.
The public Table shape omitted those derived fields, so an async import
could be started and cancelled but never observed to completion, failure,
or progress. That is the gap the import/export/job-control set was meant
to close.

Table now carries `job` — id, type, status, rowsProcessed, error, or null
when idle — and the import-async docs point at the table rather than the
export list.

* feat(api): make v2 table PATCH state which operations landed on failure

Greptile held the PR at 4/5 on the residual non-atomicity and named two
acceptable resolutions: make PATCH atomic, or have the contract adopt and
expose partial-success explicitly. Atomicity would mean threading one
transaction through renameTable, moveTableToFolder and updateTableLocks —
three shared service functions with four non-test callers including the
first-party route and two copilot tools — and deferring their per-operation
audits to commit time. That is a refactor of shared write paths well
outside this PR.

So the contract states it instead. Every rejectable condition is already
pre-validated, so a failure here is a genuine fault; when one follows a
successful operation the error now carries `details.applied` listing what
is live. Absent when nothing applied, so its presence always means "these
changes took effect despite the error". Documented on the operation.

`v2ErrorForOrchestration` gained the optional `details` this needs.

* fix(api): make table lock flags read-only on the public v2 surface

The new PATCH /api/v2/tables/[tableId] accepted a `locks` object, gated
on workspace admin plus the table-locks feature. That still lets an API
key clear the guard placed there to stop it: `write` is the floor for
the endpoint, and admin keys are ordinary API keys, so a lock is no
longer a boundary the key cannot cross.

Locks stay readable on the table resource and enforcement is unchanged
(a locked verb still returns 423). Changing one is now a first-party
admin action only.

The v2 body is declared here rather than reusing the first-party
updateTableBodySchema, which keeps its `locks` field so the UI can still
toggle them. It is .strict(), so a request carrying `locks` is rejected
with a 400 naming the field instead of silently succeeding without
applying it.

* fix(api): keep reporting applied operations when the PATCH re-read fails

The composite table PATCH promises that `error.details.applied` names the
operations that are live despite an error, but `applied` was scoped
inside the try. A rename or move that committed and was then followed by
a throw in the final re-read — or a re-read finding the table archived —
returned a bare 500/404 with no details, telling the caller nothing had
landed. It would then retry into a duplicate-name conflict or repeat the
move.

`applied` is now function-scoped so every post-write exit carries it: the
404 on a missing re-read, a thrown lock error, a classified orchestration
error, and the generic 500. `v2TableLockError` gains the same
`extraDetails` parameter `v2TableOrchestrationError` already had.

* feat(api): add workflow group writes to the v2 tables surface

v2 exposed GET /groups but none of the writes, so the public API could
run an enrichment or workflow column and read its binding, but never
create one. A caller could add a plain data column and trigger the
machine; wiring the two together still required the UI.

Adds POST/PATCH/DELETE on /api/v2/tables/[tableId]/groups. The group is
the unit that fills columns — one group feeds several — so creating one
creates its output columns in the same call, matching the first-party
shape rather than inverting it onto the column endpoint.

Four departures from the first-party body, all public-surface concerns:
- group.id is optional and server-generated. The UI mints an id to render
  optimistically; a public caller has no such need and a client-chosen id
  is a collision waiting to happen.
- outputColumns[].workflowGroupId is dropped from the body and stamped
  from the resolved group, so it cannot disagree with it.
- autoRun defaults to false. First-party defaults true so a UI add fills
  cells immediately; here it would make one POST fan out a metered run
  across every existing row.
- A group naming neither a workflowId (type manual) nor an enrichmentId
  (type enrichment) is a 400 rather than a half-specified group the route
  has to guess about.

Also rejects an outputColumns entry no group output feeds — the two
arrays are joined by column name, and the first-party client builds both
from one picker so it cannot desync, but a public caller can.

Workspace containment on workflowId is asserted before it is persisted,
on create and on any update that re-points the group; without it a table
becomes a way to invoke workflows the key cannot otherwise reach.

* improvement(api): make v2 table import and export async-only

Drops the three synchronous entry points: POST /tables/[tableId]/import,
POST /tables/import-csv, and GET /tables/[tableId]/export.

Sync import tied a write to the lifetime of an HTTP request. The body
*was* the data, so it carried a 10 MB cap that Next silently truncates
past — a partial import reporting success. It also had no job, so a
timeout mid-write left rows in place with nothing to poll and nothing to
cancel. The async path reads the file from storage instead: upload via
POST /api/v2/files for a key, start with POST /import-async, watch
GET /tables/[tableId] -> job, stop with POST /job/cancel.

Sync export carried no such hazard, but one shape per operation beats
two: with both removed the surface has exactly one way to move a table
in or out, and the CLI wraps the extra calls.

This also removes the last multipart handling in v2 tables. Those were
the only routes bypassing parseRequest — form fields were parsed by hand
against separate form schemas, outside the contract system every other
v2 write goes through.

Create-a-table-from-CSV is now two calls: POST /tables, then
/import-async with createColumns. csvImportModeSchema is append|replace,
so there is no single-call create.

Route baseline 1064 -> 1061.

* docs(api): correct the import-async note about upload size limits

The docstring claimed there is no synchronous upload endpoint and so no
request-body size cliff. Both are wrong: POST /api/v2/files is a
synchronous multipart upload with a 100 MB cap, and it is the only v2
upload path (presigned is deliberately absent).

What async-only actually bought: the cap went 10 MB -> 100 MB, it fails
on an explicit size check and a bounded body read rather than a proxy cap
that silently truncates, authorization completes before any body is
buffered, and the table write is a job that can be watched and cancelled.

* feat(api): unify file and table transfers

* improvement(api): make multipart transfers stateless

* fix(api): make table import completion retries idempotent

* feat(cli): pick up v2 workflow CRUD, table transfers, and list search

79 → 111 operations across three merged PRs.

The generator could not read the new contracts at all: a table view's filter is
a recursive predicate, so Zod lifts it into `$defs` and refers to it, and
`toTypeScript` threw on the first `$ref`. Those definitions are now hoisted into
named aliases — recursion TypeScript resolves without complaint — named after
the type that owns them so two operations lifting their own `__schema0` cannot
collide.

Uploading is no longer one multipart POST. `POST /api/v2/files` is gone,
replaced by a presigned handshake, so `files upload` was left calling a route
that no longer exists. It now creates the upload, signs part URLs in batches of
100 (each is short-lived, so signing all of them up front would expire the last
ones), PUTs each part straight to storage, and completes with the ETags —
aborting the upload if any step fails, since a half-finished one holds storage.
Parts are read through `Blob.slice`, so only the part in flight is in memory.
Verified byte-identical on a 24MB round trip.

The rest is naming. `/cancel-runs`, `/rows/find`, `/restore`, `/columns/run`
and the enrichment path each put a verb where a sub-resource was expected, so
each had become a group holding a lone `create`. Transfer steps keep names that
say what they are, since no single command drives a table import yet.

Three new DELETEs needed gates, which the existing guard test caught. Aborting
an upload and cancelling an import or export stop something in flight rather
than destroying something kept, so those are exempt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU

* feat(cli): sim tables import

Imports a CSV into a new or existing table, driving the same presigned
handshake `files upload` uses — the two are the same protocol against
different paths, so they now share one implementation.

What made this more than a wrapper is that the import carries decisions the
handshake does not: the source is a local file or one already in the workspace,
the target is a new table or an existing one to append to or replace, and
mapping/createColumns are rejected unless the target is existing. Both choices
are required rather than inferred — defaulting to a new table would turn a
forgotten --to-table into a silent second copy of the data — and the
conditional flags are checked here so the error names the flag instead of
arriving as a complaint about the request body.

The transfer only queues the work; rows are parsed afterwards, so returning at
`complete` would report success for an import that goes on to fail on a bad
row. It polls to a settled status and reports the rows written, with progress
on a terminal only. --no-wait opts out.

The handshake steps are hidden now that a command drives them; `imports get`
and `imports cancel` stay, being useful against an import already running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU

* feat(cli): default tables import to a new table named after the file

`sim tables import people.csv` now does the obvious thing rather than
demanding a target. Requiring one guarded the wrong direction: a forgotten
flag creating a new table is visible and easily undone, while the outcome
worth protecting — writing into an existing table — is the one that now has
to be asked for by name.

--to-table becomes --table-id, and --mode/--mapping/--create-columns apply
only alongside it. Passing one without it is an error rather than a no-op:
silently ignoring `--mode replace` would let it read as honoured while a new
table was created beside the one it was meant to overwrite. The reverse is
also refused, since --table-id already names the destination.

The derived name is sanitized, because table names are identifiers: the
obvious basename would reject most real files, so `2026-quarterly sales.csv`
imports as `_2026_quarterly_sales` instead of failing. --name overrides it,
and is required for --file-id, where there is no file name to take one from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU

* feat(v2-tables): paginate the table list

`GET /api/v2/tables` returned every table in the workspace in one response —
it used the cursor envelope but hardcoded `nextCursor: null`, and had no
`limit`. That was defensible when tables were only created through the UI;
`POST /api/v2/tables` is public now, so a script can create them in bulk and
the list has no way to ask for less.

Adds `queryTables` alongside `listTables` rather than changing it, so the
internal callers that genuinely want the whole scope are untouched — the same
split `queryWorkspaceFiles` / `listWorkspaceFiles` already uses. Filter, order
and slice all run in the query, so a `search` never costs a full-workspace read.

A cursor whose values don't bind raises a validation error instead of being
coerced to "no filter", which would have silently served page 1 under a resumed
cursor. The keyset closes on `id` so a page boundary inside a run of equal names
or timestamps stays stable.

The shared `LimitQuery` doc component said "Maximum rows to return"; it now
serves the table list too, so the wording is resource-neutral.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(cli): regenerate for the paginated table list

`listTables` gained `limit` and `cursor`, so the CLI's auto-pager now drives it
like every other paginated list — no CLI change, which is the point of
generating this file. Also picks up `isCurrent` on workflow versions and a new
`voice-output` enum member.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU

* fix(cli): make tables rows create and tables columns run usable

Both were dead on arrival, for opposite reasons.

`createTableRows` takes `z.union([batch, single])`. A union has no flat field
list, so the generator emitted no body slot — and slot absence reads the same
as "this operation has no body", so the command offered nothing and sent
nothing. The generator's own comment claimed the runtime fell back to taking
the body as JSON; nothing did. Unions are now marked, the fields every branch
shares are still emitted (both require `workspaceId`, which comes from the
profile), and `--body <json|@file>` carries the rest, merged over them so the
caller still wins on any key it sets. Dropping that merge was my first attempt
and it failed on the missing workspace.

`runTableColumn` takes `limit: { type, max }`. The pager claimed the *name*
`limit` regardless of type, so it became `--limit <n>` with a default of 100
and sent a number the route rejected on every call, whether or not the flag was
passed. The special case now applies only where `limit` is numeric; elsewhere
it is an ordinary field and gets the JSON flag its type calls for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU

* feat(api): add multipart knowledge document uploads

* fix(api): keep usage admission at knowledge upload session creation

* feat(knowledge): wire knowledge base uploads to multipart sessions

* fix(knowledge): refuse to abort an upload once a document is bound

* fix(uploads): prevent multipart cleanup races

* fix(cli): improve command usability and structure

* feat(cli): support knowledge document uploads

* Unify file creation and signed upload sessions (#6264)

* feat(uploads): unify signed upload sessions

* fix(uploads): preserve attachment storage semantics

* feat(files): add authored file creation

* fix(uploads): omit hoisted S3 metadata headers

* fix(cli): support unified upload sessions

* feat(api): add file metadata endpoint

* feat(cli): accept simple list inputs

* improvement(api): scope folders to resource paths (#6284)

* improvement(api): scope folders to resource paths

* fix(files): serialize folder resolution with uploads

* fix(files): release folder lock before upload setup

* feat(cli): add path-based resource directories

* feat(cli): add resource mkdir commands

* fix(cli): accept positional folder paths

* fix(api): normalize folder paths and unblock resource mutations

* fix(api): make resource cleanup and metadata consistent

* improvement(uploads): persist multipart sessions in postgres

* fix(db): store table row trigger timestamps in UTC

* improvement(api): default folder deletion to non-recursive

* feat(cli): streamline common resource workflows

* feat(cli): standardize resource command syntax

* fix(billing): unify chat usage source

* improvement(logs): expose trace spans on log detail

* feat(cli): sync unified chat billing source

* feat(cli): expose log detail trace spans

* fix(logs): parse list trace spans

* improvement(api): replace workflow jobs with execution resources (#6294)

* improvement(api): replace workflow jobs with execution resources

* fix(api): preserve legacy jobs while preferring v2 executions

* fix(api): make execution polling resume-aware

* fix(ui): hide async examples for public workflows

* fix(api): bridge resume queue visibility lag

* feat(api): add v2 workflow resume endpoint

* fix(api): project pending resume attempts

* fix(api): prefer terminal logs over stale resumes

* improvement(api): unify v2 resource query layers (#6319)

* improvement(api): unify v2 resource query layers

* fix(api): address v2 review findings

* fix(api): preserve cancelled queue status

* fix(api): guard cancelled job transitions

* fix(api): close v2 resume and log gaps

* feat(cli): improve v2 command workflows

* feat(api): rename v2 executions to runs

* feat(api): split credentials and secrets

* feat(api): add workspace metadata and email attribution

* improvement(api): consolidate public v2 route handling

* feat(cli): sync v2 API and personal login defaults

* fix(cli): use profile workspace for workspace get

* fix(cli): use profile workspace for member listing

* improvement(files): centralize operations across APIs and Copilot (#6392)

* improvement(files): unify rename authorization

* chore(skills): add file operation migration guide

* improvement(files): consolidate file operation authorization

* improvement(files): extract shared operation foundation

* improvement(api): simplify internal route declarations

* improvement(files): centralize application authorization

* refactor(api): share workspace file name validation

* refactor(files): centralize copilot application calls

* docs(skills): generalize application operation migration

* fix(cli): restore nested knowledge document commands

* feat(cli): add interactive Sim chat

* improvement(api): centralize remaining v2 resource operations (#6412)

* improvement(api): centralize v2 resource operations

* fix(api): preserve custom tool conflict errors

* improvement(api): migrate policy-sensitive v2 reads (#6410)

* improvement(workflows): centralize v2 application operations (#6411)

* refactor(api): migrate v2 knowledge operations (#6413)

* refactor(api): migrate v2 knowledge operations

* fix(knowledge): fail upload completion on dispatch errors

* fix(knowledge): preserve upload retry and VFS errors

* feat(cli): refine chat and desktop updates

* improvement(tables): centralize v2 application operations (#6414)

* improvement(tables): centralize v2 application operations

* fix(tables): preserve run validation and signals

* style(desktop): refine macOS installer layout

* fix(api): type timestamp cursor parameters

* feat(cli): add saved chat commands

* chore(desktop): trigger updated prerelease

* feat(cli): publish @simai/cli release channels

* fix(ci): include auth package in app prune

* feat(cli): separate file descriptions from content

* feat(cli): add resumable async Sim Chat

* fix(cli): guard file unsharing

* feat(auth): add scoped internal executor delegation (#6459)

* feat(auth): add scoped internal executor delegation

* fix(auth): derive delegation lifetime from one timestamp

* Include share status in file metadata

* feat(auth): centralize delegated identity policy (#6462)

* fix(cli): stream file content to stdout by default

* improvement(copilot): consolidate application adapters (#6450)

* improvement(api): harden application route boundaries (#6451)

* improvement(api): harden application route boundaries

* fix(folders): reject creates at workspace cap

* fix(knowledge): enforce trusted workspace scope (#6452)

* fix(knowledge): enforce trusted workspace scope

* refactor(knowledge): declare v2 body lifecycle

* finish knowledge application migration

* refactor(knowledge): compose copilot batch commands

* fix(knowledge): parse connector query flags

* fix(knowledge): finalize partial batch effects

* fix(knowledge): align merged application boundaries

* fix(knowledge): close application boundary review gaps

* style(knowledge): satisfy branch biome checks

* fix(knowledge): page connector documents in editor

* refactor: enforce Copilot table application boundary (#6453)

* refactor: enforce copilot table application boundary

* fix(tables): finish application boundary migration

* fix(tables): restore scoped copilot imports

* fix(tables): compose copilot commands atomically

* fix(tables): preserve workflow group scheduling

* fix(tables): complete fixed copilot composition

* fix(tables): reject enrichment output mutation

* fix(tables): complete authorized application boundary

* fix(workflows): migrate Copilot application boundary (#6455)

* fix(workflows): migrate Copilot application boundary

* fix(workflows): finish delegated application migration

* fix(workflows): encode VFS folder aliases

* fix(workflows): close application composition gaps

* fix(workflows): preserve VFS validation errors

* fix(workflows): complete application boundary migration

* test(workflows): format canonical binding coverage

* fix(workflows): scope executor metadata reads

* fix(workflows): bind executor metadata targets

* fix(copilot): recover tool arguments lost when a call is checkpointed mid-generation

Tool arguments reach Sim two ways: whole on a frame's `arguments`, or in pieces
as `args_delta` chunks that accumulate into `streamingArgs`. Only the first
populated `params`, so a call checkpointed before any frame carried `arguments`
executed with `{}` and failed its own schema on every required property. The
file subagent's `workspace_file` calls arrive exactly that way, which left the
agent retrying and then routing around the tool entirely.

- executor: hydrate `params` from the streamed deltas before dispatch, covering
  both normal dispatch and the never-dispatched resume path.
- handlers: record the subagent channel at registration rather than only on a
  finalized frame, so the workspace_file -> edit_content intent handoff can find
  its intent instead of reporting "No workspace_file context found".
- preview adapter: pass the frame's tool call id into file delegation, which
  derives its audit id from it. Without it every preview threw and no file
  content streamed at all.
- run: a checkpointed call with no recorded result now reports a failed result
  instead of throwing, which ended the whole turn and cost the user the entire
  response.

Each fix has a regression test verified to fail without it.

* feat(credentials): add v2 OAuth connection APIs

* fix(credentials): preserve active OAuth connection links

* fix(credentials): bind OAuth links to connection intent

* feat(credentials): complete v2 credential lifecycle

* fix(credentials): make disconnect idempotent

* fix(credentials): stabilize oauth draft retries

* fix(credentials): bind oauth callbacks to drafts

* fix(credentials): fail closed on oauth completion

* fix(credentials): bind shopify completion to oauth state

* revert(chat): remove Sim Chat and mothership changes

Reverts this branch's Sim Chat surface and its mothership-view edits.

d4bdb87d04 ("feat(cli): add interactive Sim chat") could not be reverted: it
is a 175-file commit that also introduced the ExecutionContext refactor and
the v2 workspaces API, which 33 files under lib/copilot now depend on.
Reverting it produced 42 content conflicts, 24 of them in the refactor rather
than in chat code. Its chat contribution is removed forward instead.

Reverted:
  b25e7ba0c3 fix(copilot): recover tool arguments lost when checkpointed
  d4c74648b0 feat(cli): add resumable async Sim Chat
  3f0c1fcce0 feat(cli): add saved chat commands
  de263204fd feat(cli): refine chat and desktop updates -- mothership-view
             only; desktop updater, terminal themes, and bridge are kept

Removed forward: the 17 CLI chat modules, /api/v2/chat, /api/v2/chats, the v2
chat contracts, and lib/copilot/headless. lib/copilot/chat/turn-persistence.ts
is kept because the web chat's post.ts imports it.

Left alone: upstream Copilot application-boundary work (#6450-#6462), the
sim-chat billing source, and the v2 workspaces API.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNFjchn6dcM7xevh34PKHE

* fix(credentials): align custom oauth reconnects

* fix(credentials): centralize application authorization

* fix(credentials): keep OAuth draft intent immutable

* chore(cli): regenerate v2 API for the staging sweep

Additive only, both picked up automatically by the derived command surface:
  - cancelWorkflowRun `reason` gains already_cancelled / already_completed /
    already_failed (#6702)
  - getFile gains `scope` (active | archived), so `sim files describe` grows a
    --scope flag defaulting to active

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNFjchn6dcM7xevh34PKHE

* fix(credentials): allow renamed reconnect targets

* fix(credentials): close OAuth draft edge cases

* fix(ci): green the repo audits after the staging merge

check:import-specifiers — 79 violations, all packages/sim-cli. That package is
"moduleResolution": "nodenext" while the rest of the repo is "bundler": Node's
ESM resolver takes the specifier literally, so `./ini.js` is required for a
file that is `./ini.ts` on disk. Following the audit's advice to drop the
extension would break the CLI at runtime. The checker now reads each
workspace's tsconfig and, for a NodeNext package, resolves a `.js` specifier
back to its source instead of flagging it — still catching genuinely missing
files.

check:utils — device-flow.ts polled with `new Promise(setTimeout)`. It cannot
import sleep() from @sim/utils: that package is private, so a published
@simai/cli would resolve it in the monorepo and fail from npm. Added a local
helpers.ts and allowlisted it, matching the existing packages/cli entry.

check:tool-registry-boundary — knowledge/page.tsx measured +43 against a +42
allowance. tools/registry.ts is not a gateway on any route, so the boundary the
audit exists to protect is intact; the growth is this branch's own v2 work.
Re-recorded per the script's own instruction.

helm — restored staging's networkpolicy_test.yaml. An earlier integration merge
had dropped its trailing newline, which was the only helm delta against
staging and was tripping the chart-version-bump check.

Not addressed: the Security audit step reports high advisories (brace-expansion,
undici, Socket.IO, OpenTelemetry, fast-uri). Every one is transitive and present
in staging's lockfile too; the step is continue-on-error and did not fail the
job. bun audit reads a live advisory feed, so staging's green run predates them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNFjchn6dcM7xevh34PKHE

* fix(cli): read share fields from the v2 share object

The file-share columns pointed at a `sharing` wrapper that v2 does not return.
The share travels under `share` on file metadata (null when unshared) and as the
unwrapped body on the share endpoints, and its flag is `isActive`, not
`enabled`. Every one of those columns was therefore rendering an em-dash on
`files describe`, `files share get`, and `files share set`.

A missing field path renders blank instead of failing, so nothing caught this.
Added a rendering test over both surfaces; it fails if a path stops resolving.
`hasPassword` is surfaced on the two share commands while they are being fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNFjchn6dcM7xevh34PKHE

* fix(credentials): fail closed without breaking auth

* improvement(cli): bundle and publish as @sim/cli

* fix(credentials): preserve migrated route behavior

* fix(cli): confirm before overwriting login profile

* fix(cli): adapt service-account credential fields

* feat(cli): prompt for secret values

* fix(cli): harden login and credentialless chat

* fix(cli): skip existing package releases

* fix(cli): avoid exposing API key fragments

* fix(cli): cancel failed download streams

* fix(cli): sanitize authentication metadata

* fix(cli): rename authentication output metadata

* fix(cli): publish downloads atomically

* fix(cli): harden download destinations and labels

* fix(cli): support dangling download symlinks

* fix(cli): keep new downloads atomic

* refactor(cli): remove unrelated Copilot changes

* fix(cli): publish as sim package

* fix(cli): use staging npm tag

* fix(cli): align vitest lock resolution

---------

Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Siddharth Ganesan <siddharthganesan@gmail.com>
2026-08-15 04:51:30 -04:00
Theodore Li 6006870f02 feat(credentials): add v2 credential lifecycle APIs (#6664)
* feat(credentials): add v2 OAuth connection APIs

* fix(credentials): preserve active OAuth connection links

* fix(credentials): bind OAuth links to connection intent

* feat(credentials): complete v2 credential lifecycle

* fix(credentials): make disconnect idempotent

* fix(credentials): stabilize oauth draft retries

* fix(credentials): bind oauth callbacks to drafts

* fix(credentials): fail closed on oauth completion

* fix(credentials): bind shopify completion to oauth state

* fix(credentials): align custom oauth reconnects

* fix(credentials): centralize application authorization

* fix(credentials): keep OAuth draft intent immutable

* fix(credentials): allow renamed reconnect targets

* fix(credentials): close OAuth draft edge cases

* fix(credentials): fail closed without breaking auth

* fix(credentials): preserve migrated route behavior

* feat(credentials): add provider search

* fix(credentials): prevent stale secrets and drafts
2026-08-15 00:47:06 -04:00
Theodore Li ee1fc379a4 fix(tables): allow unbounded v1 row queries (#6713)
* fix(tables): allow unbounded v1 row queries

* fix(tables): drain under-budget queries fully

* fix(tables): bound expanded query metadata

* fix(tables): always return query totals
2026-08-14 21:08:36 -04:00
Waleed 3848f97b4c fix(grafana): validate against the API docs, add data source querying and contact-point CRUD (#6712)
* fix(azure-data-explorer): correct the tags ingestion-property example

The example rendered as tags="[''daily'']" — doubled single quotes from an
escaping slip, which is not valid Kusto. The reference writes a tags list
as tags='["TagA","TagB"]': single outer quotes with the JSON array's own
double quotes inside.

The clause builder already handled that form; only the example text was
wrong. A template literal avoids the escaping entirely, since the metadata
generator reads the source verbatim and would otherwise carry the
backslashes into the description the model sees.

Adds a test asserting the reference's exact multi-property clause
round-trips, including the comma inside the quoted array.

* fix(grafana): correct response contracts, required alert fields, and outbound request hardening

Validated against Grafana's HTTP API reference and, where the docs
contradict themselves, against the Go wire structs.

Response shapes the tools got wrong:
- update_annotation declared an `id` that was always 0; a patch returns only
  a message, so the request's annotation id is echoed and labelled as such
- delete_folder discarded the numeric id Grafana returns and presented an
  input-echoed uid as if it came from the API
- delete_dashboard fabricated `id: 0` / `title: ''` via `||` on absent fields
- the contact-point `provenance` description was inverted: "api" means
  API-managed, empty means it stayed UI-editable

Requests that could not succeed:
- create_alert_rule left noDataState and execErrState unset and invisible to
  the model, but Grafana's validator rejects an empty value outright, so every
  model-driven create failed. Both are now sent with Grafana's own defaults,
  and skipped for recording rules, which take a different validator
- get_data_source routed a numeric input at /api/datasources/:id, which exists
  only behind an off-by-default feature toggle. UID only now
- list_annotations did not trim the dashboard UID, so a padded value matched
  nothing

Outbound hardening on the three proxy routes:
- the service-account token was re-sent to redirect targets; the shared fetch
  only drops it when asked, so stripAuthOnRedirect is now set
- no timeout was passed, leaving two sequential hops at the 5-minute default
- upstream error bodies were interpolated whole into the tool result, putting
  up to 10MB of HTML into logs and traces; now truncated
- UID path segments are URL-encoded so they cannot re-target the request
- update_folder sent both `version` and `overwrite: true`, which Grafana treats
  as alternatives, making the freshly fetched version decorative and silently
  clobbering a concurrent rename
- replaced the `any` casts with narrowed types

Block surface:
- 25 outputs the tools emit were undeclared and so unreferenceable downstream;
  get_data_source had 13 of its 18 unreachable
- `version` was typed string though the dashboard, folder, and data-source
  producers all emit a number
- the dashboard title field was shown only for create, so a dashboard could
  never be renamed through Update Dashboard
- six list outputs were typed json rather than array

* fix(grafana): let the health check report ill-health, and disambiguate block outputs

The data source health check could only ever report health. Grafana answers an
unhealthy source with HTTP 400 carrying the same {status, message} payload as a
healthy one, and the tool framework converts any non-2xx into an opaque tool
error — so the diagnostic the caller actually wants was unreachable. The check
now goes through an internal route that reads the verdict off either status and
reports it as a successful check, while a failure carrying no verdict (missing
data source, bad token, plugin with no health endpoint) stays a real error. The
plugin's `details` payload is surfaced too.

Also on that route, matching the other three: an outbound timeout, redirect
auth stripping, a truncated upstream error, and a URL-encoded UID.

Block output descriptions: ten keys are emitted by several tools with different
meanings and were described for only one producer — `database` meant both a
data source name and a health status, `annotations` both an annotation list and
an alert rule's summary map. Eleven `json` outputs were opaque although the
tools already document their inner fields. All rewritten to name every producer.

Smaller alignment fixes:
- the same EmbeddedContactPoint.settings field was typed `object` in list and
  `json` in create
- list_contact_points mapped non-nullable uid/name/type through `?? null`;
  Grafana returns an empty string, which is what create already assumed
- create_alert_rule sent `orgID`, which Grafana overwrites from the
  authenticated context, and `Number()` on a non-numeric value put NaN -> null
  in the body
- the three update routes declared `output` as required though the auth
  short-circuit omits it, and did not declare the `details` they emit on a
  validation error

* feat(grafana): complete contact-point CRUD, and add folder move and rule-group read

Four operations the integration was missing, taking it to 29.

update_contact_point / delete_contact_point close a real gap: contact points
could be listed and created but never corrected or removed. Two things worth
recording, because the published docs get both wrong:

- both verbs answer 202 with only a message, not the object. The rendered docs
  claim delete returns 204; the current spec and handler both say 202. So the
  UID is echoed from the request, the way delete_folder and update_annotation
  already do
- update is a full replace with no PATCH counterpart, so name, type, and
  settings are all required and the description says so. Omitting
  disableResolveMessage resets it

X-Disable-Provenance is exposed on update only. Its polarity is the opposite of
the alert-rule case: omitting it always succeeds, while sending it against an
API-provisioned contact point is rejected — with 403, not the 409 rules use. It
is not exposed on delete at all, because that handler never reads stored
provenance and the endpoint takes no such parameter.

move_folder reuses get_folder's mapping verbatim — same DTO. It always sends
the parentUid key, since Grafana reads an empty value as "move to the root",
which a conditionally-omitted field could not express.

get_alert_rule_group surfaces the group evaluation interval, the one alerting
knob the per-rule operations cannot reach. It reuses the shared mapAlertRule for
the nested rules, and the interval is documented as an integer of seconds.

* feat(grafana): add data source querying, and ground the skill and templates in real tools

query_data_source closes the largest gap in the integration: 29 tools could
read dashboards, folders, and alert configuration, but none could read a metric
value. It posts to /api/ds/query and returns both the raw response and the
frames flattened into rows.

The flattening is derived from the documented layout rather than any data
source's field names: a frame carries schema.fields[] alongside data.values[],
where values[i] is the whole column for fields[i], so zipping them by position
works for Prometheus, SQL, or anything else with a backend.

A failed query is a 400 by Grafana's own status table, so it stays a tool
error — unlike the health check, where the failure status carries the answer.

That also lets four templates and the review-firing-alerts skill stop promising
things the integration could not do. Three templates assumed a metric-query
tool, which now exists. The fourth, and the skill, assumed live alert instance
state, which the provisioning API never returns — they now derive firing rules
from alert-state annotations, which are documented to carry newState and
prevState, and say so explicitly rather than implying a live snapshot.

Deliberately not added: a tool over /api/prometheus/grafana/api/v1/rules for
live instance state. That endpoint appears on no Grafana HTTP API doc page, its
response is only readable from Go internals and test assertions, and the
instance-level state casing differs from the rule level with no documented
contract. Not something to build an output schema on.

* fix(grafana): declare the two block outputs the earlier fixes introduced

Renaming update_annotation's phantom `id` to `annotationId` and adding
`details` to the health check both created outputs the block never declared, so
neither was referenceable downstream. Caught by re-running the output-coverage
check over both integrations; the block now covers all 64 keys the 30 tools emit.

* fix(grafana): make Update Contact Point actually usable from the block

The new replace operation could never succeed. contactPointType and
contactPointSettings were widened to cover it, but contactPointNameNew was
left create-only — and the update maps `name` from that field, so the required
parameter was never supplied.

disableResolveMessage had the same gap, and it matters more than it looks:
the update is a full replace, so a block-driven update was silently clearing
resolve suppression on every contact point it touched. Both fields are now
shown, and required where the API requires them.

Also states a reason on each intentionally-unconstrained response field —
Zod issue objects, alert query stages, notification settings, recording-rule
config, and data-source health detail are all genuinely opaque, but that was
left implicit.
2026-08-14 15:58:17 -07:00
mzxchandra 5a88ce22d1 feat(ashby): incremental job sync, custom field writes, and application lifecycle ops (#6703)
* feat(tools): add incremental job sync and draft postings to Ashby reads

list_jobs accepts Ashby's syncToken and returns it as nextSyncCursor, so a
scheduled sync costs O(changed reqs) instead of rescanning every req. Ashby only
returns the token once the last page is drained, which the param description
states.

The output is named as a cursor deliberately. It is an opaque resumption marker,
not a credential, so it belongs with nextCursor - and a field literally named
syncToken matches the /^.*token$/i deny-list in redaction and renders as
[REDACTED], which makes an incremental sync unusable since the operator cannot
read the value the next run needs. The wire name stays syncToken.

list_job_postings gains includeUnpublishedJobPostings, plus the posting status
field - without status a caller cannot tell a returned draft from a published
posting, which makes the flag useless.

Also widens the custom field valueLabel type, which MultiValueSelect returns as
an array, for the write operations that follow.

* fix(tools): render Ashby object-shaped API errors readably

Ashby documents two error shapes and uses both. The `errors` array form carries
`{ message, parameter }` objects, which stringified to '[object Object]' and hid
the real cause - including the 403 a key gets when it lacks a module permission.

Also adds the shared pieces the new write operations need: one definition of the
custom field value shape for the read and write paths to agree on, and a
normalizer for Ashby's case-sensitive objectType enum so a model emitting
'candidate' fails here with the allowed values rather than at the API.

* feat(tools): add Ashby custom field writes, delete, source, and anonymize

customField.setValue/setValues are the only way to annotate a job or req, since
Ashby has no job notes and no job tags. Writing null clears a value, so the
annotation is reversible.

Because null clears, every one of these operations requires explicit intent
before it can destroy data. The block's required markers do not cover the agent
path - a model calls the tool directly, so tools.config.params never runs and
validateRequiredParametersAfterMerge skips a param marked not-required:

- set_custom_field_value rejects an absent or blank fieldValue; an explicit null
  still clears
- change_application_source requires unsetSource to clear, and rejects a source
  id and an unset request together, since preferring either one silently
  discards the other. Ashby has no 'leave unchanged' mode, so setting and
  clearing are the only two intents and exactly one must be expressed
- set_custom_field_values rejects an empty array locally rather than relying on
  Ashby to reject it

application.delete needs candidatesDelete, a module permission separate from
candidatesWrite. candidate.anonymize strips PII but leaves the record; Ashby
exposes no candidate deletion endpoint.

* test(tools): cover the new Ashby request and response shapes

Includes a gated live harness (ASHBY_LIVE=1) alongside the mocked tests.
vitest.setup.ts stubs global fetch for every file in the app, so the live file
restores the real implementation and asserts the restore worked - without that
guard the whole suite silently passes against a mock.

* feat(blocks): expose the new Ashby operations in the block

fieldValue is polymorphic (boolean, number, string, array, object, null), so it
decodes structured input and otherwise passes text through. The decoding is
deliberately narrow rather than a blanket JSON.parse, which corrupts real text:
1e999 becomes Infinity and serializes back out as null, which CLEARS the field;
a long numeric id loses precision past 2^53; and prose starting with { turns into
an object. Only the literal keywords, {, [ or " prefixes, and exactly
round-tripping numbers decode.

fieldValue carries no wand generationType: json-object forces braces and
json-array forces brackets, and both would wrap a value that must stay bare.
fieldValues, whose contract really is an array, uses json-array.

Setting and clearing an application source are mutually exclusive, so the Source
ID field is conditioned off while the clear switch is on and the params mapping
sends only the intent the switch selects. A value typed before the switch was
flipped cannot reach the tool and surface as an error with no visible cause.

* docs(ashby): document the new operations, permissions, and limitations

Ashby scopes permissions per module and they fail at runtime, not build time, so
the block docs now carry the permission table. Also records the hard API limits
worth designing around: no note or tag on a job, no pagination on
jobPosting.list, and no delete for jobs, candidates, or custom field definitions.

* fix(blocks): stop a stale create-path source id leaking into a source change

The executor merges { ...inputs, ...transformedParams }, so any key the params
mapping leaves unset inherits whatever inputs held. The shared create-path
sourceId subblock reaches inputs even on change_application_source: it is mode
'advanced', and the serializer includes an advanced subblock whenever its value
is non-empty without ever evaluating its condition (serializer/index.ts).

So a source id typed while on Create Application survived into a source change.
With both fields blank it silently attributed a source nobody asked for, and
with the clear switch on it collided with the unset request and failed with no
visible cause, because the field producing it is hidden in that state.

sourceId is now always assigned for this operation rather than conditionally,
so it can never inherit. The regression test asserts the merged result rather
than the mapping alone, since the gap between them is where the bug lived.
2026-08-14 14:37:23 -07:00
Waleed 5bb59f08ee feat(connectors): add 9 knowledge base connectors (#6699)
* feat(connectors): add 9 knowledge base connectors

Box, Zoho Desk, PagerDuty, Trello, Microsoft Excel, Google Slides, Google
Vault, Mintlify, and SFTP. Selected by intersecting the published connector
catalogs of Glean, Onyx, Dust, Vectara, Writer, Guru, Elastic, Microsoft 365
Copilot, Notion AI, Unstructured, and Airbyte against services that already
ship a Sim block, so OAuth providers, credentials, and icons are reused. Box
was the largest gap, appearing in 7-8 of ~10 catalogs.

Every connector was validated against live provider documentation twice, the
second pass treating the first pass's conclusions as unproven. Notable
correctness work that came out of that:

Listing truncation. The sync engine hard-deletes documents past a cap that is
not flagged with `listingCapped`, and five connectors had a path there — an
empty Mintlify discovery, Zoho Desk's exact-multiple default caps, Trello's
archived lists and 1000-card ceiling, a Google Vault cursor bailout, and a
PagerDuty stalled page. The engine also gained a backstop: an empty or
collapsed listing blocks deletion reconciliation until the same observation
repeats on a consecutive sync, reconstructed from existing sync-log counters
so no migration is needed.

API alignment. `desk.zoho.ca` does not resolve (Canada is
`desk.zohocloud.ca`, and Singapore and UAE were missing); `modifiedTime` is
absent from Zoho's ticket list projection, so every ticket re-embedded on
every sync; Trello's `dateLastActivity` is documented to miss some edits;
PagerDuty's 10,000-record ceiling bounds `offset + limit`, not offset; Excel
indexed dates as raw serial numbers while Google Sheets renders them; Google
Vault truncated at roughly 249 matters.

Security. SFTP followed symlinks in `getDocument` and composed unchecked
server-supplied filenames into paths; it now also supports optional host-key
fingerprint verification, which runs during key exchange before any password
is sent. Trello interpolated user-supplied board ids into URL paths. Google
Vault is narrowed to `ediscovery.readonly`. `getDataverseBaseUrl` accepted
any host while attaching a bearer token, and is pinned to Microsoft's
Dataverse domains — pre-existing shipped code, fixed here.

Also adds `ConnectorAuthConfig.optional` so a public source can be configured
without inventing an API key, and teaches the scope check that a granted
read-write scope satisfies a required `.readonly` sibling.

Microsoft Dataverse was built and then removed: its OAuth cannot complete
consent. Dataverse requires a per-environment resource URI, the provider
declares a static `https://dynamics.microsoft.com/user_impersonation` that is
not an Entra Application ID URI, and the environment URL is only collected
after the credential exists. That predates this change and also affects the
12 shipped Dataverse tools.

* fix(dataverse): strip the bearer token when a request redirects

The host allowlist added alongside the connector work only constrains the
initial destination. `secureFetchWithPinnedIP` follows redirects and keeps the
`Authorization` header unless a tool opts out, so a redirect away from an
allowed Dataverse origin would forward the caller's OAuth token to whatever
host answers. Dataverse redirects in normal operation — file downloads hand
back a signed storage URL, and environment hosts move between regional
origins — so this is reachable without a compromised environment URL.

Sets `stripAuthOnRedirect` on all 18 Dataverse tools, matching the existing
GitHub job-logs and Windchill precedent.

* fix(connectors): address review findings on listing and hashing

- microsoft-excel: `fetchWorksheets` read only the first Graph page and never
  followed `@odata.nextLink`. A workbook with more sheets than fit in one page
  dropped the remainder from the listing without setting `listingCapped`, so
  the sync engine reconciled those documents away as deleted. The walk now
  pages, bounded by MAX_WORKSHEETS, and only follows a nextLink that stays on
  the Graph origin, since the link is server-supplied and carries the token.

- google-slides: the listing `contentHash` covered only the file id and
  modified time, so toggling the speaker-notes option left every stored hash
  matching and no presentation was ever re-hydrated with the new scope. The
  setting is now part of the hash, in the single shared stub builder so the
  list and hydrate paths stay identical.

- mintlify: `pathPrefix` filtered with a bare `startsWith`, so a prefix of
  `/guides` also matched a sibling like `/guides-archive`. It now shares the
  `/`-boundary rule `withinBasePath` already used, extracted as `isUnderPath`.

* fix(connectors): list newest first in zoho desk, accept a trailing slash prefix

- zoho-desk: `sortBy: 'createdTime'` is ascending — Zoho denotes descending
  with a `-` prefix — so the default 500-record caps kept the oldest tickets
  and articles and recent ones were never listed. Because the cap sets
  listingCapped, that stale tail could not reconcile away either. Now sorts
  `-createdTime`. Still ordering on createdTime rather than modifiedTime, so
  rows do not reshuffle mid-walk.

- mintlify: `resolvePathPrefix` kept a trailing slash while `isUnderPath`
  accepts an exact match or `prefix + '/'`, so `/guides/` matched neither
  `/guides` nor `/guides/intro` and the source synced nothing. A regression
  from the previous round, which replaced a bare `startsWith`. The prefix is
  now normalized before comparison.

* fix(dataverse): strip the bearer token on the upload route's own redirect

`upload_file` posts to an internal route rather than calling Dataverse
directly, so the tool-level `stripAuthOnRedirect` added in 903c94e9 only
covers the same-origin hop into that route. The route's own outbound PATCH
carries the caller's OAuth token and left redirect stripping at its default,
so a redirect to a signed storage host — which is exactly how Dataverse
serves file operations — would have handed that host a reusable credential.

The other 17 tools build the Dataverse URL directly, so the tool-level flag
already covers them.
2026-08-14 14:14:43 -07:00
Vikhyath MondretiandClaude Opus 5 237f973a11 fix(condition): stop a secret value from breaking or forging a condition (#6705)
Condition expressions pasted every environment variable value into the
expression as source. Block references in the same expression go through a
proper escape and get quoted; env vars went through neither. That left three
defects:

- A bare string placeholder was a SyntaxError. `{{NAME}} === 'alice'` resolved
  to `alice === 'alice'`, so the form the Function block docs recommend could
  not be used here at all.
- Ordinary data broke the block. An apostrophe (`O'Brien`) or a newline in a
  legitimate value produced unparseable source and failed the run.
- The quoted form was injectable. A value of `x' || true || '` turned
  `'{{NAME}}' === 'bob'` into `'x' || true || '' === 'bob'`, forging a true
  branch out of a comparison that should be false.

Inline only structurally inert literals — numbers, booleans, and null, with
optional space/tab padding. Every other value keeps its `{{NAME}}` placeholder
and is bound as a string by the execution-boundary compiler, the same one
Function blocks and Custom Tools already use.

Legacy outcomes are preserved. `{{COUNT}} === 3` and `{{ENABLED}} === true`
still compare as literals, and an embedded `"Bearer {{API_KEY}}"` still
compares equal — now via compiled concatenation rather than a pasted value.
Padding is admitted rather than trimmed so the inlined text stays
byte-identical to the stored value, which is what keeps a padded number
correct both bare and quoted.

A resolved secret also no longer travels to the execution boundary inside the
condition source.

The one deliberate behavior change: a value whose text is itself a quoted JS
literal (a secret stored as `'foo'`, a plausible workaround for the bare-string
SyntaxError) now compares as the 5-character string rather than as source.
That form is the injectable one, so it cannot be kept.

Docs: state the placeholder type contract, which was described mechanically but
never in terms of what a reader gets. `{{KEY}}` in Function and Custom Tool code
always evaluates to a string, so a bare `if ({{FLAG}})` is always true and a list
has to be stored as JSON. This is what a customer hit after the resolver lift in
 #6247 moved Function blocks off source inlining.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:50:56 -07:00
Waleed a7115e87ee feat(integrations): add Azure Data Explorer (#6701)
* feat(integrations): add Azure Data Explorer

Add a 14-operation Azure Data Explorer (Kusto) integration covering KQL
queries, schema and metadata discovery, table management, inline and
query-sourced ingestion, ingestion-failure triage, and arbitrary
management commands.

Authentication uses a Microsoft Entra service principal through an
internal proxy route, since the Kusto token audience is per-cluster and
cannot be expressed as a static-scope OAuth provider.

* fix(azure-data-explorer): only read partial-failure status from the QueryStatus table

Scanning every returned table for Severity and StatusDescription columns
misread an ordinary query as a failed request whenever the user's own
result selected columns of those names — a common shape for a log table.

Failure detection now consults only the table the response's table of
contents names as QueryStatus, and primary-result selection reuses the
same index instead of re-reading it.

* fix(azure-data-explorer): keep the Show Operations and Show Table Details cards from painting empty

check:canvas-sentences flagged the Show Operations sentence: it anchored
`core` on operationId, which is an advanced-mode optional field, so an
untouched card resolved to nothing and painted empty. Show Table Details
had the same shape in milder form — table is optional there, since
omitting it describes every table, leaving a dangling preposition.

Both now lead with literal copy and treat their field as an optional
refinement. Also simplifies the primary-table condition to a single
`!= null` check.

* fix(azure-data-explorer): authenticate sovereign clusters against their own Entra authority

The cluster allowlist accepted Azure China and US Government hosts, but
every token request went to login.microsoftonline.com. Those clouds are
isolated instances with their own Entra endpoints, so a sovereign cluster
passed URI validation and then could never obtain a token.

Each Kusto service domain is now declared alongside the authority that
issues tokens for it, so the two cannot drift apart, and the authority is
part of the token cache key.

* improvement(azure-data-explorer): warn that ingest-from-query matches columns by position

Kusto aligns an ingested query result to the target table on column type
and order, never on column name, so a query projecting the right columns
in the wrong order lands data in the wrong columns without erroring.

Surfaces that in the tool description and param the model reads, in the
wand prompt that generates the query, in the rollup skill's steps, and in
the docs. Also verifies the target schema first rather than after.

* chore(azure-data-explorer): drop the unsourced kustomfa host from the cluster allowlist

Every other entry traces to a Microsoft reference — the Kusto
connection-string doc, the national-cloud endpoint tables, and the Fabric
KQL-database REST reference. kustomfa.windows.net does not, and the
connection-string doc states the trust boundary as hostnames ending in
kusto.windows.net.

An allowlist should only hold hosts we can justify, so this drops it and
records the sourcing standard for anything added later.

* fix(azure-data-explorer): handle commas inside quoted properties and empty extent IDs

Two defects in the shared command helpers:

buildWithClause split the property list on every comma before validating,
so a value that legally contains one — a docstring sentence, or a tags
array with more than one entry — was torn in half and rejected. Splitting
is now quote-aware, and an unterminated quote is rejected outright rather
than swallowing the rest of the clause.

transformColumnListResponse dropped empty strings, but `.ingest inline`
reports "no data shards were generated" as a single record carrying an
empty extent ID. A no-op load therefore looked like a missing column
instead of an empty result. Only non-strings are skipped now.
2026-08-14 12:27:13 -07:00
Waleed 1d342722ad feat(rabbitmq): add RabbitMQ integration (#6700)
* feat(rabbitmq): add RabbitMQ integration

* fix(rabbitmq): strip auth on redirect, require https, and bound the retrieval response

* fix(rabbitmq): reserve message metadata in the retrieval response budget
2026-08-14 12:05:01 -07:00
Waleed 7f64d5e600 perf(tables): stop a table write refetching every loaded page in the tab that made it (#6698) 2026-08-14 11:13:53 -07:00
Vikhyath Mondreti fa394e5e07 fix(execution): resolve secrets against the acting principal, not the workflow owner (#6690)
* fix(execution): resolve secrets against the acting principal, not the workflow owner

* fix(execution): resolve anonymous public-API runs as the workspace billing account

* fix(execution): propagate run identity across dispatch paths and scope public runs to workspace secrets
2026-08-13 18:50:15 -07:00
1424809614 feat(netsuite): add Oracle NetSuite integration (#6476)
* revise netsuite integration

* fix(netsuite): align selector route with snowflake

* test(netsuite): remove selector route coverage

* test(netsuite): align coverage with snowflake

* fix(netsuite): complete integration validation

* refactor(netsuite): align integration with codebase patterns

* test(netsuite): correct async job citation

* fix(netsuite): address final audit findings

* fix(netsuite): surface upsert/transform Location, relax task link check

Oracle documents the Location response header for create and update, and
both tools already require it. Upsert and transform also produce a record
but Oracle documents no response headers for either, so they dropped the
header entirely and the new record's ID was unreachable.

Add a `resource-optional` location mode that captures Location when
NetSuite sends it and never fails when it is absent, and wire it to
upsert and transform along with their tool and block outputs.

Async task discovery rejected the whole response if any task link carried
a rel other than `self`, collapsing the picker into a 502. Oracle
documents a `self` link per task but never guarantees it is the only one,
so skip other relationships and fail only when no self link exists.

Also use the shared `truncate` helper in the error sanitizer per the
repo convention instead of an inline slice.

* fix(netsuite): validate SuiteQL pages against their documented shape

The shared collection-page validator required links, items, count,
hasMore, offset, and totalResults on every 200, and a missing field turns
a successful call into a reported failure.

Oracle documents all six for record collections and SuiteAnalytics
dataset pages, but its SuiteQL reference lists only links, count, offset,
totalResults, and items. A documented SuiteQL response that omits hasMore
would therefore have been rejected.

Split out a suiteql-page validator that requires the five documented
SuiteQL fields and type-checks hasMore only when the account returns it.
Record collections and dataset pages keep requiring all six.

* chore(netsuite): regenerate tool metadata after rebase on staging

The rebase conflicted only in the generated tool-id, tool-metadata, and
tool-output artifacts, which NetSuite and the newly landed LogRocket
integration both extend. Regenerated from the merged registries: the
result is staging's catalog plus the 27 NetSuite tools, with LogRocket's
entries intact and no other tool changed.

---------

Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
Co-authored-by: Bill Leoutsakos <billleoutsakos@Mac.localdomain>
Co-authored-by: Waleed Latif <walif6@gmail.com>
2026-08-13 15:47:03 -07:00
Waleed 58b5ee9337 feat(logrocket): add LogRocket integration (#6678)
* feat(logrocket): add LogRocket integration

* improvement(logrocket): fail loudly on a non-numeric identify timestamp and cover releases in the catalog copy

* fix(logrocket): require a highlights identity and treat pagination cursors as opaque strings

* fix(logrocket): trim request fields so whitespace-only input fails validation

* fix(logrocket): declare the release version in the block inputs map
2026-08-13 15:01:22 -07:00
Waleed 6de8ba2504 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 — 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.

* fix(v2): bind the audit and billing window bounds by instant

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.

* fix(v2): drop an inert field from the document tag-filter scope

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.

* refactor(v2): derive the body 413 from the contract in every document

Two mechanisms encoded one rule. `withRequestBodyErrors` derived the 413 from
`route.contract.body` for the tables document, while the resources document
hand-picked RESOURCE_BODY_ERRORS / RESOURCE_CONFLICT_BODY_ERRORS at nine
sites. The cross-document sweep caught drift, but only after the fact: a new
body operation that forgot the _BODY_ variant published a reachable 413
nowhere until a test failed.

Hoists the mapper to openapi/shared.ts and applies it in both documents, so
the rule is derived rather than remembered. The two hand-picked sets and
their shared TSDoc are gone.

Regenerating all seven specs produces zero drift, which is the proof the two
mechanisms were computing the same thing.

* refactor(v2): collapse duplicated cursor and validation mechanisms, drop dead exports

One rule, one implementation:

- `parseRequest` hand-inlined the "caller envelope or default" validation-error
  projection four times. Extract `projectValidationError` and route all four
  through it.
- Nine keyset lists hand-rolled the `present` half of the cursor pair that
  `readSortedCursor` already owns the read half of. Add the symmetric
  `writeSortedCursor` and use it everywhere.
- `GET /workflows/{id}/runs` re-derived `readSortedCursor`'s invalid/refiltered
  ladder from `decodeSortedCursor`; it now calls the shared reader and keeps
  only the key-arity check that is genuinely its own.

Files and exports that no longer earn their place:

- Inline `credentials/utils.ts` into its single consumer.
- Delete symbols with zero references repo-wide: `v2CustomToolWriteError`,
  `secretCredentialTypes`, `v2CursorList`, `v2WorkspaceAccessError`,
  `resolveFolderPathIdentity`, `folderPathForId`, `v2FolderPathMutationError`,
  and seven of twelve `tables/utils.ts` exports.
- Drop `export` from symbols used only inside their own module.

No behavior change; every response body and error message is byte-identical.

* docs(v2): cut duplicated and non-load-bearing comment prose

Five rationales were written three to five times each by parallel agents
that could not see one another. Each now has one home and the rest point
at it:

- HEAD existence oracle -> the headSafe option on defineV2JsonRoute
- cursor query binding -> cursorScopeKey in lib/api/cursor-binding.ts
- storage-key prefix budget -> buildStorageKeySegment
- NUL / U+0000 -> the containsNulCharacter predicate
- blank and duplicate query values -> their own implementations

Also drops changelog-in-source (prose narrating what the code used to
do), anchorless module headers attached to no declaration, rejected-
alternative essays, and @param tags that only restate the signature.

Comments only: the diff contains no executable-code change.

* fix(v2): name the undecodable-cursor failure on the two sortless lists

GET /workflows/{id}/versions and GET /workspaces/{id}/members threw a bare
'Invalid cursor' literal where every other v2 list uses a shared constant.
The right one is UNREADABLE_CURSOR_MESSAGE, not INVALID_CURSOR_MESSAGE:
both lists take only limit and cursor, so naming sortBy/sortOrder would
answer one 400 with advice that earns a second.

Their missing filter scope is correct and stays. Neither contract accepts a
filter — v2PaginationFields is the whole query — so there is nothing to bind,
and limit is excluded from a scope by design.

Pins the message on the versions route, verified to fail against the literal.

* test(openapi): give the determinism check a chosen timeout

`serializes all documents deterministically` serializes all seven published
documents twice — roughly 2MB of JSON — under vitest's 5s default, which is
not a budget anyone picked for it. The published specs grew 3.3% on this
branch (961KB -> 993KB) from richer descriptions, which is far too small to
move a comfortable test and is enough to tip one already sitting just under
the cap. Measured at 5.1s in isolation with nothing else running.

Raises it to 30s for the openapi suite rather than trimming a real assertion.

* fix(v2): make the NUL path scan linear, and force a write surface to choose

Two findings from a simplify pass, both in code this branch added.

findNulBytePath copied `[...path, key]` per child, which is O(nodes x depth).
A caller controls that depth directly: v2 row cell values are `z.unknown()`,
so nesting passes Zod untouched and reaches the scan. Measured on Node 22 --
JSON.parse accepts a 200KB body nested 100k deep in 9.8ms, and the scan then
blocked the event loop for 27.7s. Frames now carry a parent link and the path
is materialized once, for the node actually reported: 27.7s -> 5ms, with
byte-identical paths across nested arrays, records, NUL keys and clean input.
The always-run first pass drops Object.entries for Object.keys, which halves
its cost on large bodies by not allocating a pair array per object.

`strictWrite` was optional with the lenient default, so a v2 write route added
tomorrow would silently inherit first-party behavior -- unknown column dropped
under a 201, uncoercible cell stored as null -- defended by nothing but five
copies of a literal. It is now required on the five write-shaped inputs, so
omission is a compile error. The type-checker named every caller: the five v2
routes already passed true, and the three Copilot sites now say false
explicitly, which is the behavior they already had.

* refactor(v2): apply the body-413 mapper to every OpenAPI document

The earlier unification wired withRequestBodyErrors into two of the five
content documents and left files-audit, knowledge and workflows hand-writing
the entry, so the helper's own claim that "a new body route cannot forget it"
held on 40% of the surface while reading as global.

Regenerating all seven specs produces zero drift, which is the useful proof:
the mapper agrees with every hand-written entry today, so the gap was never a
missing 413 — it was a missing guarantee for the next body route added to
those three documents.

The existing hand-written entries stay. The mapper is one-directional and
several bodyless folder reads publish 413 for the folder-tree ceiling, so
stripping them by hand would risk removing one the mapper cannot restore.

* refactor(v2): fold the v2 validation renderer into the shared parse defaults

V2_PARSE_DEFAULTS calls itself "the parse failures every v2 route renders the
same way", but the option deciding how a v2 validation failure renders sat
outside it and was re-stated at seven sites. A raw route that spread the
defaults and stopped emitted a non-v2 error envelope.

Removes the redundant line from the five sites that only restated it. The two
builders keep theirs: theirs sits after `...options.parseOptions`, so it is a
deliberate override that stops a caller swapping the v2 renderer, not a copy.

Also adopts the mandated `filterUndefined` in cursorScopeKey in place of the
Object.fromEntries/Object.entries form CLAUDE.md forbids, and collapses a
one-element `as const` array plus a Math.max over it to the single `.length`
they computed.

* test(persistence): keep the wire round trip without tripping the utils audit

check:utils forbids `JSON.parse(JSON.stringify(...))` and points at
structuredClone, which is right for a deep clone and wrong here: this test
exists to prove the schema accepts a `deployedAt` that arrived over HTTP as a
string as well as an in-process `Date`. structuredClone preserves the `Date`,
so adopting it would leave the test asserting nothing about the wire form.

Splits the serialize and the parse into two statements. The round trip stays
lossy — verified `JSON.parse(JSON.stringify(...))` yields a string where
structuredClone yields a Date — and the pattern the audit matches is gone.

Arrived from staging in #6660, so `check:audits` is red on origin/staging too,
not only here.
2026-08-13 10:52:20 -07:00
Waleed 1fa40b8118 feat(v2): complete and align the v2 API surface (#6643)
* fix(v2): close four validation holes in the logs and billing surfaces

Each of these answered a caller-supplied value with a 500 or a silently
wrong result instead of a 400.

- `GET /api/v2/logs` accepted any string as `startDate`/`endDate`. The
  route constructs a `Date` from it, so `?startDate=abc` reached the
  driver's timestamp mapper as an `Invalid Date` and 500'd. Both bounds
  now carry `.datetime()`, matching the sibling run list so one timestamp
  works on both collections. This narrows the accepted set: a date without
  a time and an offset-bearing timestamp are now rejected, and the field
  descriptions say "UTC ISO 8601" rather than overpromising "ISO 8601".

- `v2BillingStatusQuerySchema` was the only non-strict query schema in its
  family, so a mis-cased `workspaceID` was stripped and the caller got
  account-scope billing in place of the workspace scope it asked for — a
  wrong answer about money, served as a 200.

- An unresolvable `cursor` on `/api/v2/billing/logs` applied no cursor
  condition and restarted the sequence at page 1 while still reporting
  `hasMore`, so a pager holding a cursor across a deploy loops over the
  first page and counts the same credits on every lap. It is now a 400.
  The message does not reuse `INVALID_CURSOR_MESSAGE`, which names
  `sortBy`/`sortOrder` params this collection does not accept.

- The logs `status` field disagrees with the run resources for the same
  run: the run projection overlays `paused` from `paused_executions`,
  so an ordinary human-in-the-loop pause reads `paused` there and
  `pending` here. Reconciling would mean joining `paused_executions` in
  this read and silently moving live runs between two buckets of a
  shipped field, so the divergence is documented on the contract instead.

* feat(v2): expose the MCP tool plane and page the MCP server list

Registering an MCP server through v2 dead-ended: nothing on the public
surface ever ran tool discovery, so connectionStatus, toolCount, lastError,
and lastToolsRefresh stayed at their registration defaults and there was no
way to read a server's tools without opening the UI.

Adds GET /api/v2/mcp-servers/{id}/tools over a thin use case composed from
the existing mcp_servers.tools.discover operation, resolveServerContext, and
mcpService.discoverServerTools. It is personal-API-key-only — discovery
resolves the acting user's own OAuth credentials, which a workspace key
cannot supply — and the contract says so rather than letting callers meet an
unexplained 403. Discovery failures are classified instead of collapsing
into a 500: an unreachable or cooling-down server is a retryable 503, a
stale OAuth grant is a 401.

Also pages GET /api/v2/mcp-servers. It was the one unbounded list on the v2
surface, classified full-set on a bounded-by-construction rationale that
only holds for folder lists; nothing caps how many servers a workspace
registers.

* feat(v2/tables): strict row bodies, a filtered row count, and round-trippable required columns

Three tables gaps from the v2 capability evaluation.

Strictness. Every v2 tables request body is now `.strict()`. The row family
was the whole hole: `POST /query` sent v1's `filter` key answered 200 with a
fully unfiltered page, because Zod strips unknown keys unless told not to. The
same laxity covered the row create/update/delete/upsert/find bodies, the
run and cancel-runs bodies, the enrichment body, and — outside the row family
but the same class — the column delete, view create/update, and export bodies.
A contract sweep now walks every body-bearing tables contract and fails if one
of them stops rejecting an unrecognized key.

Filtered row count. `POST /api/v2/tables/{tableId}/query/count` answers the
question v1's `includeTotal`/`totalCount` answered and the `{data, nextCursor}`
envelope has nowhere to put: how many rows a predicate matches. It binds the
existing `queryTableRows` use case with `includeTotal: true, limit: 1` — no new
domain logic and the same `tables.rows.query` read policy. The use case types
`totalCount` as nullable because paged callers can decline it; this route always
asks for it, so a null is treated as a broken invariant rather than presented as
a fabricated zero.

Required columns. `required` is accepted on create-table, add-column, and
update-column, matching v1. v2 emitted the flag on every read while stripping it
from every write, so a column could not round-trip. Enforcement was already
complete: turning it on over rows with null, missing, or empty cells is rejected
by the domain.

* test(skills): pin the workspace-API-key split as structural, not accidental

A workspace API key can create a skill it can then never update or delete,
which no sibling resource does — so the asymmetry reads like an oversight
worth widening. It is not. Skill edits are authorized by the per-skill
editor row belonging to the acting user, which is why update/upsert/delete
declare a 'read' floor rather than 'write': workspace role is not the
authority. A workspace key carries no user subject, so allowing one replaces
a 403 with an unclassified PrincipalSubjectUserRequiredError that the v2
surface renders as a caller-reachable 500.

Records the reason on the registry and pins it, so the next reader finds the
argument instead of flipping the flag.

* feat(v2): read deployment state, and undo a file delete

Two v2 reads that existed only as a side effect of a mutation.

`GET /api/v2/workflows/{id}/deployment` publishes the state the deploy,
undeploy, and rollback responses carry, plus `needsRedeployment` — which
those responses structurally cannot carry, because they answer at the
moment the draft and the live version are equal. A caller that lost the
mutation response, or that polls from another process, had no way to ask.
Reuses `readWorkflowDeploymentStatus` behind `workflows.read`, the same
use case the internal status and deploy GETs already adapt.

`DELETE /api/v2/files/{fileId}` was a soft delete with no way to see what
it archived and no way to reverse it. `GET /api/v2/files?scope=archived`
pages the archived set and `deletedAt` on the file resource dates each
one; `POST /api/v2/files/{fileId}/restore` reverses the delete through
the existing `files.restore` operation. Restore is not a pure undo — it
returns the file to the root and renames it on a collision — so the use
case now reads the file back and both the response and the OpenAPI
description say what actually came back rather than what was deleted.

`scope=all` is rejected on the list for the reason the internal contract
already gives: it drops the `deleted_at` predicate and cannot use the
partial index. `scope=archived` combined with `folderPath` 404s when the
containing folder was archived too, which the contract documents.

* fix(v2): keep the unresolvable-cursor rejection a 400 on every surface

The cursor rejection lived in shared billing core but was an OrchestrationError
only, which the session-only GET /api/users/me/usage-logs cannot project: that
route is raw withRouteHandler and readTypedError matches instanceof HttpError,
so any signed-in caller typing ?cursor=x got a 500. UnknownUsageCursorError is
an HttpError carrying the OrchestrationError as its cause, so the v2 route still
renders BAD_REQUEST off the cause chain and the internal route answers 400.

Also closes the other half of the run-list parity: an inverted window on
GET /api/v2/logs is now a 400 instead of a silently empty page.

* fix(v2/tables): sweep union bodies per member and name the shapes on a rows 400

Review follow-ups on the strictness work.

The sweep was vacuous on the one union body it covers. Parsing
`{ notAContractField: true }` against `v2CreateTableRowsBodySchema` and looking
for `unrecognized_keys` anywhere in the issue tree is satisfied by either member
alone, so dropping `.strict()` from the single-row branch shipped green —
reproduced, 36/36 passing with the regression in place. The sweep now flattens a
union body into its members and asserts each one separately; removing `.strict()`
from either branch now fails a case that names it.

`POST /rows` answered an unknown key with `Invalid input`, the exact message the
v2 conventions name as failing the actionable-error rule, because a union
surfaces `invalid_union` first. The union now carries a message naming both
accepted shapes; the per-member failures still ride along in `details`.

Two TSDoc corrections. The `required` docstring claimed the domain rejects
turning the flag on over rows with empty cells — true of the update path, false
of add-column, which applies the flag as given (the same shape `unique` already
had here). And `.strict()` binds the top level only, so the view `config` object
and the shared sort-spec elements still strip unknown keys; both docstrings now
say so instead of implying full coverage.

* fix(v2): classify MCP discovery failures by type, not by substring

The tool-discovery error policy consumed categorizeError's status, whose
fallback is a substring match on the upstream message. Three consequences,
all caller-visible:

- A ZodError from the builder's own response `.parse` contains `invalid_type`,
  so a Sim-side response-schema defect answered 400 "Invalid request
  parameters" and suppressed the builder's 500 and its unhandled-error log.
- An upstream `Invalid params` or `not found` became the caller's 400/404 on a
  request the contract had already validated.
- A stale OAuth grant to the third-party server answered 401, the status this
  surface reserves for a missing or invalid Sim API key, so a client would
  rotate a credential that was never the problem.

The policy now dispatches on the MCP error families and returns null for
anything else. Reauthorization is a 409 carrying
`details.code: MCP_SERVER_REAUTHORIZATION_REQUIRED`; an unreachable, slow, or
cooling-down server is a 503 with a constant message.

Also: widen the shared server path-param description now that it covers tool
listing, map the list query explicitly so no undeclared `cursor` reaches the
use-case input, and document the endpoint's write side effects.

* merge: bring in the MCP tool plane workstream

* feat(v2): make knowledge tags usable and let documents be updated

v2 accepted tag slots on upload and filtered search by tag display name,
but no response ever returned a tag value and nothing listed the
vocabulary, so a shipped feature dead-ended in the public API. A document
that failed processing could only be deleted and re-uploaded, and
retiring 500 documents cost 500 requests.

- GET /api/v2/knowledge/{id}/tags returns the vocabulary (display name,
  slot, field type) as a full-set list.
- Document list and detail responses carry `tags`, keyed by display name
  exactly as search keys its result metadata. Writes stay slot-keyed; the
  tags endpoint is the mapping and the contract documents the split.
- PATCH /api/v2/knowledge/{id}/documents/{documentId} renames, enables,
  disables, retags, or requeues processing. Derived indexing state is not
  writable: asserting `processingStatus` on an unindexed document would
  corrupt search. A retry may not ride along with field updates.
- PATCH /api/v2/knowledge/{id}/documents bulk-enables or bulk-disables.
  Bulk delete is deliberately absent — that operation records no semantic
  audit, and a public bulk delete would empty a knowledge base leaving no
  DOCUMENT_DELETED entries.
- The document list accepts the same name-based `tagFilters` as search;
  the name-to-slot resolver moves out of search into a shared helper, and
  the filters are stamped into the offset cursor scope so a replayed
  cursor cannot cross a filter change.
- Search accepts `rerankerEnabled`, `rerankerModel`, `rerankerInputCount`
  and returns `rerankerScore`; `rerankerApiKey` and `skipUsageBilling`
  stay unexposed. Every result now names its `knowledgeBaseId`.

knowledge.tags.list flips from workspaceApiKey 'deny' to 'allow' (and
gains the workspace_api_key principal kind) so it matches the sibling
reads knowledge.documents.list / read / search. The vocabulary is
required input for two operations a workspace key can already perform.
Every tag write stays human-delegated.

* fix(v2): name every 403 cause, unfork boolean params, close nested strictness holes

Four cross-cutting consistency gaps on the v2 public surface.

**403s now carry a machine-readable cause.** The conventions skill mandated
`error.details.code` on 403 and nothing emitted one, so a client had to
string-match prose to tell "raise this member's role" from "this workspace
refuses personal keys" from "buy an enterprise plan" — four different
remedies behind one status, and every message reword a silent break. The
vocabulary is a closed set, `FORBIDDEN_DETAIL_CODES`, with a `Record` of
descriptions beside it that the generated OpenAPI 403 description is built
from, so a code cannot reach the wire unpublished. Refusals throw
`ForbiddenOperationError` in the domain and `v2CaughtOrchestrationError` —
the function every v2 error policy falls through to — attaches the code, so a
route cannot forget it. The audit-log resolver distinguished four causes and
collapsed them into one; it now names each.

Cross-tenant refusals deliberately get no code: they are concealed as 404 and
naming their cause would hand back the existence signal the concealment
withholds.

**Two boolean query params rejoin the majority.** `?includeDeparted` and
`?includeOutput` were `'true'`/`'false'` string enums inherited from the
internal shapes they reused, while four sibling params were real booleans.
Both move to `booleanQueryFlagSchema`, which still coerces both strings — a
strict widening, so an existing caller is unaffected, and the spec stops
telling callers to send a string.

**Two nested strictness holes close.** `.strict()` binds the top level only,
so `sort: [{ field, direction, nulls: 'last' }]` was answered 200 with the
null-ordering request dropped, and an unknown key inside a saved view's
`config` was accepted and discarded — the headline `filter` bug one level
down. `sortSpecSchema`'s element and both view-config schemas are now strict.
Safe on the read side because `normalizeStoredViewConfig` projects the
schemaless stored blob onto the declared keys first, so a legacy row cannot
turn into a 500.

The two sort dialects stay as they are. `/logs` and `/workflows/{id}/runs`
have one sortable column, so there is no `sortBy` to pair with; renaming
`order` breaks every caller and an alias is a second spelling of one thing
with undefined precedence. Both contracts and the skill now state the rule.

* style: format the files the workspace-scoped lint gate does not reach

`turbo run lint:check` runs `biome check .` per workspace, so `scripts/` at the
repo root is outside the graph and four changed files were unformatted — one of
them a merge artifact from reconciling the route baseline across branches.

* fix(v2): collapse the four knowledge document projections onto one null-tolerant summary

Extracts toV2DocumentSummary in app/api/v2/knowledge/utils.ts and composes the
list, upload-acknowledgement and detail presenters from it. toV2TaggedDocument
serialized uploadedAt with a bare .toISOString(), so a document with no upload
timestamp threw where every sibling returned null and the contract declares the
field nullable.

Also consolidates the two Zod strictness walkers onto one shared introspection
helper that unwraps wrappers and expands unions, closing the hole where a
union-shaped schema answered null and was skipped by the pagination sweep.

* fix(v2): stop HEAD driving MCP discovery, and unbreak the updatedAt keyset page

B1: Next aliases HEAD onto GET, which RFC 9110 permits only because GET is safe.
The MCP tool-discovery GET is not: it opens a live connection to the registered
endpoint and writes the outcome onto the server row. The v2 JSON builder gains a
headSafe option, default true, and the discovery route declares itself unsafe —
a HEAD is authenticated and rate-limited, then answered bodiless.

B2: a discovery status write stamped updatedAt, which this branch added as a
keyset sort, so any concurrent discovery duplicated and skipped servers across a
caller's pages. Discovery liveness already has lastConnected, lastToolsRefresh,
lastError and statusConfig.

B4: a public refresh now skips the positive cache but keeps the failure cooldown,
so it cannot be used to drive a connection attempt per request at a failing
endpoint. An explicit user action on their own server keeps the full bypass.

B6: the consecutive-failure counter is incremented SQL-side rather than read,
incremented and written back, and the success branch carries the same workspace,
liveness and staleness guard the failure branch already had.

* fix(v2): bound the bulk update echo, close the search leak, and make the docs true

B3: a selectAll bulk document update echoed every changed identifier, which the
request does not bound — a 100k-document knowledge base produced a multi-megabyte
array, materialized and then element-wise validated. The use case now reports
whether the selection was unbounded and the presenter omits the echo.

A1: the knowledge search presenter spread the whole use-case result, which also
carries userId, workspaceId, a cost breakdown and a live secret-trace registry.
Only Zod's default key-stripping kept them off the wire. Projected explicitly.

P1-a: GET /knowledge/{id}/tags advertised all 17 slots while the document PATCH
accepted only the seven text ones. The writer already coerces every slot type,
so the PATCH now takes all 17 in their declared types, with a 400 where a
malformed value used to silently clear the tag.

P1-b: both new PATCHes deny workspace API keys and now say so.
P1-c: the two table query reads declare maxBodyBytes and now document the 413.
P1-d: getWorkflowDeploymentV2 loses its legacy suffix.

C3: deletes two orchestration error mappers with no callers that mapped
'forbidden' with no details.
D2: a stored null in table_views.config survived the pick and failed the
response schema.

Also folds the six 'bounded set' paraphrases onto one FULL_SET_LIST constant,
shares the run-window date bound between the logs and runs lists so their
documented parity is enforced rather than asserted, adds the missing barrel
export for FORBIDDEN_DETAIL_CODE_DESCRIPTIONS, and strictens two response
schemas whose peers were already strict.

Migrates 40 v2 route tests onto the shared @sim/testing harness: 26 asserted a
rateLimitSubjectIds shape v2 auth never returns, 26 asserted the wrong
refillRate, 33 could not exercise their 401 path at all, and 6 hard-wired the
rollout gate to null.

* fix(mcp): bound the connect handshake, and stop the 403 description over-claiming

B5: the connect clamp was getMaxExecutionTimeout(), the workflow ceiling of
seven days, so the real bound became the server row's own timeout — which the
registration contract permits up to 300s — times the connect retries. A slow
server could hold a Node request for roughly twenty minutes. Connecting is not a
workflow run, so the handshake now shares the one-minute ceiling tools/list
already applies to itself.

C2: the generated 403 description asserted that error.details.code names the
cause on every 403. Nine domain refusals still throw a bare forbidden
OrchestrationError and reach the wire codeless, so the wording now says 'where
the cause is one a caller can act on'. Reparenting those throws is left as a
deliberate change: one of them is a cross-tenant refusal that belongs in the
codeless class and would change its status.

* chore: reconcile the route ratchet with staging

* style: sort imports and format the three files biome flagged

* fix(openapi): import the forbidden-code constants from their module, not the application barrel

The barrel also re-exports the authorized use-case layer, which loads
@sim/db at import time. That pulled a database connection into the
OpenAPI spec check, so check:audits failed wherever DATABASE_URL is
absent, including CI.
2026-08-12 15:04:24 -07:00
2805a8def9 feat(windchill): add document integration (#6577)
* feat(windchill): add document integration

* fix(windchill): align tool contracts and docs

* fix(windchill): use official integration icon

* fix(windchill): correct response and paging semantics

* fix(windchill): align execution and API contracts

* refactor(windchill): inline route authentication

* fix(windchill): correct OData query encoding, content download, and cleared-field handling

Validated the integration end to end against PTC Windchill REST Services 2.7
documentation and fixed every divergence found.

Protocol correctness:
- Encode OData query spaces as %20 rather than the form-encoded `+` that
  URLSearchParams emits. Every multi-token $filter and $orderby reached
  Windchill as a literal `+` and could not match.
- Download content through the documented typed navigation
  `<content>/PTC.ApplicationData/Content/URL`, which returns a signed vault
  URL, instead of a `$value` segment that WRS does not implement. The
  resolved URL is pinned to the configured HTTPS origin.
- Terminate every Stage 2 CacheDescriptor_array entry with `;` to match the
  documented grammar.
- Raise the $top bound to Windchill's documented 2000 maximum, keeping 200 as
  the default page size.

Cleared-field handling:
- The executor merges raw block inputs before the block's param transform, so
  omitting a key could not clear it. A cleared numeric or boolean field
  reached the URL builder as '' and threw, and cleared optional strings failed
  contract validation. Coercions now emit an explicit undefined, and the
  internal-route body strips blanks centrally.

Robustness and contracts:
- Bound the document-structure walk to the depth actually requested.
- Loosen response schemas that re-applied request-side bounds to
  provider-returned values, which turned committed mutations into opaque
  parse failures.
- Return contract-shaped bodies for oversized, malformed, and unhandled
  request failures.
- Normalize downloaded content types and drop charset parameters.

Presentation and docs:
- Square the icon to a centred tile on white.
- Replace WT.Document and PATCH-compatible jargon with plain language.
- Fix canvas sentence noun stutters on the bulk operations.
- Correct the revision skill's unverified working-copy claim to read the OID
  back rather than assume it, and add retirement and stale-checkout skills.
- Add a manual intro section to the integration docs page.

* fix(windchill): align tool copy with the docs page and rebase the route baseline

Tool descriptions feed both the integration catalog and the generated docs page,
so the plain-language pass had to reach them too: drop WT.Document and
PATCH-compatible from the operation copy, and correct the $top bound the
descriptions still advertised as 200.

Correct the docs intro's attachment wording, gloss OData on first use, and
attribute the bulk-atomicity claim to PTC's documented behavior.

Raise the API route-count baseline, which staging advanced while this branch
was behind.

* feat(windchill): add update common properties

Name, Number, and Organization are rejected by the PATCH-based update
operation, and the rejection message told users to reach for Windchill's
UpdateCommonProperties action that the integration did not expose. Add it.

PTC documents UpdateCommonProperties as a bound DocMgmt action taking an
Updates wrapper, available when hasCommonProperties is set on the Documents
entity, and refused while the document is checked out. The subblock and param
descriptions carry that constraint, and the rejection message now names the
operation that does the job.

* test(windchill): assert block and tool params stay aligned for every operation

Validating the new operation surfaced that nothing enforced the block-to-tool
alignment the review process had been checking by hand. Assert it for all 27
operations instead: every required tool param has a required, non-advanced
input under that operation's condition, and no operation shows an input its
tool cannot accept.

Both fail on a deliberately broken condition or a dropped required flag.

---------

Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
Co-authored-by: Waleed Latif <walif6@gmail.com>
2026-08-12 13:18:31 -07:00
Waleed 9dfd9db3b0 feat(xai): wire reasoning effort through the Grok adapter (#6627)
* feat(xai): wire reasoning effort through the Grok adapter

The catalog never declared reasoningEffort for xAI and the adapter never
sent reasoning_effort, so the flag was dead for every Grok model.

Values are per-model and verified against the live API rather than the
docs, which are wrong in three places: grok-4.5 does accept xhigh, grok-4.3
supports the parameter at all (undocumented) including none, and
grok-4.20-0309-reasoning rejects it outright despite being a reasoning model.

Also corrects grok-4.5's missing cachedInput and drops an inline comment the
new provider TSDoc now covers.

* test(xai): type the provider test helper instead of casting to any

* fix(agent): correct reasoning-effort copy that still claimed GPT-5 only
2026-08-12 11:08:27 -07:00
Waleed 47f143016e fix(docs): restore api-reference URL continuity and fix translated SDK bodies (#6617)
Two docs-only defects introduced by #5273 (`263e3ca67e`), which re-founded the
public API reference on the v2 surface.

1. Ten translated SDK snippets produce a deterministic 400.

The streaming example in the five translated `api-reference/typescript.mdx` and
`python.mdx` pages was repointed from `/api/workflows/{id}/execute` to
`/api/v2/workflows/{id}/execute` and nothing else was changed — fr/ja/zh
typescript.mdx are literally one-line diffs. `message` stayed at the body root.
That was correct against v1, whose route treats the whole non-control body as
workflow input, but `v2ExecuteWorkflowBodySchema` ends in `.strict()` and the
route parses before executing, so every copied snippet returns
`400 Unrecognized key: "message"`. The same commit fixed the English bodies to
`input: { … }`, so this is an oversight, not a decision. The ten fences now
match `en/api-reference/typescript.mdx:959` and `python.mdx:681`.

Not relaxing `.strict()`: it is deliberate house style across the v2 contract
and is what makes a typo'd option fail loudly instead of silently.

2. Thirty-two published operation pages 404 with no redirect.

Replacing the single v1 `openapi.json` with seven v2-only specs changes page
identity, because fumadocs derives every generated page as
`slugify(tag)/operationId` from the specs at build time. Re-deriving both sets
gives 52 old slugs and 128 new ones: 32 disappear and 20 keep their URL while
silently retargeting v1 -> v2 (`knowledge-bases/updateKnowledgeBase` also flips
PUT -> PATCH). All 52 are in the live sitemap — parsing `<loc>` from
docs.sim.ai/sitemap.xml gives 458 URLs of which 56 are `/api-reference/`: the
four static pages plus all 52 generated ones by name, including every one of
the 32 that die. They are 200 today under an allow-all robots.txt.

The spec swap itself is deliberate and CI-enforced (`check-openapi-specs.ts`
requires every published operation under `/api/v2/`), so restoring the v1
operations is not an option. The missing piece is the redirect map, in a file
that already carried 56 such rules from earlier doc moves.

`permanent: true` (308) is used only for a true 1:1 successor — same operation,
renamed. A 308 is cached indefinitely and effectively unrecallable, so anything
that collapses two pages onto one, changes the identifier model, or lands on a
merely adjacent operation is `permanent: false` (307). That splits 21/11.

Four destinations differ from the mapping proposed in review, each on evidence
from the specs rather than from the operation names:

- `workflows/getJobStatus` is not destination-less. The v2 queued-execution
  receipt (`QueuedWorkflowRun`) returns `statusUrl`
  `/api/v2/workflows/{id}/runs/{runId}`, so `workflow-runs/getWorkflowRunV2` is
  the successor poll target — far better than a generic landing page.
- The three HITL read operations go to `getWorkflowRunV2`, not to the resume
  page: `WorkflowRunStatus` carries a `paused` object with `contextId`,
  `pausedAt`, and `pauseKind`. Pointing a GET doc at a POST doc would be wrong.
- `human-in-the-loop/listPausedExecutions` goes to `listWorkflowRunsV2`, whose
  `status` filter includes `paused`.
- `tables/batchUpdateRows` is 307, not 308. v2 `updateTableRows` is "Update Rows
  by Filter" — the successor of v1 `updateRows` (PUT, predicate-based), which
  keeps its 308. v2 has no by-id batch update at all, so batchUpdateRows lands
  on a genuinely different operation.

3. A guard, so the map cannot rot silently.

`scripts/openapi/docs-redirects.test.ts` recomputes the generated slug set the
way fumadocs does and asserts no `/api-reference/` source shadows a live page
and every destination resolves. Nothing else in the repo reads docs URLs, so a
future spec regeneration would otherwise break the map with no signal. It needs
no wiring: `check-openapi.ts` already runs this vitest config.

The redirect array moves to `apps/docs/lib/redirects.ts` because the guard
cannot import `next.config.ts` — `createMDX()` runs the fumadocs-mdx generator
at import time, which made vitest emit an unhandled build error and warn about
false positives. The 56 pre-existing rules are byte-identical to before,
verified programmatically; `next.config.ts` keeps the same public shape and
Next's own `checkCustomRoutes` accepts all 88 rules.

Open question for the owner, larger than the redirects: all 82 `/api/v1`
route files survive on staging, so a live public API now ships with zero
reference docs, while the documented `/api/v2` surface returns 404 for any
caller outside the off-by-default `v2-api` flag cohort. Is that the intended
end state or transitional?
2026-08-12 01:49:59 -07:00
Waleed 34d65df7d6 fix(sdk): make the 0.2.0 SDK release safe to publish (#6616)
The v2 SDK migration (#5273, #6564) shipped five breaking changes in both
SDKs but got the release mechanics wrong in three separate ways, and left
one of the two rewrites unable to complete a single successful call.

Versions. packages/ts-sdk/package.json read 0.1.3 -- a patch digit added
inside an unrelated compatibility commit, never deliberated. npm expands
^0.1.2 to >=0.1.2 <0.2.0, so every existing consumer would have picked the
break up on a lockfile refresh: AsyncExecutionResult.jobId renamed to
runId, executionId dropped from that interface, a failed sync run now
throwing instead of resolving {success:false}, the request body reshaped,
and the endpoint moved to /api/v2 with no fallback. 0.2.0 excludes every
existing range, so the upgrade becomes opt-in. packages/python-sdk carries
the identical break and was never bumped at all, so its publish job would
have skipped green at the "version already exists" gate and left the repo
and PyPI silently divergent; it moves 0.1.2 -> 0.2.0 in lockstep, along
with the __version__ string in simstudio/__init__.py, which tracks
pyproject and would otherwise have started lying. setup.py is left at
0.1.1: it is unchanged from main and demonstrably unread (0.1.2 published
from pyproject while setup.py already said 0.1.1). It wants deleting, in
its own commit.

A 404 fallback was considered and rejected. The legacy 202 body's statusUrl
points at /api/jobs/{jobId}, so mapping jobId onto runId would hand the
caller an id that getWorkflowRun cannot resolve against that same old
server -- a successful execute followed by an inexplicable failure on the
next call is a worse contract than a clean 404. Both READMEs instead state
the minimum server version and name the endpoint to check for.

Cancelled runs. packages/python-sdk computed success as status != 'failed',
so a run cancelled out of band reported success=True. The TypeScript SDK
uses a closed whitelist and reports False, and before the migration both
SDKs read the server's own value, which was False -- so this was a Python
regression, not merely an inconsistency. Fixed by mirroring the whitelist.
The v2 contract enumerates exactly completed|failed|paused|cancelled, so
narrowing the blacklist to a whitelist cannot drop a live value, and a
status added later now defaults to "not successful" rather than silently
reporting True. WorkflowExecutionResult gains a status field because
Python, unlike TypeScript, does not throw on 'failed' -- so success=False
alone is ambiguous there in a way it is not in the TypeScript SDK, which
is why status is not added to both.

Rate-limit header. Found while auditing the two SDKs for further
divergence, and the reason the Python bump could not have shipped as it
stood: every authenticated v2 response now carries X-RateLimit-Reset as an
ISO 8601 timestamp (recorded by v2RateLimits.publicApi, stamped by
withRouteHandler). The Python SDK parsed it with int(), raising a bare
ValueError that no handler in execute_workflow catches -- so every
successful v2 execution raised instead of returning. None of the legacy
endpoints the SDK previously called record a rate-limit snapshot, which is
why the latent int() survived until the v2 move. The TypeScript SDK
already branches on the format; _parse_reset_header mirrors it, including
degrading an unrecognised value to 0, because a quota hint must not take
down the call it rode in on.

Timing metadata. The v2 rewrite stopped forwarding startedAt/endedAt, which
main passed through and the TypeScript SDK still reports; restored under
the same startTime/endTime keys the TypeScript SDK uses.

Tests: cancelled/failed/paused status coverage, the ISO reset header, and
the restored metadata keys, each verified red against the unfixed line
first. The TypeScript suite gains matching cancelled/paused and ISO-reset
pins -- they pass against today's source by design, and were confirmed to
fail against a deliberately degraded copy so they are not toothless.

Deliberately not included: a CI guard failing a PR that changes SDK source
without a version bump. It would have caught this twice over, but it is a
new script and workflow rather than a fix to the defect at hand.

Review revision. bun.lock recorded packages/ts-sdk at 0.1.3 and was left
stale by the first pass, so the repo asserted two versions for the same
workspace package -- in a change whose whole thesis is that the version
strings had diverged. It does not break CI (bun 1.3.14 accepts the
mismatch under --frozen-lockfile, confirmed here), but 092311ea68 bumped
the lock in lockstep with package.json, and the next unfrozen install
would otherwise drop the line into an unrelated PR.

_parse_reset_header gated the numeric branch on str.isdigit(), which
accepts characters int() rejects ('²'.isdigit() is True, int('²')
raises) -- and that int() sits outside the try, so the one function added
to stop a quota hint raising could still raise, contradicting its own
docstring. str.isdecimal() is exactly the set int() accepts. The
tolerates-unparseable test is parametrized over both forms and was
confirmed red on '²' against isdigit.

Docs and docstrings: apps/docs api-reference/python.mdx mirrors the
README's dataclass block and was the only copy left without the new
status field. RateLimitInfo now names its units, because reset is epoch
seconds for the legacy integer and milliseconds for the ISO form that v2
sends. execute_workflow's Args entry still described the pre-v2 body
shape ("spread at root level"); every input is nested under input now,
and this is the commit that ships that help() text to PyPI. The
"declared last so positional construction keeps working" sentence was a
maintainer's note that belongs in this message, not in every user's
help(WorkflowExecutionResult).
2026-08-12 01:48:37 -07:00
Waleed 892401a8d0 fix(uploads): sign Azure upload URLs create-only (#6607)
getBlobPresignedUploadUrl signed its SAS with BlobSASPermissions.parse('w').
Per Azure's service-SAS reference, `w` is "create or write content" and permits
overwriting an existing blob; `c` is "write a new blob" and does not. main
signed `c` before this signer moved out of core/storage-service.ts, so Azure
deployments lost create-only enforcement in the move.

The `If-None-Match: '*'` the signer returns cannot carry the guarantee on its
own: an Azure service-SAS string-to-sign covers the resource, times,
permissions and the five rsc* response-header overrides, never request headers,
so a client is free to drop it. The header is signed on the other two providers
-- inside the PutObjectCommand on S3, and as x-goog-if-generation-match in
signed extensionHeaders on GCS -- which is why only Azure regressed.

Without this, a signed upload URL stayed a plain overwrite grant on the final
key for its full hour. A caller could replace the object after complete had
already verified size and content type, written the workspace file row and
metered storage from that verified HEAD, leaving durable metadata and billing
describing content that no longer exists.

The multipart block-staging signer keeps `w`: block staging is overwrite-shaped
and matches main.

The existing test asserted parse('w'), so it locked the defect in; it now
asserts create-only, and its name states the guarantee so a future flip reads
as deleting a security property rather than adjusting a value.
2026-08-11 23:10:42 -07:00
Justin Blumencranz a72457b9f5 fix(docs): preserve items response fields (#6587) 2026-08-11 19:43:58 -07:00
Waleed 7c05e36049 fix(api): close five defects found auditing the v2 migration against main (#6575)
* fix(tables): stop a column retype from nulling empty-string cells

A type conversion rewrote every cell holding '' to null. Main only nulled a
blank the target type could not read; '' is a real stored value that both
string and json columns accept, so string->json and json->string silently
destroyed those cells.

Worse on a required target: countEmptyCells matches only a missing key, SQL
NULL, or '[]', so '' passes the required guard and the rewrite then wrote null
behind a constraint that had just succeeded.

The per-cell decision is now the pure retypeCellRewrite, restoring main's rule:
null a blank only when the target cannot read it, otherwise coerce.

* fix(execution): release the concurrency slot when a group cancel is refused

The stop-the-work effects (durable Redis abort record, queue-job cancel,
in-process abort) all fire before the workflow-group sidecar is consulted, and
none can be undone. When the sidecar refuses the claim we throw a conflict,
which skipped releaseExecutionSlot and stranded the plan concurrency
reservation until it expired.

Every conflict return is a terminal-or-absent state - a missing log row, a log
already completed or errored, or a terminal cell - so a refusal never means the
run is still executing. The slot is released before the throw, keeping the exact
success && !isPausedCancellationPath predicate rather than a blanket finally
that would free reservations for live runs.

* fix(uploads): recover an ambiguous PUT instead of discarding the object

Main recovered an upload whose bytes committed but whose response was lost, via
a verify endpoint. The session client retries the PUT instead, but every
provider now signs a create-only precondition, so the retry returns 409/412,
is classified non-retryable, and the session aborts - deleting the object that
had already landed. A transient blip on the final ack cost the whole upload.

A conflict on a retry attempt is now treated as our own earlier PUT having
committed, and completion proceeds. That is safe because completeUploadSession
independently verifies the object through assertObjectIdentity, which rejects on
uploadId mismatch before anything durable is registered. A first-attempt
conflict still fails loudly.

* fix(folders): enforce the workspace folder ceiling on the create path

Readers bound the active path index at MAX_FOLDERS_PER_WORKSPACE and throw once
a workspace exceeds it, but POST /api/folders reached createFolder, which has no
maxFolderRows field and never counts. A workspace could therefore be driven past
the ceiling, after which the 27 capped read sites failed on a state the product
had allowed.

createFolder now asserts room inside its transaction, right after the mutation
lock, so the count cannot be raced. The refusal is a typed conflict rendering
409 with an actionable message rather than a 500. The check counts rows directly
instead of loading the path index, so an already-over-cap workspace gets a clean
refusal rather than a read error, and no reader gained a cap.

folderMutationStatus also gained the payload_too_large mapping it was missing,
which had been rendering a delete-cascade cap breach as an unexplained 500.

* fix(skills): route internal skill writes through the shared use cases

The internal route made the workspace authorization decision itself, never
consulting the skills operation policy, never loading canonical workspace
context, and recording an audit entry with no operation id or actor projection.
v2 and Copilot already went through the use cases; only this surface did not.

GET/POST/DELETE now authenticate, parse, call the shared use case, and present.
Request and response shapes are unchanged. Two behavior changes fall out: a
write against a deleted workspace is now refused with 404 rather than accepted,
and permission-denial text matches the rest of the platform.

Legacy internal-JWT auth is dropped because no principal kind expresses that
caller and nothing calls it: the whole repo references /api/skills only in two
comments, no tool declares an internalRoute to it, and the executor reads skills
through a direct listSkills call rather than over HTTP.

* fix(folders): enforce the workspace ceiling on the remaining create paths

Folder duplication, admin workspace import, and workspace forking all inserted
folders without consulting the ceiling that 27 read sites enforce, so any of
them could leave a workspace whose reads then fail.

Each now asserts room for the rows it is about to add rather than one at a
time: duplication measures the whole subtree up front, forking counts its bulk
insert, and import counts per segment because that is genuinely one row.
assertFolderCollectionHasRoom gained an additionalRows notion for the bulk case,
and short-circuits when nothing is being added so an over-cap workspace still
reads and still syncs.

Duplication deliberately does not take the folder mutation lock. Holding it
across the copy would block folder creation workspace-wide for an unbounded
time - there is no cap on workflows per subtree and duplicateWorkflow runs
sequentially - and narrowing it is impossible because an advisory transaction
lock cannot be released early; splitting the transaction would leave a
half-copied tree on failure. A rare few-row overshoot near the ceiling is the
better trade, and it matches what forking already does. A test asserts the lock
is absent so re-adding it is a visible decision.

Admin import gained the transaction and lock it never had. Its folder-full
refusal escapes the per-workflow result list, because a full tree is a property
of the workspace and would otherwise be buried as N failures behind a 200.

The fork and promote routes had no catch at all, and withRouteHandler only
classifies HttpError, so a refusal rendered as an opaque 500 - twice over, since
drizzle wraps the throw. Both now project a classified conflict as 409 and
rethrow anything unclassified.

* fix(uploads): bound the signed PUT lifetime and advertise its real expiry

A single-PUT transfer was signed for the whole 24h upload-session TTL, because
expiresAt was reused as both the session lifetime and the signing lifetime.
Multipart part URLs in the same file kept 1h, and the pre-migration presigned
route signed every PUT for 1h, so the widening was unintended rather than a
policy change. No provider clamps below 24h.

The PUT presign is now clamped at the provider boundary by a shared
UPLOAD_URL_TTL_MS, which the part-URL path also uses so the two cannot drift.
An expired PUT URL is deliberately not recoverable: unlike multipart, which
re-signs per part call because its progress is durable, a PUT is not resumable,
so an expired URL and an interrupted PUT have identical recovery. Nothing leaks,
since every provider signs a create-only precondition.

Clamping alone would have made the contract lie: the URL would die an hour
before the session's advertised expiresAt, with nothing telling an integrator
why the 403 happened. The PUT transfer now carries its own expiresAt, mirroring
the multipart part-URL field. It is provider-dependent on purpose - cloud
transfers report the clamped signature expiry, while the local data plane has no
signature and admits against the session, so reporting an hour there would have
been a new inaccuracy in the other direction.

* chore: delete the dead presigned-upload and skill-adapter paths

The presigned upload routes and the internal skills adapters were both replaced
during the v2 migration, leaving their implementations behind with no callers.

Removed generatePresignedUploadUrl and verifyPresignedUploadReceipt with their
three provider helpers, QUOTA_EXEMPT_STORAGE_CONTEXTS and the types it orphaned,
and the performCreateSkill/performUpdateSkill/performDeleteSkill adapters with
recordSkillEvent and statusForSkillOrchestrationError. Each was verified
unreachable across apps, packages, scripts and ee, including barrel re-exports
and string access, not just direct imports.

recordSkillEvent needed the closest look, since deleting an audit writer can
silently drop coverage. The use cases declare the same action, resource, and
description, and the framework adds the operation and actor the old helper
lacked; recordAudit back-fills actorName and actorEmail from the user table
when both are omitted, so the one field the helper passed is not lost.

The self-hosting architecture doc described a directUploadSupported flag on an
endpoint that no longer exists, and now describes the upload-session flow that
replaced it.

* refactor(folders): keep the cheap resource facts out of the schema graph

Reading a folder resource type's label or its lock support meant importing
folderResourceConfig, which imports the db schema for every table it serves and
from there reaches lib/table/service, the executor, and the tool registry.

That mattered as soon as lib/folders/queries needed a label: queries is reached
from workspace-file-manager, which is reached from the files and chat pages, so
one import edge put roughly 4,700 modules into those page graphs and broke the
tool-registry boundary audit.

Labels and lock support now live in a leaf module that imports only a type, and
config composes them so there is still one source of truth. The three folder
routes that pulled the whole config in for a single boolean read the leaf
instead.

* fix(skills): apply an upsert batch in one transaction

The internal skills route looped the batch, calling an independently committing
use case per item. A rejection on a later item left the earlier ones written and
audited while the request reported failure - the compound-mutation rule in
CLAUDE.md exists for exactly this.

No new transaction plumbing was needed: upsertSkills already wraps its whole
item loop in one db.transaction, so the partial commit came from calling it N
times rather than once. upsertSkillBatch now validates and per-skill authorizes
every item before issuing a single write, and createSkill and updateSkill became
thin wrappers over it so v2 and Copilot keep one authority for the rules.

The compound operation declares the read floor that skills.update already used,
and the use case additionally authorizes skills.create when any item lacks an id,
still ahead of every write. A read-only member who is a skill editor keeps their
edit, and creates are not authorized more loosely than before.

Audit projects one entry per committed skill, and analytics moved after the
commit so nothing is reported for a rolled-back item. Note metadata.operation
for these writes is now skills.upsert rather than skills.create/update; the
action field still carries the distinction.

* fix(security): close two disclosure gaps and finish the slot-leak fix

The payer-pool gate only covered the workspace branch. A personal API key that
omits workspaceId takes the account branch, where getHighestPrioritySubscription
resolves an organization subscription from any member row regardless of role -
so a plain member read the organization-wide credit and storage pool by dropping
one query parameter. The account branch is now gated by the same authority, and
the storage pool is not queried when it may not be disclosed. Forcing that
branch self-scoped instead would have downgraded plan, period, and status, which
is what a member needs to see whether the org is blocked.

GET /api/v1/logs/executions/[executionId] emitted the workflow snapshot raw,
carrying password sub-block values and oauth-input credential ids. It now shares
the sanitizer the v2 read already used, extracted so there is one implementation
rather than two. Env-var references are still preserved.

cancelWorkflowGroupExecution itself was unguarded, so an unexpected throw from
its transaction escaped ahead of every release site - the same reservation leak
this branch set out to close, still open on the adjacent path. It now releases
through the shared predicate and rethrows, because a failed transition means the
cell state is unknown and a success-shaped answer would be a lie. The comment
claiming the abort record cannot be taken back was false and now states the real
reason: a refusal is always a terminal-or-absent state.

* fix(v2-api): cover every persisted run status and every capped body

The workflow-runs endpoints carried the same omission the logs contract had:
the execution logger persists redacting, the run schemas did not list it, and
because validation is whole-response one such row returned 500 for an entire
page. Both schemas now derive from PersistedWorkflowExecutionStatus behind the
same AssertNever gate, so a future status is a type error rather than a
production 500. The single-run read keeps its extra queued value, which only it
can observe.

That schema was also serving as the run-list status filter. Widening it would
have accepted a filter value the application input cannot express, so the
reported set and the accepted filter are now separate schemas.

Routes declaring maxBodyBytes without payloadTooLargeResponse fell back to a
bare string with no error code and no private cache header. Rather than patch
the four, the default moved into the builders - all three had the hole - which
covers 58 body-bearing handlers, and a route override still wins. The five
per-route overrides that merely restated the default are gone.

Also documents the 413 on the one knowledge route that has a real body cap, adds
the rollout gate's 404 to the last v2 operation missing it, and rewords the
nextCursor description, which read as though every list were a full-set list.

* fix(api): make the shared traits and lifetimes single-sourced

The folder resource-traits leaf composed labels into config but restated lock
support independently, so the routes reading the trait and the orchestration
reading the config could disagree about which resources lock. Config now
composes both, and supportsLocking is required rather than optional so a new
resource type cannot silently omit it.

The upload commit claimed the PUT clamp and the multipart part URLs shared a
constant and could not drift. That was true only of the advertised expiry - the
three provider signers each hardcoded an hour, so changing the constant would
have moved what we advertise while leaving what we sign, recreating exactly the
mismatch the clamp removed. Each provider now receives the lifetime in its own
unit from the one constant.

Table restore hand-rolled its status map and returned the driver message
verbatim at 500, leaking the failed statement and its bound parameters - the
same defect this branch closed at nine other sites. It and import-csv now use
the shared projection; import-csv's result type also had to carry the lock the
classifier already set, so a 423 can name it.

Deletes v2RowWriteError, which had no callers and would have rendered a locked
table as 400 by discarding the 423 it was handed.
2026-08-11 19:37:40 -07:00
Waleed be5db68644 fix(agiloft): repoint the block at the alrest surface and fix EWLogin (#6562)
* fix(agiloft): make the block work, and align it with the REST documentation

The native Agiloft block could not authenticate against any instance. A
customer reported it; production traces for their workspace confirm every
failure mode verbatim. Fixing that exposed a second, larger problem, and a
per-endpoint audit against the full published documentation found the rest.

Authentication
- EWLogin sent only $KB/$login/$password as query parameters. A live instance
  answers `400 EWWrongDataException ... One has to specify $table, $KB, $lang
  parameters`. $table is required even though only $KB/$login/$password/$lang
  are documented. Parameters now travel in a form-encoded body, which the docs
  permit and which keeps the password out of URLs and access logs.
- The authentication scheme is read from the login response and trimmed;
  Agiloft returns it as "Bearer " with a trailing space.
- EWLogout was missing $lang.

Surfaces
- Record create, read, update, search and saved-search now use the endpoints
  that accept the token EWLogin issues; the legacy operations authenticate from
  inline credentials, which is what that surface expects. Nothing sends both
  forms at once — the documented 400 for doing so is what the original report
  had run into.
- EWSelect passes credentials in a POST body, one of the five operations
  documented to support it.
- Attachment retrieval uses the documented EWRetrieve endpoint, with
  filePosition rather than position, and no longer needs a login/logout pair.

Defects found in the audit
- remove_attachment reported zero on every call: its body is the EWREST
  assignment form but the route ran JSON.parse then Number(), yielding NaN.
- The EWREST parser could not read EWActionButton's documented response, which
  puts both assignments on one line.
- EWLock treated any 200 as success, including the documented
  {error, error_description} envelope, and invented an 'UNKNOWN' status.
- EWTable discarded the linked-field details, required flag and text field type
  it had asked for, making includeLinkedInfo inert.
- select_records had no result ceiling at all; both it and search now cap and
  report a truncated flag rather than reporting a capped length as a total.
- Optional string inputs rejected null, so a blank Page field failed validation
  before any request was made.
- Upsert treated the documented 202 async acknowledgement as a missing-ID
  failure, and returned no callback ID for the caller to poll.
- Every response contract required an output that the 401 and 500 paths never
  return.

Coverage added
- Table and field discovery (EWTable), upsert (EWUpsert), async status
  (EWAsyncStatus), natural language search (EWNLPSearch), action buttons
  (EWActionButton), the REPLACE_WITH_ANOTHER delete rule with its substitute
  records, $async on upsert, and <fieldName>$overwrite on attach.
- Reads with a named field list go through the search projection; an unfiltered
  contract record runs to roughly 184KB and swamps downstream agent context.
- Errors are readable: Agiloft wraps failures in HTML around a typed exception
  and an internal task id, and the JSON endpoints now request real status codes
  rather than a 200 the caller has to interpret.

Not implemented: $searchSQL and $operationHints=NOLOCK are EWRead/EWUpdate
parameters and those operations do not run on that surface here; EWQuestion,
EWHotlinks, EWOData, EWBroadcast and webhook registration have no documentation
beyond their names.

Verified against the published documentation, not against a live instance.

* fix(agiloft): give natural language search a sentence that paints

check:canvas-sentences failed: the nlp_search card resolved to nothing on an
untouched canvas, so it painted empty. Its only basic-mode field was the
long-input query, and the field list is advanced, so every segment dropped.

The sentence now leads with the knowledge base, matching the shape List Tables
already uses — both operations are knowledge-base scoped rather than
table-scoped, so it also reads more accurately.

* fix(agiloft): stop retrying refusals, and expose the outputs the new operations return

Five findings from review that had gone unanswered.

An Agiloft refusal was surfacing as HTTP 500. readAlrestJson throws when the
envelope reports success:false, the route catch mapped that to 500, and the
tool runner retries 500s — so a create the server had already rejected could be
retried and duplicate the record. Refusals now return a settled failure with the
message intact; genuine faults still 500.

list_tables could not run in its primary mode. EWTable is knowledge-base scoped,
but some instances reject EWLogin without a $table, so whole-knowledge-base
discovery failed at login with nothing to fall back to. It now says what the
caller can do about it rather than surfacing the raw login error.

Upsert corrupted structured values. Every field went through String(), so a
multi-value field collapsed into one joined string instead of the documented
repeated key/value pairs, and an object silently wrote "[object Object]" into
the record. Arrays now encode as repeated pairs and objects are refused, since
Agiloft documents no encoding for them.

Two outputs were invisible in the editor. `records` was conditioned on
search_records alone, so natural language search results could not be chained,
and `callbackId` on run_action_button alone, so a queued upsert's callback could
not be wired into Async Status even though both values exist at runtime.
2026-08-11 16:31:32 -07:00
Waleed 81e04a8e41 fix(agiloft): align the integration with the documented ewws REST interface (#6556)
* fix(agiloft): align the integration with the documented ewws REST interface

The CRUD tools targeted /ewws/REST/{kb}/{table}/{id} with JSON bodies and
guessed at the response by probing `data.result ?? data` and `id ?? ID`.
Agiloft documents that path as a URL convention only -- no method table, no
example call, and no response shape -- and no known client uses it. The EW*
operation family is specified end to end, including exact response bodies, so
every operation now goes through it and parses the documented
`EWREST_key='value';` assignment format.

- EWCreate/EWRead/EWUpdate/EWDelete/EWSearch/EWSelect/EWGetChoiceLineId are
  form-encoded and parsed via a shared EWREST parser; the /.json suffix is kept
  only on EWAttachInfo, the one operation with a published JSON sample
- EWDelete now sends the deleteRule the docs require, defaulting to
  ERROR_IF_DEPENDANTS so a delete fails rather than cascading
- EWRemoveAttachment uses GET; it does not accept DELETE
- EWSearch accepts the documented `search` saved-search label, so saved
  searches are reachable for the first time
- Search query help taught AND/OR; Agiloft uses && and ||
- Add run_action_button (POST /ewws/async/EWActionButton) for approvals and
  send-for-signature steps
- Drop saved_search: EWSavedSearch has no doc page, so neither its URL nor its
  response could be verified and it could only ever return an empty list
- Add force on unlock, filter read fields locally since $fields is
  undocumented, correct lock status to LOCKED/NO_LOCK, and stop reporting a
  fabricated page size of 25

* fix(agiloft): fail loudly on non-EWREST bodies and keep the retired tool resolvable

- EWSearch and EWSelect report an empty result set as `EWREST_id_length = '0';`,
  so a body with no assignments at all is a refusal Agiloft returned with HTTP
  200, not an empty result. Both routes now surface it as an error instead of a
  successful empty list.
- Re-register agiloft_saved_search as a retired tool. Removing it outright left
  workflows saved with operation='saved_search' deriving a tool id the registry
  no longer provided, which throws "Tool not found" at execution. It now fails
  through directExecution with a message pointing at the Search Records
  operation's Saved Search field, without issuing an undocumented request. It
  stays out of the operation dropdown so it cannot be chosen for new blocks.
- Guard EWCreate and EWUpdate against oversized record data. Those operations
  carry field values in the query string, so a large payload hits the request
  line limit; the tool now explains that rather than surfacing an opaque 414.
2026-08-11 13:27:20 -07:00
Waleed c365b14f73 feat(calendly): extend tools with booking, availability, no-shows, and routing forms (#6545)
* feat(calendly): extend tools with booking, availability, no-shows, and routing forms

Adds 12 tools verified against the Calendly OpenAPI spec: get_user,
get_event_invitee, create_event_invitee, list_event_type_available_times,
list_user_busy_times, list_user_availability_schedules,
create_scheduling_link, create/delete_invitee_no_show,
list_organization_memberships, list_routing_forms, and
list_routing_form_submissions.

Also fixes issues found while validating the existing tools:

- list_webhooks dropped the scope query param the API requires, so every
  call with scope unset returned 400
- list_event_types could only send active=true, making inactive event
  types unlistable
- user and organization filters now accept a bare UUID or a full URI
  consistently across every operation
- json array params (eventGuests, events) are normalized whether they
  arrive as an array or a JSON string

* improvement(calendly): type the block/tool alignment test instead of using any

* fix(calendly): normalize webhook organization and user identifiers
2026-08-11 11:22:28 -07:00
263e3ca67e improvement(external-endpoints): v2 versions with clean signatures + updated docs based on openapi spec (#5273)
* v0.6.29: login improvements, posthog telemetry (#4026)

* feat(posthog): Add tracking on mothership abort (#4023)

Co-authored-by: Theodore Li <theo@sim.ai>

* fix(login): fix captcha headers for manual login  (#4025)

* fix(signup): fix turnstile key loading

* fix(login): fix captcha header passing

* Catch user already exists, remove login form captcha

* improvement(external-endpoints): v2 versions with clean signatures + updated docs

* feat(usage): accept X-API-Key on usage-logs list + export

/api/users/me/usage-logs and /export now use checkHybridAuth — the same
auth /api/users/me/usage-limits already accepts — so external monitors
can read summary.bySourceCredits (the source breakdown of usage-limits'
aggregate currentPeriodCost) instead of estimating Copilot spend by
subtraction. Workspace-scoped keys are pinned to their own workspace's
slice of the ledger: the filter defaults to the key's workspace and an
explicit mismatch 403s. Both endpoints documented in openapi-core.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(billing): dedicated v2 usage endpoints; keep internal usage routes session-only

Replaces the earlier X-API-Key enablement on /api/users/me/usage-logs
with a dedicated public surface, so the internal Billing-settings
endpoints can evolve with the UI while external monitors get a stable
versioned contract:

- GET /api/v2/billing/usage — current-billing-period summary with
  bySourceCredits (the source breakdown external monitors need to watch
  e.g. Copilot consumption without estimating by subtraction), plus
  limitCredits and plan
- GET /api/v2/billing/usage/logs — cursor-paged credit ledger in the v2
  envelope
- workspace-scoped keys are pinned to their own workspace's slice;
  personal keys read the account ledger

The public wire is credits-only: usage-logs rows now carry a hasCost
boolean instead of dollarCost (the Billing UI only needed the >0
signal), and the rateLimit block is removed from the usage-limits
response and docs (deploy-modal tab relabeled accordingly).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(docs): validate OpenAPI specs against the Zod contracts in CI

The specs in apps/docs are hand-authored because they carry what Zod
never defines — error envelopes, status codes, prose, examples — so
they can't be generated; check:openapi validates them instead:

- spec integrity: $refs resolve, operationIds unique, 2xx documented,
  no orphaned component schemas
- v2 conventions: every /api/v2 operation documents 401 + 429 and every
  4xx/5xx resolves to the canonical { error: { code, message } } envelope
- contract cross-check: contracts are auto-discovered from
  lib/api/contracts/v2 (each carries its method + path); doc<->contract
  coverage both ways, query/body/response field diffs via z.toJSONSchema
- examples: documented request/response examples must parse with the
  matching contract's actual Zod schemas

First run caught real drift, fixed here: 16 stale orphaned schemas in
the core spec, the v2 billing ops referencing v1-shaped error
components, deploy/rollback examples missing the required nullable
lifecycle keys, CreateTableBody missing folderId, a legacy-grammar
delete-rows example, and four knowledge document ops missing their
required workspaceId query param.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* fix(docs): recursive field diff in check:openapi + the deep drift it found

A mutation test showed the doc<->contract field diff only compared
top-level properties, so a typo inside the { data } envelope passed.
The diff now descends through matching object properties and array
items (both sides must expose a property set — passthrough contracts
and prose-only docs end the descent instead of false-positive), with
the Zod JSON-schema root doubling as the $defs context.

Deep drift it immediately caught, fixed here: select-column config
(options/multiple) missing from every tables column schema, AddColumnBody
hand-rolling a third column shape (now composed from ColumnInput, with
position/workflowGroupId as the per-op extensions the contracts actually
admit), chunking strategyOptions undocumented, and the deployment
lifecycle fields (activeDeployment/latestDeploymentAttempt) missing from
DeploymentState.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* fix(security): close the triggerType rate-limit bypass on workflow execute

Caller-supplied triggerType flowed unchecked into preprocessExecution,
whose checkRateLimit default turns OFF for 'manual'/'chat' — so any
API-key caller, and any anonymous public-API caller billed to the
workspace owner, could execute unthrottled by sending
{"triggerType":"manual"} (async runs also skipped the worker-side check
via admissionCompleted). External callers may now only send the
redundant 'api' value; internal JWT callers ('workflow'/'mcp') are
unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* refactor(execution): extract enqueue/status/cancel into shared libs

Prepares the v2 execution surface: handleAsyncExecution's queue logic
moves to lib/workflows/executor/enqueue-execution.ts (slot/claim
semantics encoded in a discriminated outcome, not HTTP statuses), the
execution-status read to execution-status.ts, and the order-sensitive
cancel machinery to lib/execution/cancel-workflow-execution.ts. The v1
routes re-render identically — their suites pass unmodified.

Also: preprocessExecution gains rateLimitCounter ('sync'|'async') and
its 429 now carries code RATE_LIMIT_EXCEEDED + retryAfterMs (previously
indistinguishable from the concurrency 429 and Retry-After was
discarded); and the duplicate cancel contract in contracts/logs.ts is
unified on the full 5-value reason enum — its narrower copy made
requestJson throw a client ZodError when cancelling a paused HITL run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(execution): callable execution service + structured error classifier

executeWorkflowService composes the same libs the v1 route holds inline
(call-chain guard, execution-id claim, LoggingSession, preprocessing,
deployed-state load + file-field processing, timeout-bound
executeWorkflowCore, output hydration/compaction) for the deployed-state
caller class — the seam the v2 execute route and in-process internal
callers share, making the HTTP endpoint syntactic sugar.

classifyExecutionError stops discarding the block context that
buildBlockExecutionError already attaches at throw sites: failed runs
now yield {message, code, blockId, blockName, blockType} with a stable
append-only code enum (TIMEOUT/CANCELLED/USAGE_LIMIT_EXCEEDED/
INVALID_INPUT/BLOCK_EXECUTION_FAILED/CHILD_WORKFLOW_FAILED/
OUTPUT_TOO_LARGE/EXECUTION_FAILED), so callers route on error class
instead of substring-matching messages — the single place raw errors
are interpreted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(api): POST /api/v2/workflows/[id]/execute

Thin route over executeWorkflowService: X-API-Key or anonymous
public-API auth (sync/stream only for anonymous), strict body with
body-flag async (no mode headers on v2), SSE passthrough for stream,
and the execution resource response — executionId always present,
in-band run failures are status:'failed' with the structured
{message, code, blockId, blockName, blockType} error, sync timeout is
status:'failed' + TIMEOUT instead of v1's 408, and a Response block's
payload stays inside output (authors never control response
status/headers on this origin). Async debits the async bucket and the
202 statusUrl points at the v2 executions resource. Adds
CLIENT_CLOSED_REQUEST/SERVICE_UNAVAILABLE to the v2 error codes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(api): v2 executions status + cancel with queued backfill

GET /api/v2/workflows/[id]/executions/[executionId] is the single
status URL for sync and async runs: before the async worker writes the
durable log row, status is backfilled from the job queue (deterministic
job id) as 'queued'/'running' — closing v1's 202-to-pickup 404 window —
and failed runs carry the structured error object. POST .../cancel
renders the shared cancellation lib in the v2 envelope with the
tightened 5-value reason enum. Both authenticate via the shared
resolveV2WorkflowAccess (X-API-Key, authz masked as 404,
allowPersonalApiKeys honored).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(execution): workflow tool + MCP bridge run in-process

workflow_executor (workflow-as-agent-tool) short-circuits in executeTool
through WorkflowBlockHandler — the same invocation boundary canvas child
workflows use — mirroring the deployed_block_executor precedent. The
MCP serve bridge calls executeWorkflowService directly instead of
fetching its own execute endpoint; deployment-version pinning, MCP
response-size rejection, and the actor override become typed options
instead of header sniffing. Both callers drop the double admission slot
and duplicate top-level log row the HTTP hop cost, and failed child
runs now surface the structured error + child executionId so parents
and MCP clients can route on error class and hand providers a
reproducible handle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(infra): CORS + CSP coverage for the v2 execute path

/api/v2/workflows/:id/execute gets the same wildcard-origin,
credential-free CORS policy as v1 (the default credentialed policy
would block browser API-key calls and open a cookie CSRF surface) with
X-Sim-Stream-Protocol allowed and no X-Execution-Mode (async is
body-selected on v2), plus the COEP/COOP/CSP header block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(ui): deploy modal + copilot advertise the v2 execute surface

All 20 API-tab snippets move to POST /api/v2/workflows/{id}/execute with
the nested {"input": ...} body, async as the "async": true body flag
(X-Execution-Mode gone), status polling against the v2 executions
resource, the third tab renamed Usage and pointed at
/api/v2/billing/usage, and {data} envelope unwraps in the printed
responses. Fixes the latent baseUrl derivation
(endpoint.split('/api/workflows/')) that would have silently built
garbage URLs under a v2 endpoint, and deletes dead code (exampleCommand
across 3 sites, getAsyncExampleTitle). Copilot deploy/manage/serializer
endpoint builders and the api_trigger bestPractices example follow (the
latter also drops its hardcoded staging host).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* docs(api): document the v2 execution surface

Adds execute, execution status, and cancel to openapi-v2-workflows.json
with the structured ExecutionError schema (append-only code enum + block
attribution) and the ExecutionResource contract, documenting the rules
that differ from v1: modes are body-selected, a failed run is HTTP 200
with status 'failed', an executionId always means data (never the error
envelope), queued status is visible immediately, and Response-block
payloads stay inside output. Registers the three pages in the generated
workflows meta.json and bumps the route-count baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* feat(api): gate the whole /api/v2 surface behind one flag; UI stays on v1

Every v2 route now runs exactly one check immediately after auth —
v2ApiGateError — and answers 404 when the `v2-api` flag is off, so the
surface is invisible until it is deliberately rolled out. The gate is
keyed on userId only: a workspace/org-keyed check would have to read
membership for a caller-supplied id before authorization runs, and its
404-vs-403 split would leak cohort membership (the trap the per-domain
table gate worked around by running late). The two executions routes
inherit it from the shared access resolver; the tables-specific gate is
removed so no route checks twice.

`tables-v2-api` stays, now gating only the internal predicate-grammar
route /api/table/[tableId]/query — note v2 tables routes move to the
unified flag, so enabling them is a `v2-api` decision now.

Reverts the deploy modal, copilot handlers, and api_trigger example to
the v1 execute endpoint: v1 works unchanged, and the UI must not
advertise a surface most users would get a 404 from.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz

* fix(executor): restore child-cost aggregation dropped by the staging merge

Staging's custom-block rewrite deleted `aggregateChildCost` from
workflow-handler.ts, and git merged that file cleanly — but this branch's
workflow-tool-runner.ts, added for the v2 execute migration, still imports it.
A silent semantic conflict: no marker, broken build.

Taking staging's rewrite is correct, so the helper is defined locally in its
one remaining consumer rather than resurrected in the file staging just
rewrote. Same four lines over the still-exported `calculateCostSummary`, so a
failed child workflow keeps billing the hosted-key spend it consumed instead
of reporting $0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(tables): make lib/table/orchestration the single implementation (#6134)

* refactor(orchestration): move the shared error contract out of lib/workflows

OrchestrationErrorCode and statusForOrchestrationError are the contract every
lib/[resource]/orchestration module returns against, but they lived inside the
workflows module, so resource-neutral code (lib/folders) already had to import
from a workflow path. Moved to lib/core/orchestration/types.

Adds a 'locked' class mapping to 423. Both tables and workflows have a lock
that forbids a mutation, and each caller was translating that to a status
itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(tables): make lib/table/orchestration the single implementation

Column update was implemented four times — the UI route, v1, v2, and the
copilot table tool — each calling the same column services but owning its own
guards, error mapping, and audit. The copies had drifted, and the drift was the
bug: v2 was missing both guards, only the copilot copy minted stable option
ids, and only v1/v2 audited.

performUpdateTableColumn, performDeleteTable, and performDeleteTableRow now own
that logic; all ten call sites reduce to auth, parse, call, render. The guards
are asserted once in lib/table/orchestration rather than four times against
four routes.

Behavior this consolidates, previously true on only some paths:

- The typeChanging guard. updateColumnType early-returns on an unchanged type
  and drops any options sent with it, so restating the current type alongside
  new options silently discarded them. v2 had no guard at all and, since its
  contract shares v1's body schema, accepted options and ignored them.
- The select-unique guard. Each write is its own locked transaction, so a
  rename or type change paired with a constraint write that is going to fail
  commits first and then throws, half-applying the schema change.
- Stable select-option ids. Cells reference the option id, so an edit that
  re-sends an option by name has to reuse it or every cell holding it is
  orphaned. Only the copilot path did this; normalizeSelectOptionsInput moves to
  lib/table/select-options and now covers every caller. It preserves a supplied
  id, so it is a no-op for the fully-formed options the HTTP contracts accept.
- required forwarded into the type and options writes, so a conversion
  validates against the constraint the same request is setting.
- An audit on every successful update. The UI route and the copilot tool
  emitted none.
- Single-row delete through the row service. v2 did a raw db.delete, skipping
  assertRowDelete and deleteOrderedRow, so a delete-locked table returned 200
  and the row-count bookkeeping never ran.
- The delete actor handed to deleteTable, which audits only when a row was
  actually archived. v1 and v2 omitted it and audited themselves outside that
  check, emitting TABLE_DELETED for a no-op delete of an archived table.

Failure classes come back as OrchestrationErrorCode; v2 renders them through a
new v2ErrorForOrchestration, mirroring statusForOrchestrationError on the v1
and UI surfaces, so a given failure maps to the same status everywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(tables): bind the column-update tests to the orchestration function

The base's route tests assert which column service each payload reaches — the
behavior that now lives in performUpdateTableColumn. They mocked the `@/lib/table`
barrel; the orchestration module imports the service directly, so they mock that
too and keep asserting the same thing through the extracted implementation.

The orchestration tests move onto the base's semantics: writes address the
stable column id, a rename rides inside the write it accompanies rather than
running first, and the currency guards replace the non-select options guard the
service now owns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(copilot): drop the column-type import the delegation made dead

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(tables): move the audit log out of the table service

`lib/table/service.ts` wrote its own audit rows, so whether an operation was
audited depended on which function a caller reached for rather than on a user
having performed it. That is what let v1 and v2 audit a no-op delete, and what
made `deleteTable`'s optional `actingUserId` double as an audit opt-out flag.

Worse, most sites fell back to `actingUserId ?? createdBy`, so an unattributed
call was logged against the table's *creator*. The copilot `mv` path passed no
actor at all: renaming someone else's table recorded them as the renamer.

Audit now lives in the orchestration functions — performDeleteTable,
performRenameTable, performMoveTableToFolder, performUpdateTableLocks — and
the services just write. Internal callers (folder cascade, import rollback)
keep calling the service and are silent by construction rather than by
remembering to omit an argument.

Two services now return what the audit needs: `deleteTable` reports whether it
actually archived a row, so a repeat delete logs nothing; `updateTableLocks`
returns the before/after locks, since only the locked write can observe the
transition its description names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tables): restore audit provenance and conflict status in orchestration

Moving the audits into the orchestration functions dropped three things the
routes had been carrying, and added one the orchestration now owns twice.

- The v1 and v2 column-update routes passed `request` to `recordAudit`, so
  their audit rows recorded the caller's IP and user-agent. The orchestration
  function had no way to receive it. Every table orchestration function now
  takes an optional `OrchestrationRequestContext` and every HTTP route
  forwards it; the copilot and VFS callers, which have no request, omit it.
- `classifyTableMutation` matched `TableConflictError` on "already exists"
  appearing in the message and reported it as `validation`, turning the UI
  route's 409 on a duplicate table rename into a 400. It now matches the type,
  the way `performRestoreTable` already did.
- `captureServerEvent` ran on every delete while the audit was gated on a row
  actually being archived, so a repeat delete of an archived table still
  reported `table_deleted`. Both now hang off the same evidence.
- The copilot delete path kept its own `captureServerEvent` from when the
  service did not emit one, double-counting every copilot table delete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a

* fix(tables): say which type a no-op column update restated

A copilot `update_column` payload whose only content was the column's current
type used to return success with the live schema, while the v1, v2, and UI
routes rejected the same payload with "No updates specified". Delegating to
`performUpdateTableColumn` unified them onto the routes' rejection — correct,
but the message tells the caller its request was empty when it named a type.

The orchestration function now reports the same thing `updateColumnType` reports
when it loses this race concurrently: the column is already that type, re-issue
without the type change. An empty payload still reads "No updates specified".

Drops the copilot's `outcome.table ?? tableForUpdate` fallback with it — the
comment described the no-op that can no longer reach that line, and a success
always carries a table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a

* refactor(tables): classify failures by type instead of by message text

The table module decided HTTP statuses by searching error messages for
phrases. `VALIDATION_MESSAGE_FRAGMENTS` and `ROW_WRITE_ERROR_PATTERNS` held 32
substrings between them, and fifteen more lists were inlined in routes — 83
matchers over 17 files, each its own copy of the guesswork and already drifted
apart. It made message wording load-bearing: `TableRowLimitError`'s own doc
comment noted that its text had to contain "row limit" for a route to answer
400, and adding "already exists" to a rename message silently demoted a 409 to
a 400 (the bug fixed one commit ago, by adding another special case).

Services now throw `OrchestrationError`, which carries the transport-neutral
`OrchestrationErrorCode` the layers above already speak. Classification is one
`instanceof` in `orchestrationErrorResponse` (UI + v1) and
`v2CaughtOrchestrationError` (v2). Every pattern list is gone. Wording is free
to change; an unclassified error still becomes a generic 500, which is what an
unexpected fault should be.

`asOrchestrationError` walks the `cause` chain rather than testing the caught
value directly: drizzle wraps a throw raised inside a transaction callback in a
`DrizzleQueryError` whose own message is the failed SQL, so a bare `instanceof`
would drop every failure raised inside `withLockedTable`. That is the same
reason `rootErrorMessage` had to dig for a root cause before.

Three throws stay bare `Error` deliberately — `Table ID mismatch`, `Workspace
ID mismatch`, and `Failed to build upsert conflict predicate` are internal
invariants no consumer classified, and they keep falling through to a 500.
`Insufficient capacity` was in the pattern list with no producer anywhere in
the codebase.

Status changes, all deliberate:

- `'forbidden'` joins the code union so the table-row-limit ceiling keeps its
  403; without it this refactor would have flattened it to 400.
- import-async's table-limit rejection: 400 -> 403, matching the two other
  create routes it had drifted from.
- Renaming a table to an invalid name: 500 -> 400. `validateTableName`
  messages don't contain "Invalid", so no matcher ever caught them.
- Restoring a table that isn't archived, or into an archived workspace:
  500 -> 400.
- A duplicate *column* name stays `validation`/400 rather than becoming a 409
  like a duplicate table name. Both v1 and the orchestration have always
  answered 400 for it; changing a published status is not this refactor's job.

The twelve tests that changed were asserting the substring mechanism itself,
constructing plain `Error`s with magic strings. They now assert the real
contract, plus new cases pinning that identical wording carrying no
classification stays internal and keeps its message off the wire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(api): add v2 endpoints for MCP servers, skills, custom tools, folders, and credentials (#6150)

* feat(api): add v2 endpoints for MCP servers, skills, custom tools, folders, and credentials

* fix(api): correct credential role, skill permission bar, MCP url identity, and custom-tool conflict mapping

* fix(api): align credential mutation gating, provider-outage status, and unique-violation conflicts

* fix(api): close unique-violation, revival, orphan-write, and env-rename gaps

* fix(api): treat every provider-outage code as unavailable on create and update

* fix(credentials): use the shared outage predicate on the session update path

* fix(contracts): anchor the predicate double-cast annotation to the cast

`check:api-validation:strict` counted 9 unannotated double-casts against a
baseline of 8, failing CI. The predicate leaf schema was annotated, but the
annotation sat above the declaration while the checker anchors on the line
carrying the cast — five lines below, at the close of the object literal. The
scanner walks back at most three lines and stops at the first non-comment one,
so it hit `value: z.unknown().optional(),` and never saw the reason.

Splitting the object schema from the cast puts them adjacent, so the existing
reason binds. No behavior change — the cast, the schema, and the reasoning are
unchanged.

Also lowers the rawJsonReads ratchet 6 -> 5 to match the current count, which
had drifted down; leaving it high lets a removed raw read silently come back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(skills): point the orchestration error contract at its moved module

#6150 branched before #6134, so skill-lifecycle.ts imports
@/lib/workflows/orchestration/types — the module #6134 moved to
@/lib/core/orchestration/types. Git merged a file deletion on one side with a
new file referencing it on the other: no textual conflict, broken build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(knowledge): make lib/knowledge/orchestration the single implementation (#6154)

* refactor(knowledge): make lib/knowledge/orchestration the single implementation

Knowledge base create was implemented four times — the internal route, v1, v2,
and the copilot tool — and the orchestration around the shared write had
drifted. Extract it the same way lib/table/orchestration was: services write,
orchestration decides which writes run, guards them, audits them, and returns a
transport-neutral failure.

Behavior converged, not preserved:

- One chunking default (DEFAULT_CHUNKING_CONFIG). The agent defaulted minSize to
  1 against the API's 100, so identical input produced differently-chunked
  knowledge bases depending on who created it. The agent path now chunks at 100.
- Every successful mutation is audited inside the orchestration function. The
  copilot tool called recordAudit zero times, so agent-created knowledge bases,
  document uploads, updates and deletes left no audit trail at all.
- Failures classify by class, not by message text. The knowledge service errors
  are OrchestrationError subclasses and storage-quota rejections throw a shared
  StorageLimitExceededError, replacing four separate message greps for
  "already exists" / "does not have permission" / "storage limit".

delete_connector reported the opposite of what happened. It reached the route
through an internal HTTP self-call that sent no query string, so the route's
keep-documents default always applied while the agent told the user the
documents had been removed. The self-call is gone — all four connector
operations run in-process — and the orchestration returns the real counts.

Also:

- OrchestrationErrorCode gains 'payload_too_large' (413 / PAYLOAD_TOO_LARGE).
  Without it, dropping the storage-limit message match would have regressed the
  documented 413 on knowledge base create and document upload to a 500.
- messageForOrchestrationError renders a route's own wording for an unclassified
  fault, so a driver's message no longer reaches the client on a 500.
- v1 and v2 knowledge base update now forward actorUserId, which the service
  requires for a workspace move; both omitted it.
- The connector DELETE route reads deleteDocuments through parseRequest. Its
  contract declared z.boolean(), which would have rejected the string a query
  param actually is.
- Drop the 409 from POST /api/v2/knowledge/{id}/documents in the OpenAPI spec.
  Nothing on the upload path throws a conflict; it was only ever reachable by
  the message match this change removes.

Behavior change worth noting: a v1/v2 PUT carrying only the workspaceId scope
field and no actual updates now returns 400 rather than 200 with the unchanged
knowledge base.

Deliberately deferred: document update remains internal-only. Extracting
performUpdateKnowledgeDocument makes exposing it on v1/v2 a contract and a route
away, but that is a new public surface rather than part of this consolidation.

* fix(knowledge): make connector create atomic and stop flattening failures

Review round 1 on #6154.

- Resolve the billing payer before the connector is committed, not after. A
  malformed attribution header rejected post-commit left a live connector behind
  a 500, and a retry created a duplicate plus duplicate sync work. Manual sync
  resolves before writing its audit for the same reason.
- Let the source-config validator carry its own failure class. Collapsing every
  rejection to `validation` flattened the connector PATCH route's 401 (stale
  stored credential) and 409 (missing workspace context) into a 400.
- Add `unauthorized` to OrchestrationErrorCode. It is the class that 401 was
  already expressing on this route, and the v2 vocabulary already had
  UNAUTHORIZED; only the shared union was missing it.
- Report a knowledge base that exists but failed to archive as failed, with the
  reason, rather than as not found. The copilot delete loop folded every
  non-not-found failure into `notFound`, telling the user it was never there.
- Route copilot failures through the same message helper the HTTP surfaces use,
  so an unclassified fault's raw text (a driver's failed SQL) no longer reaches
  the agent verbatim while the UI and public APIs get the generic wording.

* feat(api): expand the public v2 files surface (#6160)

* feat(api): expand the public v2 files surface

Adds folder support, rename/restore, move, bulk archive, share, and content
replace to /api/v2/files, so managing files by API no longer stops at
upload + download + archive-one.

Routes are thin: auth -> parse -> perform* -> serialize. Share and content
replace get their orchestration extracted first so the session routes and
the public ones cannot diverge on the effective-authType resolution, the
EE public-sharing gate, or the storage-quota classification.

Presigned upload stays session-only: presign does an advisory quota check
and the real debit happens in the separate register step, so a caller that
never registers leaves unaccounted bytes with no reaper. The buffered
multipart path debits inside uploadWorkspaceFile's own transaction.

* fix(files): classify folder and content failures instead of 500ing them

Bugbot round 1. The v2 routes map errorCode straight to a status, so every
manager failure that arrived unclassified became a 500 for what is really a
caller-fixable 400 or 404.

- Folder manager throws OrchestrationError: missing target/folder -> not_found,
  reparent cycle / self-parent / restore-into-archived-workspace -> validation.
- File manager does the same for the in-transaction 'File not found' paths that
  the earlier pass missed.
- updateWorkspaceFileContent's outer catch re-wrapped everything in a bare
  Error, which stripped the class off StorageLimitExceededError and the new
  not_found alike. It now rethrows a classified failure untouched and attaches
  cause to the generic wrap, so asOrchestrationError can still walk the chain.
- Every remaining perform* gained the asOrchestrationError branch.
- renameWorkspaceFile returned the pre-update read, so the v2 PATCH reported a
  stale updatedAt; it now returns the timestamp it actually wrote.

Docs: upload auto-suffixes a duplicate name rather than rejecting it, matching
the in-app uploader. The description claimed 409 and was simply wrong.

* fix(files): surface a failed upload read-back as the real error

getWorkspaceFile swallows a query failure and returns null unless throwOnError
is set, so a transient blip on the post-upload read reported as 'file could not
be read back'. Distinguish the two: a real null after a just-committed write is
an invariant break, a query failure is itself.

* revert(api): drop the dedicated v2 file-folder routes

File folders already live in the shared folder table as resourceType 'file'
(#6045 cut them over, #6051 dropped workspace_file_folders), and the remaining
file-specific folder machinery is being folded into the generic folder engine.
Publishing /api/v2/files/folders/** would pin that transitional split into a
public contract we'd then have to keep or break.

Files stay folder-aware — folderId/folderPath on the projection, folderId on
upload, and the move route — because a folder id is a folder.id and survives
the unification untouched. Folder management belongs on /api/v2/folders once
that surface serves resourceType 'file'; until then there is no v2 way to
enumerate file folders, which is the deliberate gap.

The orchestration classification fixes stay: the internal routes and the
copilot file-folder tools still call those perform* functions.

* fix(files): classify upload failures instead of matching their wording

Bugbot round 2. uploadWorkspaceFile had the same outer-catch rewrap that
updateWorkspaceFileContent did, so a blown storage quota reached the route as a
bare Error and the v2 handler recovered the status by substring-matching the
message. Any rewording silently demoted a 413 to a 500.

- uploadWorkspaceFile rethrows a classified failure untouched and attaches cause
  to the generic wrap.
- FileConflictError is now an OrchestrationError('conflict'), so a duplicate name
  classifies like every other conflict. Its 'FILE_EXISTS' discriminator had no
  readers and is gone; the instanceof checks elsewhere still hold.
- The v2 upload handler uses v2CaughtOrchestrationError, dropping all three
  string matches.

Also documents that bulk-archive is best-effort: unknown or already-archived ids
are skipped rather than failing the call, and deletedItems is what actually
happened. That asymmetry with the single-id DELETE was undocumented.

* feat(api): add search, filtering, and sorting to the v2 list endpoints (#6189)

* feat(api): add search, filtering, and sorting to the v2 list endpoints

One convention across every v2 list, documented on lib/api/contracts/v2/shared.ts:
`search` (case-insensitive substring on the resource's natural name field),
`sortBy` + `sortOrder` (per-resource enum, never a free string), and enumerated
resource-specific filters. Reuses the sortBy/sortOrder pair v2 logs and v2
knowledge-documents already ship rather than inventing a third dialect
alongside the Logs filters and the Tables predicate grammar.

Every filter and sort is pushed into SQL. GET /api/v2/files previously read the
whole scope and sorted/sliced it in JS; it now goes through a new
queryWorkspaceFiles that filters, orders, and bounds the page in one query.

Cursors are stamped with the sort they were minted under, so replaying one
under a different sort is a 400 instead of silently duplicated or skipped rows.

* fix(api): validate v2 cursor key values and compare timestamps at ms precision

Two review findings, fixed at the root by making a keyset key own its cursor
codec instead of hand-writing a decoder per sort.

Cursor key values are caller-controlled, and matching the sort stamp and key
count was not enough: an unparseable timestamp or a non-numeric size reached
the query as an Invalid Date or NaN and surfaced as a 500. Each key now type-
checks its own value and rejects a cursor it cannot hold, which both routes
render as the documented 400.

Timestamp keys now order and compare on date_trunc('milliseconds', col).
Postgres keeps microseconds and defaultNow() populates them, but a cursor value
round-trips through a millisecond-only JS Date — comparing the raw column
against the truncated value re-admitted the page's own last row, duplicating it
and stalling pagination outright at a page size of one. Reachable today via
workspace_files.updated_at, which insertFileMetadata leaves to defaultNow().

* feat(api): complete the v2 workflows resource with versions and CRUD (#6184)

* feat(api): complete the v2 workflows resource with versions and CRUD

Adds version listing/detail plus create, update, and delete to the v2
workflows surface, which previously covered only execution and deployment.

- GET /api/v2/workflows/[id]/versions — cursor-paginated, newest first
- GET /api/v2/workflows/[id]/versions/[version] — version + pinned state
- POST /api/v2/workflows, PATCH and DELETE /api/v2/workflows/[id]

All six delegate to the existing orchestration and persistence helpers;
no new domain logic.

* fix(api): check folder containment before lock state; reject malformed version cursors

assertFolderMutable walks a folder's ancestor chain without filtering on
workspace, so inspecting it before containment let a caller tell a locked
folder in someone else's workspace (423) from a nonexistent one (400).
Create and update now assert containment first, matching the ordering
import-workflow.ts already uses.

A version cursor that decodes to JSON without a numeric version filtered
every row out and returned an empty page with nextCursor null, which reads
as a clean end-of-list. Malformed cursors are now a 400.

* refactor(api): page workflow versions in the persistence helper

listWorkflowVersions read every version row and the route filtered and
sliced the result in memory, so the response was bounded but the query
was not. It now takes optional limit/afterVersion, turning the cursor
into a real keyset query; the route asks for limit + 1 and only trims
the has-more probe. Both params are optional, so the internal, v1 admin,
and copilot callers are unchanged.

Also restores the untouched GET handler in [id]/route.ts to its original
formatting — collapsing its signature had re-indented the whole body and
buried the actual additions in whitespace churn.

* feat(api): expand v2 tables with stateless multipart transfers (#6188)

* feat(api): expand the public v2 tables surface

Adds 16 operations so a v2 caller can do what the internal surface can:
rename/move/lock a table, restore it, manage saved views, run enrichment
columns, look up rows, and import/export with observable job control.

Extracts lib/table/orchestration/import.ts (performTableCsvImport,
performCreateTableFromCsv) and lib/table/export-stream.ts from the
first-party routes, then repoints those routes at them, so v1 and v2
cannot drift on what an import or export actually does.

events/stream, metadata and dispatches stay internal — they are editor
state, not public API.

* fix(api): make v2 table PATCH all-or-nothing and name the lock in every 423

Greptile P1: PATCH applied locks, rename and move as three sequential
transactions, so a folder rejected mid-request left the earlier writes
persisted while the response reported failure — and the schema-changed
signal was skipped, leaving open clients on stale state. Every rejectable
condition now runs before the first write, and the signal fires whenever
anything did land.

Cursor: v2TableLockError dropped the lock kind, so async import, column
run, enrichment and table mutations returned a bare LOCKED. A table has
four independent locks, so the caller could not tell which to clear.

* fix(api): report the lock kind on classified 423s too, not just thrown ones

The previous commit named the lock only where the rejection was thrown and
caught at the route boundary. Where it instead arrives as a classified
`errorCode: 'locked'` outcome — delete table, delete row, update column,
and the table mutations — the kind was dropped, so those 423s stayed
unactionable while their neighbours improved.

The orchestration results now carry `lock`, and a shared
`v2TableOrchestrationError` renders both arrival paths into the same
`{ code, message, details: { lock } }` body. `details` is omitted rather
than sent null when the kind is unknown, so a caller branching on it sees
absence instead of a phantom value.

* fix(api): make async table imports observable, not just startable

`POST /import-async` pointed callers at `GET /api/v2/tables/jobs` to track
progress, but that endpoint filters to `type = 'export'` — imports are
derived onto the table itself, one write job at a time, and exports get a
separate list precisely because they are excluded from that derivation.
The public Table shape omitted those derived fields, so an async import
could be started and cancelled but never observed to completion, failure,
or progress. That is the gap the import/export/job-control set was meant
to close.

Table now carries `job` — id, type, status, rowsProcessed, error, or null
when idle — and the import-async docs point at the table rather than the
export list.

* feat(api): make v2 table PATCH state which operations landed on failure

Greptile held the PR at 4/5 on the residual non-atomicity and named two
acceptable resolutions: make PATCH atomic, or have the contract adopt and
expose partial-success explicitly. Atomicity would mean threading one
transaction through renameTable, moveTableToFolder and updateTableLocks —
three shared service functions with four non-test callers including the
first-party route and two copilot tools — and deferring their per-operation
audits to commit time. That is a refactor of shared write paths well
outside this PR.

So the contract states it instead. Every rejectable condition is already
pre-validated, so a failure here is a genuine fault; when one follows a
successful operation the error now carries `details.applied` listing what
is live. Absent when nothing applied, so its presence always means "these
changes took effect despite the error". Documented on the operation.

`v2ErrorForOrchestration` gained the optional `details` this needs.

* fix(api): make table lock flags read-only on the public v2 surface

The new PATCH /api/v2/tables/[tableId] accepted a `locks` object, gated
on workspace admin plus the table-locks feature. That still lets an API
key clear the guard placed there to stop it: `write` is the floor for
the endpoint, and admin keys are ordinary API keys, so a lock is no
longer a boundary the key cannot cross.

Locks stay readable on the table resource and enforcement is unchanged
(a locked verb still returns 423). Changing one is now a first-party
admin action only.

The v2 body is declared here rather than reusing the first-party
updateTableBodySchema, which keeps its `locks` field so the UI can still
toggle them. It is .strict(), so a request carrying `locks` is rejected
with a 400 naming the field instead of silently succeeding without
applying it.

* fix(api): keep reporting applied operations when the PATCH re-read fails

The composite table PATCH promises that `error.details.applied` names the
operations that are live despite an error, but `applied` was scoped
inside the try. A rename or move that committed and was then followed by
a throw in the final re-read — or a re-read finding the table archived —
returned a bare 500/404 with no details, telling the caller nothing had
landed. It would then retry into a duplicate-name conflict or repeat the
move.

`applied` is now function-scoped so every post-write exit carries it: the
404 on a missing re-read, a thrown lock error, a classified orchestration
error, and the generic 500. `v2TableLockError` gains the same
`extraDetails` parameter `v2TableOrchestrationError` already had.

* feat(api): add workflow group writes to the v2 tables surface

v2 exposed GET /groups but none of the writes, so the public API could
run an enrichment or workflow column and read its binding, but never
create one. A caller could add a plain data column and trigger the
machine; wiring the two together still required the UI.

Adds POST/PATCH/DELETE on /api/v2/tables/[tableId]/groups. The group is
the unit that fills columns — one group feeds several — so creating one
creates its output columns in the same call, matching the first-party
shape rather than inverting it onto the column endpoint.

Four departures from the first-party body, all public-surface concerns:
- group.id is optional and server-generated. The UI mints an id to render
  optimistically; a public caller has no such need and a client-chosen id
  is a collision waiting to happen.
- outputColumns[].workflowGroupId is dropped from the body and stamped
  from the resolved group, so it cannot disagree with it.
- autoRun defaults to false. First-party defaults true so a UI add fills
  cells immediately; here it would make one POST fan out a metered run
  across every existing row.
- A group naming neither a workflowId (type manual) nor an enrichmentId
  (type enrichment) is a 400 rather than a half-specified group the route
  has to guess about.

Also rejects an outputColumns entry no group output feeds — the two
arrays are joined by column name, and the first-party client builds both
from one picker so it cannot desync, but a public caller can.

Workspace containment on workflowId is asserted before it is persisted,
on create and on any update that re-points the group; without it a table
becomes a way to invoke workflows the key cannot otherwise reach.

* improvement(api): make v2 table import and export async-only

Drops the three synchronous entry points: POST /tables/[tableId]/import,
POST /tables/import-csv, and GET /tables/[tableId]/export.

Sync import tied a write to the lifetime of an HTTP request. The body
*was* the data, so it carried a 10 MB cap that Next silently truncates
past — a partial import reporting success. It also had no job, so a
timeout mid-write left rows in place with nothing to poll and nothing to
cancel. The async path reads the file from storage instead: upload via
POST /api/v2/files for a key, start with POST /import-async, watch
GET /tables/[tableId] -> job, stop with POST /job/cancel.

Sync export carried no such hazard, but one shape per operation beats
two: with both removed the surface has exactly one way to move a table
in or out, and the CLI wraps the extra calls.

This also removes the last multipart handling in v2 tables. Those were
the only routes bypassing parseRequest — form fields were parsed by hand
against separate form schemas, outside the contract system every other
v2 write goes through.

Create-a-table-from-CSV is now two calls: POST /tables, then
/import-async with createColumns. csvImportModeSchema is append|replace,
so there is no single-call create.

Route baseline 1064 -> 1061.

* docs(api): correct the import-async note about upload size limits

The docstring claimed there is no synchronous upload endpoint and so no
request-body size cliff. Both are wrong: POST /api/v2/files is a
synchronous multipart upload with a 100 MB cap, and it is the only v2
upload path (presigned is deliberately absent).

What async-only actually bought: the cap went 10 MB -> 100 MB, it fails
on an explicit size check and a bounded body read rather than a proxy cap
that silently truncates, authorization completes before any body is
buffered, and the table write is a job that can be watched and cancelled.

* feat(api): unify file and table transfers

* improvement(api): make multipart transfers stateless

* fix(api): make table import completion retries idempotent

* feat(v2-tables): paginate the table list

`GET /api/v2/tables` returned every table in the workspace in one response —
it used the cursor envelope but hardcoded `nextCursor: null`, and had no
`limit`. That was defensible when tables were only created through the UI;
`POST /api/v2/tables` is public now, so a script can create them in bulk and
the list has no way to ask for less.

Adds `queryTables` alongside `listTables` rather than changing it, so the
internal callers that genuinely want the whole scope are untouched — the same
split `queryWorkspaceFiles` / `listWorkspaceFiles` already uses. Filter, order
and slice all run in the query, so a `search` never costs a full-workspace read.

A cursor whose values don't bind raises a validation error instead of being
coerced to "no filter", which would have silently served page 1 under a resumed
cursor. The keyset closes on `id` so a page boundary inside a run of equal names
or timestamps stays stable.

The shared `LimitQuery` doc component said "Maximum rows to return"; it now
serves the table list too, so the wording is resource-neutral.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(api): add multipart knowledge document uploads

* fix(api): keep usage admission at knowledge upload session creation

* feat(knowledge): wire knowledge base uploads to multipart sessions

* fix(knowledge): refuse to abort an upload once a document is bound

* fix(uploads): prevent multipart cleanup races

* Unify file creation and signed upload sessions (#6264)

* feat(uploads): unify signed upload sessions

* fix(uploads): preserve attachment storage semantics

* feat(files): add authored file creation

* fix(uploads): omit hoisted S3 metadata headers

* feat(api): add file metadata endpoint

* improvement(api): scope folders to resource paths (#6284)

* improvement(api): scope folders to resource paths

* fix(files): serialize folder resolution with uploads

* fix(files): release folder lock before upload setup

* fix(api): normalize folder paths and unblock resource mutations

* fix(api): make resource cleanup and metadata consistent

* improvement(uploads): persist multipart sessions in postgres

* fix(db): store table row trigger timestamps in UTC

* improvement(api): default folder deletion to non-recursive

* fix(billing): unify chat usage source

* improvement(logs): expose trace spans on log detail

* fix(logs): parse list trace spans

* improvement(api): replace workflow jobs with execution resources (#6294)

* improvement(api): replace workflow jobs with execution resources

* fix(api): preserve legacy jobs while preferring v2 executions

* fix(api): make execution polling resume-aware

* fix(ui): hide async examples for public workflows

* fix(api): bridge resume queue visibility lag

* feat(api): add v2 workflow resume endpoint

* fix(api): project pending resume attempts

* fix(api): prefer terminal logs over stale resumes

* improvement(api): unify v2 resource query layers (#6319)

* improvement(api): unify v2 resource query layers

* fix(api): address v2 review findings

* fix(api): preserve cancelled queue status

* fix(api): guard cancelled job transitions

* fix(api): close v2 resume and log gaps

* feat(api): rename v2 executions to runs

* feat(api): split credentials and secrets

* feat(api): add workspace metadata and email attribution

* improvement(api): consolidate public v2 route handling

* improvement(files): centralize operations across APIs and Copilot (#6392)

* improvement(files): unify rename authorization

* chore(skills): add file operation migration guide

* improvement(files): consolidate file operation authorization

* improvement(files): extract shared operation foundation

* improvement(api): simplify internal route declarations

* improvement(files): centralize application authorization

* refactor(api): share workspace file name validation

* refactor(files): centralize copilot application calls

* docs(skills): generalize application operation migration

* improvement(api): centralize remaining v2 resource operations (#6412)

* improvement(api): centralize v2 resource operations

* fix(api): preserve custom tool conflict errors

* improvement(api): migrate policy-sensitive v2 reads (#6410)

* improvement(workflows): centralize v2 application operations (#6411)

* refactor(api): migrate v2 knowledge operations (#6413)

* refactor(api): migrate v2 knowledge operations

* fix(knowledge): fail upload completion on dispatch errors

* fix(knowledge): preserve upload retry and VFS errors

* improvement(tables): centralize v2 application operations (#6414)

* improvement(tables): centralize v2 application operations

* fix(tables): preserve run validation and signals

* feat(auth): add scoped internal executor delegation (#6459)

* feat(auth): add scoped internal executor delegation

* fix(auth): derive delegation lifetime from one timestamp

* Include share status in file metadata

* feat(auth): centralize delegated identity policy (#6462)

* improvement(copilot): consolidate application adapters (#6450)

* improvement(api): harden application route boundaries (#6451)

* improvement(api): harden application route boundaries

* fix(folders): reject creates at workspace cap

* fix(knowledge): enforce trusted workspace scope (#6452)

* fix(knowledge): enforce trusted workspace scope

* refactor(knowledge): declare v2 body lifecycle

* finish knowledge application migration

* refactor(knowledge): compose copilot batch commands

* fix(knowledge): parse connector query flags

* fix(knowledge): finalize partial batch effects

* fix(knowledge): align merged application boundaries

* fix(knowledge): close application boundary review gaps

* style(knowledge): satisfy branch biome checks

* fix(knowledge): page connector documents in editor

* refactor: enforce Copilot table application boundary (#6453)

* refactor: enforce copilot table application boundary

* fix(tables): finish application boundary migration

* fix(tables): restore scoped copilot imports

* fix(tables): compose copilot commands atomically

* fix(tables): preserve workflow group scheduling

* fix(tables): complete fixed copilot composition

* fix(tables): reject enrichment output mutation

* fix(tables): complete authorized application boundary

* fix(workflows): migrate Copilot application boundary (#6455)

* fix(workflows): migrate Copilot application boundary

* fix(workflows): finish delegated application migration

* fix(workflows): encode VFS folder aliases

* fix(workflows): close application composition gaps

* fix(workflows): preserve VFS validation errors

* fix(workflows): complete application boundary migration

* test(workflows): format canonical binding coverage

* fix(workflows): scope executor metadata reads

* fix(workflows): bind executor metadata targets

* improvement(skills): align application operation guidance (#6532)

* feat(api): expose v2 resource owners

* fix(api): distinguish visible resource authorization failures (#6537)

* feat(api): generate v2 OpenAPI from contracts (#6509)

* feat(api): generate v2 OpenAPI from contracts

* fix(api): preserve string boolean wire defaults

* fix(api): document file download headers

* fix(docs): use TypeScript CLI with Next.js

* fix(docs): avoid client-rendered theme script

* fix(api): document departed audit default

* feat(api): replace legacy core docs with v2

* feat(api): generate v2 OpenAPI from contracts

* feat(api): refine generated v2 OpenAPI docs

* fix(docs): align localized v2 execution examples

* fix(ci): restore Helm diff and sync audit mock

* fix CI regressions after staging merge

---------

Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Theodore Li <theodoreqili@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: Theodore Li <theo@sim.ai>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 05:45:25 -04:00
Waleed a64ce49af9 feat(incidentio): add on-call, alert, catalog, and team tools (#6529)
* feat(incidentio): add on-call, alert, catalog, and team tools

Adds 19 tools to the incident.io block, taking it from 46 to 65
operations. Every endpoint, param, and response field is taken from the
official OpenAPI spec at api.incident.io/v1/openapiV3.json.

Who is on call had no reachable answer before: the data lives in
ScheduleV2.current_shifts, and both schedules_list and schedules_show
returned it but never declared it. The new incidentio_on_call_now tool
flattens current and upcoming shifts to one row per person, and the two
existing schedule tools now declare the fields they were already
returning.

Also fixes two pre-existing wiring bugs: the block declared an output
named schedule_override while the tool emits override, and the on-call
handoff skill described a lookup the integration could not perform.

* fix(incidentio): stop the alert filter sentinel reaching the API

The has_notes and include_maintenance_window dropdowns default to the
string "any", meaning "do not filter". The params transform skipped the
key in that case, but the executor merges its output over the raw inputs
(`{ ...inputs, ...transformedParams }`), so the sentinel survived and the
tool sent has_notes[is]=any, which incident.io rejects.

The transform now always assigns the key, mapping "any" to undefined so
it overwrites the sentinel instead of leaving it in place. The tool also
only serializes these filters when it actually has a boolean.

Adds tests covering the sentinel, both real boolean values, and the
documented bracket-operator filter syntax.
2026-08-10 22:02:50 -07:00
Theodore Li 5478a690cc improvement(setup): complete knowledge and update flows (#6521) 2026-08-10 23:38:47 -04:00