Commit Graph
6180 Commits
Author SHA1 Message Date
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
Waleed 009f5fea7f fix(logs): record how long a cancelled run had been going (#6686)
* fix(logs): record how long a cancelled run had been going

A cancelled run got an end timestamp and no duration. Every other
terminal transition writes both — the completion path sets them together,
and a paused run already records its elapsed time — but cancellation
writes the log row directly rather than through completion, so it had no
in-memory duration to store and simply omitted the column.

That is not cosmetic. `GET /api/v2/logs` filters on `minDurationMs` and
`maxDurationMs`, and a null column drops the row out of every such query,
so cancellations are invisible to exactly the searches someone runs when
investigating cancellations. The published contract also says the end
timestamp is null only while a run is active, which a cancelled run
is not.

Both cancellation writes now derive the duration in the same statement
from the row's own `started_at`, through one shared expression so the two
cannot drift apart the way they did from the completion path. The end
instant is computed once and reused, so the stamped end and the derived
duration describe the same moment rather than two clock reads.

The instant is bound as an explicit `timestamp` rather than a `Date`:
`started_at` is `timestamp without time zone` holding a UTC wall clock,
and a driver-bound date would infer `timestamptz` and make the interval
depend on the session zone. The floor of one millisecond matches the
completion path, so a run cancelled inside its first millisecond still
records that it ran.

* fix(logs): saturate the cancelled-run duration at the column ceiling

The column is `integer`, so an untimed run cancelled after roughly
twenty-five days overflowed the cast. That cost more than the duration it
was recording: the direct write is caught and logged, so the row would
have stayed `running` with no end timestamp at all, and the
workflow-group write would have failed its transaction and taken the
whole cancellation with it.

Saturating keeps the terminal write. A duration wrong in its last digits
is a smaller lie than a run that never ended.

* fix(logs): record the duration on the other two cancellation writes

Review found the first pass had only covered two of four terminal
cancellation writes. The two it missed spell the timestamp `endedAt: now`
rather than `endedAt: new Date()`, so the search that found the first pair
could never have found them — and one of them is the common case: a
workflow-group run with a live cell sidecar takes that branch, and the
direct cancel skips its own log update whenever group cancellation
handled the run, so it was the only writer for those cancellations.

The other is the paused-cancellation write, which the first pass reported
as already correct on the strength of that same search. A paused run
records its duration when it pauses; cancelling it did not.

All four now derive the duration the same way, and the sweep for the
remaining ones went over every `status: 'cancelled'` write rather than one
spelling of the timestamp beside it.

* fix(logs): let a recorded duration outlive a later cancellation

A paused run measures its own active duration at the pause checkpoint.
The previous commit then had cancellation overwrite that with wall clock
from the start, which quietly redefines the column for those runs to
include the time the run spent waiting rather than working — filling a
gap by discarding an answer someone else had already computed.

The duration now coalesces onto whatever the row already carries, so a
cancellation only supplies the value when nothing else did. Every other
cancellation path leaves the column null, so the change is inert there.

* fix(logs): only a paused run keeps the duration it recorded

Preserving any duration already on the row was too broad. Resuming flips
the log back to running and leaves the pause checkpoint value behind, so
a resumed run carries a stale reading while it is accruing time again;
cancelling it would have frozen that pre-resume figure and disagreed with
the resume completion path, which measures wall clock.

What separates the two is the row's status rather than whether the column
is populated. A paused run is not accruing, so its recorded active
duration stands. A running one recomputes.
2026-08-13 17:55:37 -07:00
Waleed b9b9c9c9a9 fix(canvas): stop phantom ports and a latched-open action bar (#6688)
* fix(canvas): stop phantom ports and a latched-open action bar

Ports surface on hover from a swell painted on the card border, and that
swell was raised with no regard for whether a handle exists behind it. A
Response block mounts no source handle, so hovering its edge raised a
knob no edge could ever leave from. A trigger mounts no target handle,
yet still swelled under a connection dragged from another card, offering
a drop it cannot accept. Gate each direction on the handle that backs it,
and limit a trigger's own swell to its source edge the way the subflow
start node already does.

The action bar latched open for the same interaction. Leaving the card
arms a retract and installs a pointermove listener to track the pointer
across the gap up to the bar; re-entering the bar's band called
openHover(), which cancelled the retract AND removed that listener. No
further pointerleave can arrive once the pointer is off the node, so
nothing was left to close the bar. Keep the listener installed and re-arm
the retract when the pointer moves back out.

Also clear the magnetized port when the pointer leaves the tracking band
onto the action bar: only the in-band path recomputed it, so the last
knob stayed pinned at hover amplitude with the pointer nowhere near it.

* fix(canvas): scope the receive gate, cover both swell directions

Gating the foreign-drag listener also ran the shared pointer-tracking
reset, which belongs to the card's own hover. A trigger sets
canReceiveConnection false while canStartConnection stays true, so the
reset undid the layout effect's :hover bootstrap and left a card that
mounted under the pointer with no source swell until the pointer left
and came back. Skip the listener instead; the effect's own cleanup
already covers a true-to-false flip.

Drop the trigger-only cursorSwellSides restriction. A swell on a
trigger's input edge resolves to a source handle, so an edge genuinely
can be made there — it was a behavior change beyond the bug, not a
phantom.

Cover both directions of the swell gate, and use the shared sleep helper
in the action-bar test. Hoist the constant connection sides out of the
render body so they stop riding the borderPorts dep array.
2026-08-13 17:50:55 -07:00
Vikhyath MondretiandSim Pi Agent 9436a93f1b feat(library): How to Turn a Workflow Into a Reusable MCP Tool (Sim vs n8n, Gumloop, and Zapier) (#6687)
Co-authored-by: Sim Pi Agent <pi@sim.ai>
2026-08-13 17:23:08 -07:00
Waleed 264d4f3469 feat(canvas): add a setting to turn off auto-focus when clicking blocks (#6685)
* feat(canvas): add a setting to turn off auto-focus when clicking blocks

Clicking a block animates the camera to center it, which zooms in far
enough that you lose sight of the rest of the workflow. Add an
"Auto-focus on click" preference (on by default, so existing behavior is
unchanged) that keeps the camera still on click.

Also re-record the auto-connect and canvas-error-notification tooltip
previews and re-encode all three at a smaller size.

* fix(canvas): keep click-marked framing when auto-focus is off, recut tooltips

Gating the whole click branch on the setting also skipped the
userFocusedWorkflowIdRef write, which is what stops <ReactFlow onInit>
from running fitView over the user's framing. That would have blown away
the framing of exactly the users who turned auto-focus off to keep it.
Mark the workflow as user-framed on any plain node click and gate only
the camera move.

Crop the auto-focus preview to the recording's viewport center so the
blocks are legible at the 240px width Tooltip.Preview renders at, and
trim the 2.45s of empty lead off the error-notification preview.

* docs(canvas): correct useAutoFocusOnClick scope to clicks only

The TSDoc claimed the preference also gated arrow-key navigation, which
calls focusBlockInView without consulting it. State the click-only scope
and why arrow-key navigation and block creation are excluded.
2026-08-13 17:05:54 -07:00
Waleed 9fc65863cd fix(v2): hold create to the rules update enforces, and bind the last two cursors (#6684)
Three defects, one shape: a rule applied to one path and not its sibling.
Two were found by probing the live surface after the previous fixes
deployed, and the third by reading for the pattern.

Creating a workflow group through the public surface validated almost
nothing the update path validates. An enrichment group could name an
enrichment the registry does not define, or an output the enrichment does
not have, or carry no output id at all — each a 201 storing a column no
run can ever write, discovered only when the caller later tried to edit
the group and got the 400 create should have given. The workflow half was
the same: a fabricated block-and-path coordinate was stored on create and
refused on update. Create now runs the same two registry helpers and the
same workflow-output check the update path uses.

The discriminator there is the backing workflow id, not the declared
type. The workflow sidebar creates enrichment-template groups labelled
`enrichment` while backed by a real workflow and carrying no enrichment
id, so keying on the label would have refused the first-party create
path outright.

A group's producer type could also be relabelled after the fact into a
state creation refuses. Nothing rejected it and nothing could repair it,
since the update body carries no enrichment id to supply. Relabelling an
enrichment group as workflow-backed is the harmful direction: it keeps
the enrichment id while moving the group onto the workflow branch with an
empty workflow id, so every cell run fails. An update may now only
restate the type the group already has.

The workflow-version and workspace-member lists were the last two paged
reads minting cursors with no route identity, so a token from one parent
resumed another at a position that silently skips rows — the defect the
previous change closed everywhere else. Both now wrap their domain token
with the same scope binding, and the pagination guardrail gained a
declaration of every nested list's parent path param, because the old
one recorded only query filters and so could not tell an unfiltered list
from a forgotten parent.
2026-08-13 17:01:18 -07:00
Waleed 4ff339e9a8 fix(chat): focus the composer when opening a new or existing chat (#6683) 2026-08-13 16:24:39 -07:00
Waleed ab8a64fcae fix(v2): close the defects live probing found (#6681)
* fix(v2): close the defects live probing found

Staging finally deployed the merged release, so the surface could be
exercised for real. Every fix already shipped held up. These are the
defects only live traffic surfaced, plus the ones a static sweep had
found and left.

A cursor named a position in a sequence without naming the sequence.
`cursorScopeKey` hashed only the caller's filters, so any two lists
filtering on nothing but `workspaceId` produced one fingerprint and
accepted each other's tokens: a tables cursor replayed against the
knowledge list answered 200 and silently skipped a row. Table rows never
reached that check at all, so a cursor from one table paged another.
Identity now comes from the route's own contract — method plus resolved
path — because a hand-written name is the step an author forgets, and
forgetting it is invisible. An unresolved path placeholder throws rather
than fingerprinting the template, so a misconfigured route fails on every
request instead of an unlucky one. Every token minted before this is
refused with an accurate message; they are single-walk and unpersisted.

Knowledge search and the document list answered different questions.
Search grouped same-tag filters by slot and joined them with OR while the
list conjoined every filter, so `gte 9` and `lte 2` on one tag returned
nothing from the list and a full billed page from search. Search now
conjoins. The OR grouping replaced an explicit `|OR|` mechanism that was
deleted outright, was never documented in any contract, and cost the
ability to express a range on a single tag; the union it gave is still
reachable as separate searches. The search body also accepted an
unbounded query that was billed and then silently truncated to the
embedding model's window, and ignored the tag-filter cap the list
enforces.

A body over ten mebibytes was reported as malformed JSON. Next's proxy
truncates there, well under this app's fifty-megabyte ceiling, so the
parse failed on a body the caller sent whole and the size branch was
unreachable. The ceiling is now clamped to what the proxy will pass.

Also: a group's output columns accepted a `workflowGroupId` and discarded
it; an enrichment group could never gain an output, because a new output
coordinate demanded workflow metadata a group with no workflow cannot
have; `newOutputColumns` alone reported success and created nothing; a
saved view stored layout references to columns that do not exist while
refusing the same name in a filter; an MCP server stored `retries: 0` as
three and overrode an explicit auth type; a disabled server answered tool
discovery with an unclassified fault; rotating a header server's headers
left it reading connected; and a run whose workflow was deleted reported
the root folder path while also reporting the workflow deleted.

Where the honest fix was out of reach, the contract was corrected instead
of half-fixing the code: the polled run resource rebuilds `error.code` by
matching the persisted message, so it can never report the two codes that
need block attribution, and now says so. `OUTPUT_TOO_LARGE` is removed —
no path ever emitted it.

`triggers` was left alone deliberately. It reads as a closed enum but
production holds 43 distinct values, because a webhook run stores its
provider id; pinning the enum would refuse legitimate history a log
search exists to find. The description now says the vocabulary is open.

* fix(v2): clamp explicit body caps to the proxy ceiling too

The previous commit clamped the default JSON body cap but left explicit
per-route overrides alone, so a route declaring a larger `maxBodyBytes`
still fell into the truncation it was meant to report: the four inline
workspace-file routes at 70 MB and the deployed-chat route at 220 MB.

Next attaches `proxyClientMaxBodySize` to every request and clones the
body unconditionally for any non-GET method on a matched path, pushing
EOF at ten mebibytes with only a warning, so the handler reads a
truncated prefix. Those routes therefore already fail above that size —
as a malformed-JSON 400. Clamping the effective limit inside the two
body readers makes the same request fail as payload-too-large, quoting
the limit actually in force.

One existing test asserted the unreachable case, allowing a sixty-mebibyte
base64 body; it now asserts what the proxy will forward intact.

The inline-file path still advertises fifty mebibytes and cannot exceed
the proxy ceiling until that ceiling is raised, which changes buffering
for every route and belongs in its own change.

* fix(v2): close the two holes the first review round found

Both are places where a fix in this branch shut one door and left a
smaller one open in the same wall.

Letting an enrichment group gain an output meant skipping workflow
resolution — but that resolution was the only thing validating a new
output, so a PATCH began storing coordinates the runner can never fill.
It fills a cell from `result[out.outputId]` and skips an output with no
`outputId` at all, while the writer diffs on that same id and the sidebar
reads and writes by it; the contract leaves it optional. The regression
test added with that fix was itself asserting such a dead coordinate.
Create's registry checks are now two shared helpers both paths call, and
on update an output is exempt only when an identical binding already
existed, so renaming a group whose enrichment has since changed still
works while anything added or repointed must name a real output.
`mappingUpdates` on an enrichment group now says it is inexpressible
rather than resolving an empty workflow id into a missing workflow.

The layout-reference check was handed the tolerant column set, so a
placeholder minted to keep a dangling filter writable also whitelisted a
brand-new layout reference — storing an entry the next read discards,
which is the inconsistency the check was added to remove. Layout now
resolves against the live columns, which is exactly what pruning keeps,
while filters and sorts keep the exemption they need.
2026-08-13 16:12:40 -07:00
Justin Blumencranz 44524d1b07 fix(autolayout): rescue new notes from blocks they were created on top of (#6680) 2026-08-13 15:56:29 -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 046302aa2a fix(sidebar): stop bubbled dragleave events cancelling an in-progress drag (#6679)
* fix(sidebar): close folders a drag spring-opened, and surface reorder failures

* fix(sidebar): stop bubbled dragleave events cancelling an in-progress drag

* fix(sidebar): disarm the spring-open timer when a drag ends
2026-08-13 15:12:19 -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
Theodore Li c155a57c76 fix(desktop): move prereleases to release repo (#6674)
* fix(desktop): move prereleases to release repo

* fix(desktop): validate prerelease authentication
2026-08-13 17:22:06 -04:00
Waleed e2b7335644 fix(v2): stop telling callers something the server did not do (#6676)
A works-as-advertised sweep of the v2 surface found one defect class in five
places: input is validated for shape, then its meaning is re-derived
independently by each consumer — so a filter compiles differently than it
validated, or a write commits and the response then reports failure.

Knowledge tag filters were validated once and re-parsed three times. The
document list read `Number()`, search read `parseFloat()`; the list matched
booleans case-insensitively, search compared against the literal `'true'`; the
list escaped LIKE metacharacters, search did not; and the date pattern was
tested against the untrimmed string the validator had already trimmed. Two of
those paths dropped the predicate entirely and answered 200 with the whole
knowledge base — on a billed endpoint. Values are now coerced once, where the
resolved field type is known, and both builders consume the result. A builder
that cannot compile an already-validated filter now raises instead of silently
widening the result set.

`PUT /api/v2/secrets/{name}` with `scope: personal` committed the secret and
then answered 500, because a user-global write was reported through a
workspace-scoped mirror lookup, and an org admin's inherited access has no
`permissions` row for the fan-out to find. The personal path no longer decides
success from a per-workspace mirror. The `workspaceId` descriptions said a
personal secret lives in one workspace; it does not, and they now say so.

A custom tool could be stored with a schema the read path cannot serialize —
`POST /workflows/import` and Copilot both wrote through name-only checks — so
one row made the whole workspace list 500, and a title-only PATCH committed,
audited, then reported failure. Every write now passes the same guard the
response schema is derived from.

`POST /api/v2/tables` accepted `workflowGroupId` on an initial column. Nothing
can populate it legitimately, and it made every later column-add and group-add
fail with no way to clear it. The key is refused at the boundary, and
`createTable` now runs the invariant every later mutation already runs, closing
the internal and v1 ingresses too. Those invariants moved to a leaf module:
reaching them through `workflow-columns` pulled the executable tool registry
into the tables page graph, taking it from 1,767 modules to 6,999.

An out-of-range upload part number answered 500 rather than the 400 its
published contract promises, because the throw happened above the route's
try/catch and was not an `HttpError`.
2026-08-13 14:19:41 -07:00
Waleed 0758df3682 perf(workspace): stop the sidebar fetching palette data on every route (#6670)
* refactor(prefetch): give the workspace-file seed its own module and tests

* perf(sidebar): let the palette fetch its own lists, so closed routes never register them

* perf(prefetch): seed the file list on the pages that render it, not every route

* fix(chat): guard the chat page prefetch behind the chat flag
2026-08-13 13:39:35 -07:00
Waleed 49ec9a907b fix(loading): debounce selector search, fix two row-cache snapshots, and move Vertex refresh to the app (#6667)
* fix(selectors): debounce the provider search, and keep the list while it refetches

* fix(tables): snapshot row pages from the non-collidable prefix

* fix(vertex): resolve the OAuth token through the app, which holds the client config

* fix(selectors): drop the previous-options fallback so a context change cannot leave stale ones selectable

* fix(selectors): clear the search without waiting out the debounce

* fix(realtime): wait for the streak-reset preconditions instead of sleeping past them
2026-08-13 20:35:37 +00:00
mzxchandraandVikhyath Mondreti 86dbd0acaf improvement(search): make overlap dedupe linear in match count (#6640)
* perf(search): make overlap dedupe linear in match count

Typing in workflow Cmd+F froze the editor for over a second on a large
workflow, with the typed characters landing late in one burst.

`dedupeOverlappingWorkflowSearchMatches` ran `deduped.findIndex(...)` over
the whole accumulated list for every match, recomputing each candidate's
scope key inside the predicate - O(n^2) string builds. The memo re-runs on
every keystroke (the query is not debounced), and a single character is the
worst case because it matches the most.

Reproduced on an 81-block workflow (4530 subblocks, real knowledge-base and
OAuth references). Stage timing during one typing burst:

  searchBlocks merge      13ms
  index                   35ms
  hydration               10ms
  filter + dedupe       1458ms   <-
  resource options         6ms

Overlap is only ever resolved within one value of one subblock, so bucket
candidate indices by scope key and scan the bucket. A bucket holds exactly
the entries the old predicate could match (scope key and range both
present), buckets keep insertion order, and the scan stops at the first
overlap, so the same candidate wins. A per-bucket `maxEnd` skips the scan
entirely when a match starts at or after every kept range's end, which
keeps a single long field full of disjoint hits linear too.

Measured on that workflow, dedupe alone, by query:

  query    matches    before     after
  email        558     6.4ms    0.56ms
  r           1698    53.3ms    0.71ms
  e           3373   232.9ms    1.10ms

End to end in the browser the longest task while typing went from 1534ms
to 247ms, with the same 521 matches either way.

Two traps `maxEnd` sets, both found by adversarial review and both now
pinned by tests:

- `shouldPreferOverlappingMatch` prefers the SHORTER range, and a shorter
  range can end further right than the one it evicts. `maxEnd` has to be
  refreshed on the replacement path, not only on append, or the
  short-circuit skips real overlaps and leaks duplicates into replace-all.
- Widening with a non-finite end would pin `maxEnd` at NaN, and since every
  comparison against NaN is false that silently switches dedupe off for the
  rest of the scope. Only finite ends widen it, which matches how the
  unbucketed scan treated such a range.

`resolvers.test.ts` gains a reference implementation - a transcription of
the original linear scan - checked against the bucketed one over 400
sequential seeds whose generator also emits inverted, empty and non-finite
ranges, plus the two concrete replacement shapes above and a 20k-element
single-scope case that pins the asymptotics. An earlier revision of this
test pinned 8 hand-picked seeds and passed while 7.7% of the seed space
diverged, so the sweep width is the point.

* docs(search): describe maxEnd as a bound, not the exact maximum

Review pointed out the comment claimed `maxEnd` is "the largest range.end
currently kept in the bucket", which stops being true the moment a
replacement swaps in a range that ends earlier - it is only ever widened.

Only the upper bound is load-bearing, so say that. A bound left too high
costs a scan that would have been skipped, never a wrong answer, and the
staleness is capped at one token length because every range spans a matched
token rather than the field.

Also records why the exact maximum is deliberately not recomputed: on the
realistic overlap shape at 10k matches, recomputing measures 45ms against
23ms as written, and 1010ms for the scan this replaced.

* fix(search): preserve infinite range overlap semantics

* test(search): group dedupe equivalence coverage

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-08-13 13:23:49 -07:00
Vikhyath Mondreti 2da885582c test(realtime): wait for the idle read the streak test depends on (#6673)
The guard failed intermittently in CI — `expected 1870 to be less than 1000`,
which is the carried-streak backoff, meaning the streak was never reset before
the phase that measures it.

The middle phase cleared the fault and then waited a FIXED 1000ms for an idle
read to land. It usually did. But the pending backoff from the two failures
before it runs 400–600ms then 800–1200ms, so the next read is due anywhere up
to ~1805ms — and the phase ends at 1800ms. When both jitters drew high the read
arrived after the fault had already been re-armed, so it failed instead of
succeeding, the streak survived at two, and the measurement caught the third
backoff (1600–2400ms) rather than the first.

Waiting for a duration where the thing being waited for is an event is the bug.
Each phase now waits for its own event: two failed reads to build the streak,
then a read that actually RETURNS to clear it.

Also measure failure-to-failure rather than read-to-read. A successful read can
land in the instant after the fault is re-armed, and as the first sample it
would make the gap ~5ms — passing for the wrong reason, the same false-pass
shape review caught in this test last round.

20 consecutive runs green; still fails on the un-fixed reader every time
(1753ms, 1688ms, 1881ms, 1701ms, 1837ms against the 1200ms bound).
2026-08-13 13:15:25 -07:00
Waleed 85c8451a86 fix(blocks): give the detail headers the same tile as everything else (#6672)
The tile consolidation left the large detail headers on their own treatment,
so a block wore one colour on the canvas and another in the header naming it:
the preview panel behind the deploy modal and the logs trace detail both
painted straight from the catalog `bgColor`, showing Start's catalog blue over
a neutral card, and hardcoded `#2FB3FF`/`#FEE12B` for the two subflows.

Adds the 18px header slot to `BlockTile` and points all three headers at it —
preview panel, trace detail, and the editor header, which had been carrying its
own inline copy of the accent rule. `WorkflowTypeIcon` takes an icon class so
the accent chip can draw the larger glyph the header uses.
2026-08-13 12:34:19 -07:00
Waleed 1a62c44913 improvement(canvas): cancel an in-flight edge drag with Escape (#6669) 2026-08-13 12:20:29 -07:00
Waleed 8d319a430a fix(v2): close the correctness gaps the release audit found (#6671)
An end-to-end audit of the v0.8.1 release surfaced one regression the
release itself introduced and a set of filter/cursor gaps that let a
caller's spelling change what a query answered.

An `enrichment` workflow group stored no `workflowId`. The public
contract invites a caller to omit it, the write persisted caller input
through an `as WorkflowGroup` cast that hid the omission from the type
checker, and the response schema still required it — so the write
committed and then the outbound parse threw. Because the list presenter
maps every group through that schema, one such row made GET, PATCH and
DELETE on that table's groups fail from then on, with no public way to
remove it. The cast is gone rather than papered over, so the same class
of omission cannot recur silently.

Knowledge tag filters accepted any operator string and then ignored an
unrecognized one in opposite directions: the document list dropped the
predicate and answered with the whole knowledge base, while search fell
through to equality and answered a different question. Both now reject
at the boundary. `.strict()` is applied on the v2 chain only — v1 has
always stripped unrecognized keys, and the `between`-requires-`valueTo`
rule already closes the mis-cased `valueTo` trap on both versions.

Cursor scopes bound set-valued filters to the caller's ordering:
`all`/`any` clause order and `in`/`nin` operand order in the table
predicate, and the raw `resourceType` text on audit logs, whose query
splits it into an `inArray`. Audit logs is canonicalized on both sides,
because canonicalizing the scope alone would have given two genuinely
different result sets one fingerprint.

Also: the exposed-header list reached only the fallback CORS policy, so
all five matched rules — including the wildcard-origin execute route,
the only one that emits `X-Run-Id` — could not hand a browser the run id
or a 429's `Retry-After`; a bulk row update reported an uncoercible
value only when its filter happened to match; the cost and duration
windows accepted an inverted pair and answered it with an empty page;
and the sortless runs list advised callers to fix a `sortBy` it rejects.
2026-08-13 12:20:16 -07:00
Waleed 9c7b24369f fix(sidebar): keep the right-click context menu open over the collapsed chat flyout (#6665)
* fix(sidebar): keep the right-click context menu open over the collapsed chat flyout

* fix(sidebar): use an absolute import in the context menu test

* test(sidebar): cover item selection after a surrounding menu takes focus

* fix(sidebar): keep the collapsed workflow actions menu open on right-click
2026-08-13 12:00:53 -07:00
Waleed cc7f005b3a feat(emails): add aug-13 what's new broadcast (#6663)
* feat(emails): add aug-13 what's new broadcast

Adds the August 13 broadcast alongside july-1, covering realtime collaboration
in tables/files/workflows and the redesigned workflow editor.

Assets live in public/email/broadcast/aug-13/ so the broadcast is
self-contained: the two feature GIFs plus its own copies of the shared logo
and pre-footer, matching the per-broadcast asset layout july-1 established.

* improvement(emails): update aug-13 broadcast copy and compress its GIFs

Copy revision from the marketing draft, plus asset compression:

- feature-realtime.gif: lossless gifsicle -O3, 77KB -> 55KB, pixels unchanged
- feature-workflow.gif: 1888px -> 1200px (still 2x the 600px render width) and
  a 64-color palette, 1.15MB -> 764KB. Verified frame-identical at render size
  and at 2x crop; frame count and timing preserved.
2026-08-13 11:03:10 -07:00
Vikhyath Mondreti bed25e2a86 fix(realtime): keep the file-doc store reconnecting instead of dying quietly (#6661)
* fix(realtime): keep the file-doc store reconnecting instead of dying quietly

A relay that lost Redis for longer than its retry budget did not degrade — it
went silently split-brain and stayed that way. The reconnect strategy returned
an `Error` after ten attempts, which tells node-redis to give up and CLOSE the
client, and a closed client rejects every command with "The client is closed"
for the rest of the process's life. From that point the task kept serving
clients while its rooms stopped receiving other tasks' updates, its own edits
stopped reaching the shared stream (also the crash buffer between persists), and
seeds, locks and the persist If-Match token all failed.

The tail loop then treated that as a transient read error — `running` is only
false during shutdown, so it retried every 500ms forever, one warning per
attempt. A tab left open overnight produced thousands of identical lines, which
is how the actual failure stayed invisible.

- Never stop reconnecting. This process holds live documents whose only
  convergence path is that connection, so a connection it can rebuild is always
  worth rebuilding. Same capped backoff, now via the shared
  `backoffWithJitter`, and no error return.
- Back the reader off after a failed read (500ms → 10s) instead of retrying at
  the read cadence, re-open a client that was CLOSED — node-redis reconnects a
  dropped client, never a closed one — and log the first failure of a streak
  then one in twenty, carrying the streak length, so an outage stays visible
  without burying itself.

Pinned by a test that models a closed connection: six read attempts in three
seconds before, about three after, and proof the reader is re-opened rather
than abandoned.

* fix(realtime): end the reader's failure streak on an idle read, not a busy one

Review findings, both accurate.

The streak reset sat after the entries were applied, so a blocking read that
timed out with nothing new — the idle steady state — skipped it via `continue`.
A healed outage's count therefore survived through normal polling, and the next
unrelated blip opened at the backoff cap: minutes of avoidable split-brain, and
a log line claiming a failure count it never earned. The streak now ends on the
read RETURNING, which is what proves the connection works.

Also: the new test built a raw `setTimeout` promise instead of the shared
`sleep`, which CLAUDE.md calls out by name.

* test(realtime): assert the retry delay, not a count inside a window

The streak-reset guard could pass on the very regression it exists to catch. It
counted read attempts inside a 1900ms window, and the jittered delay for a
carried streak is 1600–2400ms — so whenever jitter landed below about 0.95, a
second read fell inside the window and the assertion held even though the idle
reads had never cleared `failures`. A single falsification run happened to draw
a long delay, which is exactly how a guard like this goes quiet.

Assert the delay itself instead. The first retry after a reset is 500ms ±20%
(400–600ms); carried over it is the third, 2000ms ±20% (1600–2400ms). Those
ranges are disjoint, so the check no longer depends on which jitter is drawn:
against the old placement it now fails every time (measured 2160ms, 1925ms,
2046ms against the 1000ms bound).
2026-08-13 10:54:20 -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 1e6004259e fix(tools): resolve credentials over HTTP again so token refresh keeps the app's OAuth config (#6662)
* fix(tools): resolve credentials over HTTP again so token refresh keeps the app's OAuth config

* chore(ship): note what to keep out of PR titles and descriptions
2026-08-13 10:48:24 -07:00
Waleed 0c4e674132 perf(server): stop calling our own API over HTTP during render, execution, and tool runs (#6660)
* fix(prefetch): parse the file-folder seed through its contract

The audit found this key was the workspaceFilesKeys bug waiting to recur. The
manager's record type and workspaceFileFolderSchema are two independent
declarations that agree today by coincidence; the seed had no parse, so adding a
column to one would have silently cached a shape a client fetch strips — and
three of its fields are z.coerce.date(), the exact divergence that put ISO
strings under the file-list key.

Its sibling is immune because listWorkspaceFilesWithShares parses at the data
layer. This does the same at the seed, and adds the shape-parity assertion the
key never had. Verified falsifiable: removing the parse turns it red. Doing so
also exposed the existing folder test as fixture-thin — a folder with only an
id, which the contract rightly rejects — so it now uses a real row.

Also points the credential block's fetchQuery at the exported staleTime
constant instead of restating 60 * 1000; it was a fifth producer on that key
free to drift from the four that share it.

* fix(queries): stop a table cell edit throwing, and an upload gate failing shut

Two functional bugs found auditing the query layer.

patchCachedRows walked tableKeys.rowsRoot non-exact, but rowsRoot is a prefix:
the find (search results) and write (pending writes) subtrees hang off it with
non-paged shapes, and the updater's old.pages.map threw on them. It runs inside
onMutate, so the whole cell edit rejected before reaching the server — reachable
as soon as a find entry exists, i.e. after the user searches the table once. The
sibling isDefaultOrderRowsQuery already excluded those subtrees and its docstring
claimed they "never match"; that was only true of the sibling. Both now share one
isRowListQueryKey helper so they cannot drift apart again.

useCloudStorageConfigured combined staleTime: Infinity, retry: false, and the
global retryOnMount: false on a workspace-independent key, so one transient
failure left it errored for the tab's lifetime with no way back — navigating or
switching workspace cannot change the key, and the upload path fails closed, so
cloud-backed uploads stayed disabled until a full reload. useVoiceSettings
carries the same three options and already escapes this with retryOnMount: true;
this one now matches.

Note: hooks/queries/workspace-files.test.tsx cannot load in a git worktree
(pre-existing postcss resolution failure), so CI is the first place that file
runs against this change.

* perf(server): memoize the request-scoped workspace and entitlement reads

The workspace row was read ~3x per workspace route and ~5x on settings, and the
same Max-tier entitlement was resolved twice on one render.

Memoization is deliberately partial. getWorkspaceWithOwner accepts a
transaction and forUpdate, and live callers use both, so only the plain
no-options read routes through the memo; a row read inside one caller's
transaction or under a lock it alone holds can never be served to a later
caller. includeArchived is part of the key so the two variants cannot alias.

Three substitutions were considered and rejected as behavior changes, not
optimizations: hostContext.ownerBilling resolves subscriptions differently from
hasWorkspaceTierAccess and exposes no Max tier, so it cannot answer the
Inbox/Sandbox gates; isOrganizationOnEnterprisePlan carries self-host
short-circuits ownerBilling has no equivalent for; and widening
WorkspaceHostContext to carry the full row would push owner and org ids onto
the wire for every viewer to save a server-side read, since that type is a
response contract rather than an internal struct.

* fix(selectors): key CloudWatch lists by search, and stop a caller erasing the credential gate

The CloudWatch log-group and log-stream selectors forwarded `search` into the
request as `prefix` but left it out of the query key, so every keystroke
resolved to the same fresh entry and no refetch fired. Server-side filtering
was dead: a log group outside the first page could not be reached. An audit of
all 69 selector definitions found these two and no others.

useSelectorOptions resolved `args.enabled ?? definition.enabled(...)`, so a
caller supplying its own gate replaced the definition's precondition rather
than narrowing it. useSelectorDisplayName knows nothing about credentials, so a
card holding a saved value with no credential context ran a query that could
only reject. The two are now conjoined. The detail hooks keep the override
deliberately — resolving one known id needs less context than listing, which
their TSDoc already documents.

The list-key fix has a test, proven to fail without it. The `enabled` change
has none: loading use-selector-query pulls the selector registry and emcn CSS,
which cannot resolve in a git worktree.

* fix(queries): give optimistic rows collision-free ids

generateTempId used Date.now(), so two rows created in the same millisecond
shared an id and the first server response overwrote both — leaving one row
duplicated and the other's real id lost until a refetch. Now uses generateId(),
matching what the workflow mutations already do. Reachable by double-clicking
create, or by any scripted or bulk create.

Also documents the contract of fetchOAuthConnections, which reports an unknown
connection state as disconnected. No consumer reads that field today — both
read names and icons, and connection state comes from useWorkspaceCredentials —
so letting the query reject would blank the suggested-action rows and drop the
credential page to raw provider ids. The note is what stops a future consumer
branching on it silently.

* fix(queries): close six stale-data gaps found auditing the query layer

Each was verified against the mutation that changes the data and the keys that
expose it, not taken on report.

- Workspace usage/credits were invalidated nowhere in the app. Six sites already
  refreshed subscriptionKeys after credits moved — post-run, post-wand, limit
  edits, upgrades, top-ups — and none touched workspace usage, so the credits
  chip and the run gate held their page-load values until a reload. Adds one
  shared invalidateWorkspaceUsage and calls it from all six.
- Knowledge-base list doc counts went stale: document upload, delete, and bulk
  delete invalidated only the detail key, though the list carries docCount.
- Plan switches that do not redirect refreshed only the host context, leaving
  subscription and credit state showing the previous plan.
- The copilot tool-event handler invalidated a raw workflowKeys.list, which
  covers only the active scope and skips the selector prefix; it now uses the
  shared invalidateWorkflowLists like the other thirteen call sites.
- scheduleKeys.byId was a strict prefix of scheduleKeys.schedule, so the two
  addressings aliased, and nothing invalidated byId. De-aliased and invalidated.

Not changed: the CSV preview key already folds in the file version and storage
key, so a content update addresses a different cache entry — version-in-key is
the mechanism there, not a missing invalidation.

Tests added for the usage and knowledge fixes, both proven to fail without them.
The other four live in files that cannot load in a git worktree (pre-existing
postcss resolution failure), so CI is where they first run.

* improvement(queries): make the row-list prefix non-collidable, and share the usage refresh

Two corrections from reviewing the previous commits.

patchCachedRows was fixed with a predicate naming the sibling subtrees to skip —
a denylist that rots the moment a fifth subtree is added under rowsRoot. The key
factory already separated row lists under an 'infinite' segment; it just had no
prefix accessor, so every caller reached for the parent and subtracted. Adding
infiniteRowsRoot lets the walk be an allowlist by construction and deletes the
predicate, the helper, and both docblocks explaining the subtraction.

The searched-rows view is consequently no longer patched by a cell edit and is
left to its own refetch — it holds a flat result, not pages. That is recorded on
the function rather than left to be rediscovered.

The delayed usage refresh was written out three times across two files, a
duplication the previous commit enlarged rather than introduced. It is now one
scheduleUsageRefresh beside the keys it invalidates, which also gives the bare
1000ms a name and one place to change it.

* improvement(prefetch): budget the workspace file seed and narrow its columns

* improvement(api): resolve credentials and checkpoint reverts in-process

* improvement(executor): run router and evaluator provider calls in-process

* improvement(queries): fix a second row-cache collision, and make the memoized workspace read actually dedupe

* chore(test): type the evaluator provider-request helper instead of using any

* improvement(perf): restore the parallel file read, and close the gaps a diff audit surfaced

* improvement(perf): drop a duplicate authorization, parallelize the credential reads, and trim the comments
2026-08-13 09:13:08 -07:00
Waleed 618cee5cb9 test(prefetch): cover the workspace-list seed, and fix two tests that could not fail (#6659)
The audit found the seed contract — the whole point of #6656 — had no test, and
that one existing assertion was vacuous.

- prefetchWorkspaceSidebar and seedWorkspaceList now have coverage: the empty
  list seeds nothing (so the client reaches the route's default-workspace
  creation path), a populated list seeds, a rejected read neither throws nor
  seeds, and a host context for another workspace seeds nothing at all.
  Verified falsifiable — removing the empty-list guard turns the first red.
- The graceful-failure row for prefetchFilesBrowser asserted on the file-list
  key, which that function deliberately never writes, so it held no matter what
  the code did. It now asserts the folder key it owns, and the setup rejects
  the folder read.
- The sidebar was the third hand-rolled copy of the folder prefetch the shared
  helper was extracted to remove; it now calls prefetchResourceFolders too.
- prefetchKnowledgeBases returns early without a userId like every sibling,
  rather than reaching the authenticator and throwing per render.
- Dropped two docblock claims about a `retry` default that no longer applies
  server-side.
2026-08-13 00:36:14 -07:00
Waleed c49751b32e perf(prefetch): stop calling our own API over the wire during server render (#6657)
* perf(prefetch): read the data layer instead of calling our own API over the wire

Four server-render prefetches went out over HTTP to our own routes. With
INTERNAL_API_BASE_URL unset in prod, getInternalApiBaseUrl() falls back to the
public base URL, so each was RSC -> public HTTPS -> load balancer -> back into
the app, awaited inside the render with a second round of auth.

- /home fetched the workflow folder list that the workspace layout had already
  fetched, under the identical query key. Since getQueryClient() builds a new
  client per call on the server, the two never deduped: same data, twice a
  request, once directly and once over the wire. Dropped; the layout's entry
  already hydrates it.
- /home cached raw route JSON under workspaceFilesKeys.list, while
  files/prefetch.ts seeds that same key from listWorkspaceFilesWithShares. The
  contract declares the date fields z.coerce.date(), so consumers hold Dates —
  a file record's type depended on which page the viewer landed on. Now reads
  the same function files/prefetch.ts does.
- tables and knowledge folder reads now call listFoldersForWorkspace, matching
  the sidebar prefetch.

These reads carry no authorization of their own, so each surface proves the
viewer through getWorkspaceHostContextForViewer first and caches nothing when
it fails, leaving the client fetch to reach the route for the real 403. Both it
and getSession are cache()d and already resolved by the layout, so the proof
costs no extra queries.

Left on the wire, deliberately: the tables and knowledge lists, whose cached
shape is the serialized wire shape, and pinned items and members, which have no
exported data-layer function.

* improvement(prefetch): skip the viewer proof when there is no session

Passing an empty-string userId ran a real permission query that could only
return null. Take an optional userId instead and skip straight to the
unauthorized path, matching how the home prefetch is called.

* perf(prefetch): finish removing self-HTTP prefetches and delete the legacy helper

Converts the last four server-render prefetches that called our own API over
HTTP, and deletes prefetch-internal-fetch.ts now that nothing imports it.

- knowledge bases: runs the route's own listInternalKnowledgeBases use case
  with a principal from the same internalSessionAuth policy the route declares,
  then projects through the same presenter and contract. Not a bypass of the
  application boundary — the same path, called in-process.
- tables: extracts the route's list projection into lib/table/wire.ts as
  toTableListItem, which the route and the prefetch now both call. This matters
  because listTablesContract's response schema is a passthrough z.custom, so a
  client fetch caches the route's JSON verbatim. Seeding listTables() directly
  would have put Date objects and the server-only metadata field under a key the
  hook never sees them on.
- pinned items: extracts the route's inline query into lib/pinned-items/queries.ts
  as listPinnedItemsForUser, which the route now calls too.
- workspace members: getWorkspaceMemberProfiles already existed; the prefetch
  calls it directly.

normalizeColumn moves from app/api/table/utils.ts to lib/table/wire.ts with ten
importers repointed. That also removes a pre-existing lib/* -> app/api/* boundary
violation in lib/table/import-runner.ts. No response shape changes: the v1/v2
edits are import-path moves only.

Every converted read proves the viewer first and caches nothing when that fails,
so an unauthorized viewer's client fetch still reaches the route for the real
403. Authorization equivalence was checked by unfolding both paths to
checkWorkspaceAccess rather than assumed.

* improvement(prefetch): collapse the duplicated folder prefetch and unify the call shape

- Extract prefetchResourceFolders. The same eight-line folder prefetch was
  written three times, varying only by resourceType, with the key, stale time
  and mapper kept in sync by hand.
- Adopting it removes the conditional spread from the tables and knowledge
  prefetches. Tables can now early-return, matching prefetchFilesBrowser:
  prefetchResourceListChrome already self-guards on the same cached host
  context, so a null context meant the function did nothing either way.
- Take userId as string | undefined everywhere and guard inside, so every
  prefetch module has one calling convention rather than two.
- Export toWireTimestamp and use it for the create-table response's own copy of
  the same idiom, and drop a cast that the extraction made dead: the parameter
  is already TableDefinition, whose schema is TableSchema.
- Read params and the session concurrently on the tables and knowledge pages,
  matching the files page, and drop TSDoc that restated each prefetch's own.

* fix(prefetch): keep the tables list on its route and cut the executor edge

Reading listTables from a page prefetch put the executable tool registry into
the Tables page server graph — ~4,700 modules, which check:tool-registry-boundary
rejects. lib/table/service reaches workflow-columns by several independent
paths (directly, and through jobs/service and rows/service), so severing one
edge is not enough; untangling that belongs in its own change.

- The tables list goes back through GET /api/table, with the reason recorded so
  the next person does not repeat the attempt. Folders and chrome on that page
  stay on the data layer.
- stripGroupDeps moves to its own leaf module. It is a pure projection over a
  WorkflowGroup, but living beside the group runtime meant every importer of
  lib/table/service paid for the executor to get it.

Net effect on the Tables page graph: 2,186 modules to 1,742.

* perf(prefetch): finish the migration, delete the legacy helper, ratchet page graphs

Answers the question the previous commit left open: the tables list did not have
to stay on HTTP. lib/table/service reached the executor through
jobs/service -> rows/service -> workflow-columns, for one symbol.
pendingDeleteMask is a delete-visibility SQL clause with no executor
involvement, so it moves to its own leaf and that chain is cut. The tables
prefetch now reads the data layer like every other one, and
prefetch-internal-fetch.ts is deleted: nothing in the app calls its own API over
HTTP during a server render any more.

stripGroupDeps likewise moves to a leaf rather than being re-exported through
workflow-columns, so its importers no longer pull the executor to get a pure
projection.

React Query mechanism fixes, all found by audit:
- settings/[section] fired two prefetches without awaiting them. Only a settled
  query is dehydrated, so those were shipped mid-flight; a rejection hydrated
  into an error state retryOnMount: false never retries, leaving the panel
  broken for the session. Awaited now, and the pending-dehydration opt-in is
  removed since nothing streams.
- The viewer profile was prefetched by both the layout and the settings page.
  Separate server QueryClients mean that was a real second read per request.
- prefetchSubscriptionData was dead, and hand-rolled an unannotated raw fetch.
- retry is scoped to the browser. Query core defaults it to 0 on the server;
  stating one value for both opted awaited prefetches into a retry backoff. The
  gcTime default is dropped entirely — 5 minutes is already the browser default,
  and setting it explicitly overrode the server's Infinity, leaving a live timer
  and payload per request.

check:tool-registry-boundary now also ratchets per-page module counts against a
committed baseline, attributing a regression to the import that caused it via a
dominator tree. It caught a +444 regression in this branch by hand; it would
have caught it in CI. Its import regex also missed bare side-effect imports,
so `import '@/tools/registry'` could have slipped past it entirely.

Prefetch guidance added to .claude/rules/sim-queries.md.

* fix(prefetch): correct the extracted module's db imports and stale rationale

Audit findings from the migration.

- pending-delete-mask imported its schema tables from @sim/db rather than
  @sim/db/schema, which the module it came from was careful to split. The
  global test mocks are bound per-entrypoint and only the schema mock exports
  tables, so every suite that reaches pendingDeleteMask would have failed on a
  missing mock export. Restored to the original convention, and the same split
  applied to the new pinned-items queries module before it grows a test.
- The settings prefetch and page justified awaiting with a mechanism this
  branch removed — pending queries being shipped with their promise. Only a
  settled query is dehydrated now, so an unawaited prefetch is dropped from the
  payload entirely. Same conclusion, correct reason, and no longer contradicting
  the rule this branch added.
- Removed the doc block left orphaned above validateSchema when stripGroupDeps
  moved out of workflow-columns.

Skill projections regenerated after trimming the boundary skill.

* chore(table): drop a section separator comment

Separators like these are non-TSDoc decoration that CLAUDE.md already rules
out. This is the only one in a file this branch touches; the rest of the repo
is swept separately.

* chore: remove section separator comments

CLAUDE.md already rules these out ("No ==== separators. No non-TSDoc
comments"), but 546 of them had accumulated across 48 files. They decorate
rather than explain, and they drift: a separator says "Validation" while the
code beneath it moved elsewhere, as one in workflow-columns already had.

Pure deletion — no source line was touched, and lines inside template
literals were skipped so nothing in a generated string changed.
2026-08-13 00:12:45 -07:00
Vikhyath MondretiandClaude Opus 5 e8d278b6e4 fix(files): stop the collaborative editor rewriting and reflowing a document on open (#6652)
* fix(files): stop the collaborative editor rewriting and reflowing a document on open

Opening a file rewrote it. Binding an editor to a seeded document emits a Yjs
update of its own — ProseMirror appends an empty paragraph to any doc that does
not end in one — which the relay saw as a real edit and persisted. Every open
therefore uploaded the file under a FRESH storage key and deleted the old one,
404ing the page's own in-flight content read, bumping "Last Updated" just from
viewing, and churning a blob per open. Worse, a trailing blank line cannot
serialize, so the file never recorded that paragraph and nothing reconciled the
two: each client that seeded without seeing another's contribution stacked one
more. A real document reached 18 against the placeholder's 1 — measured as the
pane growing several hundred pixels the instant the live editor took over.

- Seed and merge through the editor's own normal form (`editorNormalForm`), so
  binding is a no-op and `canonicalizeYDoc` collapses an accumulated run back to
  one. Placed at the collab boundary, not in `parseMarkdownToDoc`: only the CRDT
  has to agree with the editor — every other consumer of the parse renders
  through a real editor that normalizes itself.
- Skip a persist whose projection already matches the durable bytes. Byte length
  is the free reject, so the compare read only happens when a no-op write is
  actually on the table.
- Revoke collaborative readiness on a fatal join. The sticky `syncedOnce` latch
  outlived the document: after a readiness timeout the provider drops `synced`
  so the gate closes, but the latch re-opened it on the offline fallback's seed
  flag — handing back an EDITABLE editor on a document the provider had
  abandoned, with client autosave gated off because collaboration is nominally
  on. Keystrokes went nowhere and vanished on reload, with no error shown.
- Recover from a superseded storage key instead of stranding the reader: a 404
  re-resolves the file record, so the read re-keys onto the current object. And
  do not focus-refetch durable bytes while the relay owns durability.
- Prefetch the workspace file list in the layout, where the sidebar already
  reads it. `HydrationBoundary` defers an already-seen query to an effect that
  SSR never runs, so a page-level prefetch of that key could not reach the
  server render — the file route rendered a spinner and disagreed with the
  client about the header's markup (a hydration mismatch).
- Load the document font with `display: block`. A swap repaints prose in
  metric-adjusted Arial first, so paragraphs re-wrap when the real face lands.

Also: a detail-route `loading.tsx` (the segment was inheriting the list chrome),
`normalize.ts` renamed to `field.ts` now that it holds only the field constant,
and the duplicate `COLLAB_DOC_FIELD` in the streaming path folded into it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(files): serve a whole document on join, and stop persistence deadlocking

Opening a file right after an edit replayed it — the block you had just moved
moved again, in front of you. A room rebuilds itself from the file's Redis
stream one entry at a time, into the same Y.Doc that fans every update out to
its room, and the join attached the socket before that finished and before the
server seed. So a client was never sent the document; it was sent the
document's history, and it watched that replay. The new join-readiness test
reproduces it exactly — ['AAA', 'BBBAAA'] where one state was due — and fails
without the fix.

Underneath it, persistence had deadlocked. The If-Match token is a REMEMBERED
timestamp: held in the room, which dies with it, and in a cluster key written
fire-and-forget. A relay that exits in the moments after a successful write
comes back holding a version older than the file's, every persist then fails
the CAS, and because a conflict neither writes nor advances the token, that
document can never be persisted again. Measured on a live file: token
1786594348911 against a content version of 1786594418409, with 103 unpersisted
entries still in the stream. The durable markdown froze there — and the
editor's placeholder is built from it, so every reload painted the pre-edit
document and the live one corrected it on screen.

- Assemble a room before attaching a client to it. The join awaits the stream
  catch-up and the seed, so the first sync IS the finished document, in one
  message. The seed is memoized on the room, so concurrent joins wait for the
  same one instead of the second being served an empty doc; a task that loses
  the seed lock PULLS the winner's seed from the stream rather than waiting for
  the tailer to push it, which is what left a freshly uploaded file read-only
  until its readiness deadline lapsed.
- Drop the client-side quiet-frame gate that was standing in for this. It was
  unsound in both directions: it delayed a document that was already correct,
  and it opened mid-flight anyway whenever updates arrived more than a frame
  apart, which is what a cross-region Redis and a long room history produce.
- Await the cluster version write after a successful persist. It is the only
  record that survives a teardown, and one round trip after a blob write is not
  a cost worth a wedged document.
- On a version conflict, ask the CONTENT, not the clock. If the file still
  holds the bytes this document last projected — the tag written with every
  persist — then nothing out-of-band exists to protect, so re-sync the token and
  write. Any other bytes and the conflict stands exactly as before.
- Actually run the stale-storage-key recovery. It was requested from inside the
  failing read's own queryFn, where react-query drops it, so nothing re-resolved
  the record and the reader sat on a dead key showing "Failed to load file
  content" until something unrelated refetched. It now runs off that cycle and
  cancels a read already in flight, which could only hand back the dead key.
  While the record is re-resolving the surface reports loading, not failure.
- One document per file, for its whole life. A document rebuilt from markdown is
  a DIFFERENT document to Yjs — its items carry new client ids — so a client
  still holding the old one merges the file into itself, twice, on both sides.
  The seed now stores what it builds (until that row existed, a file opened but
  never edited was rebuilt on every open) and resumes it with a CRDT diff when
  the markdown moved on out-of-band. A document also carries an identity the
  join ack names, so a tab that outlived its room is refused rather than merged.
- Let a browser keep an embedded image. The inline route sent no-cache with no
  validator, so every open re-downloaded the whole image — measured at ~1 MB per
  open of a real document, with the image area blank until it landed. A `key`
  names one storage object and a content write never rewrites one, so those
  bytes are immutable; a `fileId` names the file, whose bytes move, so that form
  still revalidates.

* fix(files): name every collaborative document, and only cache what the URL names

Two findings from review, both real.

A document stored before identities existed is returned by the seed's fast path
on every open, and that path never named one — so those files could never
acquire an identity, and the join-ack guard could never fire for them. That is
the population most likely to have a tab that outlived its room, which is the
case the guard exists for. The fast path now names an unnamed document and
stores it, once: minting without storing would name it differently on every
open and the guard would start refusing clients that hold the very same
document.

The inline route marked a response immutable whenever the caller passed a key,
but a key is resolved to a FILE and the file's current key is what gets
streamed. A content write landing between those two reads would serve the new
bytes under a URL naming the old object — and cached for a year, that is wrong
forever. The flag is now what it always meant: the URL names the exact object
that was streamed.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:59:19 -07:00
Theodore Li e4019fa39e fix(files): preserve principals when serving generated documents (#6654)
* fix(files): preserve principals when serving documents

* fix(files): address principal serve review findings
2026-08-13 00:34:07 -04:00
Waleed ec70c4d85b improvement(nav): cut prefetch and session-recorder waste (#6656)
- Seed the workspace list instead of prefetching it. The empty-list case was
  signalled by throwing inside queryFn, which the retry: 1 default re-ran the
  entire read to re-derive, a retry delay later. Log the failure path, which
  was silent — contract drift would have degraded into every viewer
  waterfalling with nothing in the logs.
- Drop non-painted nodes from rrweb snapshots via slimDOMOptions. Enumerated
  rather than true/'all' so headTitleMutations stays off and replays keep
  document.title.

Prefetch concurrency and await semantics are unchanged, so sidebar paint
timing matches staging.
2026-08-12 21:19:45 -07:00
Waleed 29853fbbcf improvement(docs): make the API reference read as code and unify its type token (#6653)
* improvement(docs): make the API reference read as code, and unify the type token

The API-page font override matched every span/div/p inside the page, which
outranks the .font-mono class on specificity, so every parameter name, type,
and identifier silently rendered in the body sans face. Exclude .font-mono so
code tokens stay monospace.

Consolidate the three divergent type-slot treatments — plain scalar, union,
and schema reference each carried their own chip definition, differing in
size, weight, face, and box height — onto one code token that reuses the docs
inline-code recipe and the platform's 20px chip height.

Demote the row metadata: 'required' and 'header' were filled pills, 'required'
on the error token, making a constraint the loudest element on the page and a
page of required parameters read as a page of alarms. Both are now uncontained
text, leaving the type token as the only box on the row.

Pin the two 'application/json' labels to one treatment; the Request Body and
Response headers rendered the same string at different weights and faces.

* improvement(docs): mono status-code tabs, and match fumadocs' lucide icons to emcn

Status codes in the example panel are numeric literals and render as code
everywhere else on the page, including the Response header's own trigger, but
fumadocs rendered the strip in the body sans face. Language tabs sit in a
separate container and stay sans — those are product names, not code.

fumadocs draws a few lucide glyphs on API pages that its client-component
overrides do not expose (the heading anchor and the code-block copy button).
emcn strokes at 1.55 and lucide at 2, so those icons read heavier than every
icon around them; match the weight.

* fix(docs): align the auth type chip with every other property row, and wrap example code

The auth row collapses its real `<token>` type and renders the chip through
::after, so the span is only a wrapper — but it still matched the type-token
rule and kept that rule's border, height, and gap. The border drew a second
empty box around the real chip and the gap opened in front of it, because the
collapsed text remains an anonymous flex item; together they pushed the chip
right by roughly 8px that no other row had.

Example-panel code overflowed sideways instead of wrapping: fumadocs sizes the
block with `w-max`, so it grew to its longest line inside a 400px scroller and
the existing pre-wrap never applied. Cap the width, switch break-all to
overflow-wrap anywhere so only unfittable tokens split, and reserve room for
the copy button fumadocs floats over the first line.

* revert(docs): let example code overflow instead of wrapping

Wrapping restarts every continuation line at column zero, and in a JSON body
indentation is what carries nesting depth — so a wrapped response misreports
its own structure. A hanging indent keeps the depth but needs the shiki lines
forced from flex rows to blocks, which breaks the line rhythm.

Removes the pre-wrap rules rather than repointing them: fumadocs sizes the
block with w-max, so the previous rule never took effect and overflow was
already the behaviour on the page.

* fix(docs): tighten array type tokens and keep the union separator legible

An `array<T>` slot holds its angle brackets as bare text nodes, which become
anonymous flex items, so the slot's gap prised `array<` and `>` away from the
type they wrap. Drop the gap and let the union separator carry its own margin;
this also makes the auth row's gap override redundant.

The separator was dimmed twice, by a muted token and again by opacity, which
on the dark chip fill left `string | null` reading as `string null`.

* fix(docs): restore the hidden API key description, and drop dead API-reference CSS

The rule hiding the trailing `In: header` line matched `p:has(> code)`, which
is a shape, not a target — every scheme description in our specs cites a status
code, so the whole explanation of personal vs workspace-scoped keys was
display:none on every API reference page. Match the last child instead, and
shorten the description to one line now that it renders.

The dropdown trigger's hover rule had been left below a new id-qualified base
rule that outranked it, so the trigger could no longer change colour on hover.

Removes what does not run: the four `::-webkit-scrollbar` rules (specifying a
non-auto scrollbar-width makes Chromium ignore them, and Firefox never had
them) and an `order: 2` block whose selectors and declaration the type-token
rule above it already carried.

Names the two values the API reference repeats — the monospace stack, written
out eleven times, and the 12.5px code size, written nine — as --font-mono-stack
and --text-code. Also drops four !important declarations that already won on
specificity, a --text-muted fallback that can never fire, and a lucide selector
subsumed by the one beside it.

* refactor(docs): define the API-reference chrome once, and cut the commentary

The metadata face — size, leading, weight, mono stack — was written out in seven
rules that a comment asked future readers to keep in sync by hand; it is now one
rule those seven consume, each adding only its own colour, content, and order.
The auth row's chip likewise re-derived all eleven declarations of the type
token and now joins that rule, keeping only its label.

Comments were running longer than the rules they documented — 88 added comment
lines against 73 declarations. Trimmed to the load-bearing facts: cascade traps,
browser behaviour, and the bugs a rule prevents. Dropped the block narrating why
the wrap rules were reverted, which duplicated its own commit message.

Also retires a scrollbar token left unreferenced by the webkit removal, moves
the last two fumadocs colours in our own components onto platform tokens, and
brings the callout icon to 1.55 so the docs really do have one icon weight.

* fix(docs): keep the union separator in the type token's own face

The `|` between union members is a classless span, so the page-wide
`span:not(.font-mono)` rule assigned it the body sans face while the members
beside it stayed mono — one chip rendering in two faces.

Applies the inherit reset to every descendant of a type token rather than just
its links, so anything fumadocs nests there later is covered too.
2026-08-12 19:27:32 -07:00
Waleed 738006db33 fix(emails): re-export the wordmark onto its original canvas (#6651)
#6648 shrank the header wordmark by re-exporting the raster edge-to-edge
at 168x80, replacing a 272x164 file whose mark was inset. Same URL, new
proportions — so every email already delivered, which keeps the
width=68 height=41 it was sent with and refetches that URL forever,
now stretches the mark ~20%, and a client still holding the old bytes
squashes it ~22% into the new box. That is the squished logo on staging.

Re-export onto the original 272x164 canvas instead, with the outlines at
their own aspect (the previous asset was itself 3.9% squashed) and the
textBody fill. Old mail renders against its own numbers as before; the
header takes a 43x26 box for 20.7px of ink. A test pins the canvas, since
that shape — not the display size — is what old mail depends on.
2026-08-12 18:29:04 -07:00
Theodore Li 2da80150b8 fix(enrichment): require work email company domain (#6531)
* fix(enrichment): require work email company domain

* fix(enrichment): show exhausted cascades as not found
2026-08-12 20:40:07 -04:00
Theodore Li 51df824451 fix(copilot): align principal lifetime with orchestration (#6649)
* fix(copilot): align principal lifetime with orchestration

* fix(copilot): align workflow lifetime expectation
2026-08-12 20:15:24 -04:00
Waleed f890c89978 improvement(emails): size the header wordmark to the landing navbar's rule (#6648)
* improvement(emails): size the header wordmark to the landing navbar's rule

The email header rendered the wordmark at 34px of ink against 16px body
copy, and inked it #1a1a1a while every other line of the email uses
#434343.

Size it by the rule the landing navbar states: the mark stands 2px above
and below its neighbouring text (18px against 14px chip labels), so 20px
against the email's 16px body copy. Rasterize it from the same brand
outlines the navbar renders, filled with the email's own textBody token,
so the two surfaces cannot drift.

* chore(emails): drop the wordmark generator script

The raster is committed at 4x, so the display box can be retuned without
re-exporting it — the script only ever ran by hand, and the test pinning
the asset to an exact multiple of the box would have forced a pointless
regeneration on any size tweak.

Keep the shared outlines the navbar and 8 other surfaces already render,
record how the asset was produced on the size constant, and assert the
property that actually matters: the asset out-resolves its display box.

* chore(branding): re-export the branding barrel through the absolute alias

Matches the repo's absolute-import rule and the majority of lib barrels
(billing, table, uploads and five others), instead of leaving this file
split between relative and absolute re-exports.

* docs(branding): correct the wordmark comments that named the removed script

Two TSDoc blocks still pointed at scripts/generate-email-wordmark.ts as
how the email raster is produced. Point them at the committed export and
the size constant that records its fill and scale instead.
2026-08-12 16:30:18 -07:00
Theodore Li aeb77ddf14 fix(files): allow document compiler to read referenced images (#6647) 2026-08-12 19:07:30 -04:00
Theodore Li 1faac4e8ef fix(tools): sanitize database execution errors (#6645)
* fix(tools): sanitize database execution errors

* fix(tools): retry transient permission failures

* fix(tools): preserve preflight cancellation
2026-08-12 18:35:30 -04:00
Vikhyath Mondreti 9f8d4d1310 fix(billing): checkout guard, admin panel case (#6641)
* fix(billing): checkout guard, admin panel case

* fix(billing): serialize checkout admission

* fix(billing): release checkout admission claim
2026-08-12 15:32:54 -07:00
Waleed e56b28857c fix(workflow): draw a highlighted edge over the ordinary ones (#6642)
* fix(workflow): draw a highlighted edge over the ordinary ones

An edge's z came from the nesting depth of the container it belongs to, and a
highlighted edge kept that depth like any other. A line one level deeper
therefore sat above it and painted straight through the highlight, cutting it
in half wherever the two crossed.

Give a highlighted edge — selected, or connected to the selected card — the top
tier of the edge band instead. Depth only ever ordered edges against each
other, and once the user has picked one out, being drawn whole matters more
than which container it came from.

The tier stays inside the band, below the cards, deliberately: highlighted
edges were elevated over the cards once before and drew across the chrome of
their own endpoints. A line belongs behind cards, knobs and the action-bar
swell whether or not it is highlighted, so ordinary edges give up the top of
the band rather than the band being widened into the cards.

* fix(workflow): elevate the connection preview edge with the rest

It renders highlighted — its data carries `isConnectedToSelection` — but it was
the one call site left taking a depth tier, so the line being drawn could be
crossed by an ordinary edge in a deeper container. Highlighted now means
elevated with no exception.

Also drop the export on the highlighted tier: nothing outside the module reads
it, and the band's tiers are an implementation detail of `getEdgeZIndex`.

* fix(workflow): give the edge highlight one definition

The z-index elevation I added checked canvas selection only, while the edge
darkens for panel focus too — a block open in the editor lights its edges, and
those stayed depth-tiered, so an ordinary edge could still cut through the
highlight. The bug I set out to fix, on the path I had not covered.

The condition already existed in two places and the second one carries a comment
saying it must mirror the first exactly, because a knob checking fewer
conditions than the line leaves a dark line running into a light knob. Adding
the z would have made a third copy, and the finding here is what the third copy
gets you.

One predicate now, in `edge-highlight`, used by the line, the knobs, and the z.
The canvas subscribes to the panel store rather than reading `getState()`, since
the z has to be recomputed when the open block changes.
2026-08-12 15:27:18 -07:00
Waleed c58a6427f7 fix(workflow): stop a nested block jumping when it leaves its container (#6644)
* fix(workflow): stop a nested block jumping when it leaves its container

`getNodeAbsolutePosition` added the container's header and padding to a child's
position. Those are already in the position: React Flow places a child at its
parent's origin plus its own coordinates, and `clampPositionToContainer` is what
holds it clear of the chrome, flooring it at `LEFT_PADDING` and
`HEADER_HEIGHT + TOP_PADDING`. Counting them twice put every nested node 16px
right and 66px below where it actually renders.

Visible as a block dropping down-right the moment it is dragged out of a Loop,
and as a block landing off-target when dragged into one from the canvas. Also
skewed container hit-testing during a drag and the bounds `fitView` focuses on.

Two callers already knew: both subtracted the same three constants straight back
off to recover a relative position. They now take the difference of two
absolutes, which is what a relative position is. A third place, React Flow's
child `extent`, had its own copy of the numbers — a fourth distinct header
height, 42, against the 40 the card renders — and now reads the same constants
as the clamp, so a drag stops where a drop would put it.

`positionAbsolute ?? getNodeAbsolutePosition(...)` in the fit-view path can also
stop disagreeing with itself: React Flow's own answer carries no offset, so the
two branches returned points 66px apart for the same node.

* refactor(workflow): type the node-utilities block map

`useNodeUtilities` took `Record<string, any>`, so the test fixtures had to be
cast to reach it and nothing in the hook was checked against a real block.

Typing it as `Record<string, BlockState>` surfaced an unsafe read straight away:
the cycle walk re-read `blocks[currentId].data.parentId` after the `while`
condition had tested the same optional chain, on a map where both links are
optional. It now reads the value once and breaks on absence, which is what the
condition was trying to express.

The fixtures follow the hook's own parameter type, so they stay honest without a
cast on either side.
2026-08-12 15:16:12 -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
Waleed 128054ea51 fix(workflow): stop subflows resizing themselves after every load (#6639)
* fix(workflow): stop subflows resizing themselves after every load

A container sized itself from its children, and when a child had not yet
reported a height it used `estimateBlockDimensions` in its place — a guess of
`ceil(subBlockCount / 2)` rows, which read a 39-field Gmail card as 276px tall
against the 112px it draws. The container painted that number, the real height
arrived a frame later, and it visibly resized between the two. Nothing is
persisted, so it happened on every refresh.

A card's height depends on what it actually renders — which rows survive its
conditions, whether it draws a summary sentence, and for a reactive field even
a credential it has to fetch — so the card is the only thing that can know it.
Size only from heights the children have themselves reported, and hold the
container at its current size until they have. `getBlockDimensions` keeps the
estimate for the callers that only need a rough box (clamping a drag, placing a
paste) and is now that same lookup plus the fallback.

Also stop `calculateContainerDimensions` counting the container's chrome twice.
Child coordinates are relative to the container's own origin and are already
held clear of the header by `clampPositionToContainer`, so a child's far edge is
the distance to cover and only the trailing padding is owed on top. Adding the
header and leading padding again left every container 66px taller and 16px
wider than its contents.

* fix(workflow): gate container sizing on this session's reported layout

Two holes in the measurement gate, both from reading the wrong field.

`height` is a persisted column and `data.width` / `data.height` persist a
container's last size, so a block that has not reported yet can still carry last
session's numbers — reachable through paste, import, and checkpoint restore. The
gate treated those as reported and sized from them.

Nested containers had it worse: an inner container with an unreported descendant
handed back its 500x300 default as though it were measured, so the outer
container sized to that and resized again once the descendant filled in — the
same two-step this change exists to remove.

`layout` is in-memory only and written by exactly the two places that know: a
card through `updateBlockLayoutMetrics`, a container through
`updateNodeDimensions`. Reading it means "reported during this session" and
nothing else, and an inner container that is still waiting reports null, so the
outer one waits with it.

`getBlockDimensions` keeps the persisted height and the estimate as fallbacks —
its callers only need a rough box, where a stale height still beats a guess.

* Revert "fix(workflow): gate container sizing on this session's reported layout"

This reverts commit 3cce724832.

* fix(workflow): size containers from a state-aware child estimate

The gate in the reverted commit held a container at its current size until its
children reported. That is worse than it sounds: the size it holds is the
persisted default of 300, the child needs 335, and so the child hung outside
the container until something forced a resize.

Estimate accurately instead of waiting. `getBlockMetrics` derives a card's
height from the block's own state — the sub-blocks its values leave visible, the
summary sentence, the error row — through the same
`calculateWorkflowBlockDimensions` the card calls, and lands on the height the
card goes on to render: 112px for the Gmail card the type-only estimate put at
276px. The pass before the cards report and the pass after now produce the same
container, so there is nothing to gate and nothing to correct.

This also fixes the guess everywhere else it was painted rather than only in the
container path — `estimateBlockDimensions` fed React Flow's node height for
unmeasured blocks, so selection bounds were 276px around a 112px card.

* improvement(workflow): even out the gutter inside a container

Left, top and bottom were 16 and the bottom read tighter than either, because
the 50px header sits above the top gap and gives that edge visual weight the
other two do not have. Taking them to 24 leaves the three gutter-only edges
matching and the bottom no longer pinched.

Right stays 80. The container's output handle sits on that edge, so a child
needs clearance there it does not need anywhere else — chrome rather than
gutter, now said so in the type.

Only reachable as a single constant each because the paddings mean what they
say: each is the gap between a child's edge and the container's, counted once.
While the sizing math added the header and leading padding a second time, the
effective bottom gap was spread across three constants and tuning it meant
reasoning about all of them.

* improvement(workflow): give a container one source for its own gutter

The four paddings and the header height existed twice: as
`CONTAINER_DIMENSIONS`, which sizes a container and clamps its children, and
again as Tailwind literals in `subflow-node-view`, which draws the header and
the content box. Nothing kept them in step and they had already drifted — the
view rendering a 40px header against a constant claiming 50, so children were
clamped 10px below where the header actually ends.

The view now renders from the constants, and the constant follows the DOM at 40.

Match the bottom gutter to the right at 80. The two edges that carry chrome are
now the two that are wider: the container's output handle sits on the right, and
the resize grip in the bottom-right corner spans 40px in from both, so a child
at the 24px gutter width could sit underneath it. Left and top are only gutter
and stay at 24.

* test(workflow-renderer): assert the subflow header's height, not its class

The header renders from `CONTAINER_DIMENSIONS.HEADER_HEIGHT` now, so the class
it used to carry is gone. Assert the rendered height against the same constant
the layout math measures against — the two drifting apart is what this whole
change is about, and a utility-class assertion cannot catch that.

Also set `IS_REACT_ACT_ENVIRONMENT`, which these tests have always needed. React
only treats `act` as supported when it can see the flag, so every render logged
"The current testing environment is not configured to support act(...)" — around
forty lines of it per run, burying the actual failure output.

* fix(workflow-renderer): declare the act-environment global

`vitest.setup.ts` is inside the package's tsconfig, so assigning an undeclared
property on `globalThis` failed type-check (TS7017) even though the tests ran.

* fix(workflow): size a container from one snapshot of the store

`calculateLoopDimensions` took child positions from the live store but child
dimensions from the hook's render snapshot, so it was reading two ages of the
same data. `resizeLoopNodes` walks deepest-first: an inner container resized
earlier in the pass was already updated in the live snapshot and still stale in
the closed-over one, so its parent sized against the old inner box and only
caught up on a later render — a nested container visibly resizing twice, which
is the symptom this branch set out to remove.

Take both from the snapshot the function already reads.

* fix(workflow): size an unmeasured note as a note

Routing every non-container block through `getBlockMetrics` sent notes through
the workflow-card estimate, which counts sub-block rows and an error row a note
does not have. A note that had not reported a height yet got a card's box, so a
container holding one sized itself around the wrong shape.

Give a note its own branch, as the estimate it replaced did: measured height
when there is one, and the height an empty note paints when there is not.
2026-08-12 13:26:23 -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 9aa16e381c improvement(workflow): smooth the running hatch and sit it in the slot's own box (#6638)
* improvement(workflow): smooth the running hatch's slanted edges

The marks read as stepped rather than slanted. A repeating gradient is sampled
once per pixel with no coverage term, so a hard colour stop on an edge 15° off
vertical can only land wholly on one side or the other — there is no partial
value to soften the transition, and the staircase is the whole edge on a mark
this thin.

Ramp each edge over 0.75px, roughly a device pixel, instead of switching colour
at a single offset. That hands the rasterizer the intermediate values
antialiasing would have produced: measured deviation of the edge from its own
straight line falls from 0.28 device px — pure quantization — to 0.05.

The ramps are centred on the offsets the hard stops used, so the 50%-coverage
line does not move: same 75° lean, same 24/2 rhythm, same 26px scroll period.

* improvement(workflow): sit the running hatch in the slot's own box

The hatch was inset 4px into a 24px row, so it stood 16px tall inside a swell
whose slots are 24px — it read as a shorter bar floating inside the row rather
than as the slots themselves filling, and its right end stopped short of where
a hovered slot's fill ends.

Span the row instead. The row already sits inside the container's 2px/3.2px
inset, so occupying it outright puts the hatch in exactly the box a slot's
hover fill occupies: same height, same padding in from the swell on every side.

The end taper has to move with it, since its two numbers were read off the
slot's diagonal at the old overlay's top and bottom (y=4 and y=20). Continuing
that same edge — slope 20/24 — across the full row gives 20px in at the top and
flush at the bottom, so the hatch still ends on the slot's own diagonal.

* fix(workflow): feather both hatch edges, not just one

The trailing ramp straddled the period boundary. Anchored at 0, the mark's
leaving edge ramped 24.735 → 25.485, but a repeating gradient truncates at its
own wrap, so it was cut at 25.11: half the feather, and its 50%-coverage line
pulled 0.19px inward. That edge stayed sharper than the other and the gap
rendered 1.75px instead of 1.93px.

Run the period centre-of-mark to centre-of-mark instead, so both ramps sit
strictly inside it. The stop list still tiles backwards from its first stop, so
the marks land where anchoring at 0 put them — measured pitch is unchanged at
26px and both edges now carry the full 0.75px.
2026-08-12 12:13:37 -07:00
Waleed 1b635417fc fix(v2): give the keyset cursor's timestamp an explicit SQL type (#6636)
Handing back the `nextCursor` from any timestamp-sorted v2 list and passing
it straight in returned 500. The keyset compares millisecond-truncated
timestamps on both sides, and the bound cursor value went out as a bare
placeholder — which Postgres types as `unknown`. `date_trunc` is overloaded
across `timestamp`, `timestamptz`, and `interval`, so `date_trunc(unknown,
unknown)` matched no single candidate and the statement failed outright.

The value was already validated; it just carried no type. Cast it to the
column's own SQL type inside `timestampKey`, so all twelve call sites across
six modules inherit the fix. Derived from the column rather than hardcoded,
which keeps a `timestamptz` column's offset honoured too.

The millisecond truncation is unchanged — it is what stops the page's own
last row being re-admitted.
2026-08-12 12:08:11 -07:00
Justin Blumencranz 7c2ba46cbe fix(cmdk): keep the first result focused and the top fog stable across re-ranks (#6635) 2026-08-12 11:47:35 -07:00
Justin Blumencranz 0e65ca32ed fix(copilot): surface document render failures (#6629)
* fix(copilot): surface document render failures

* Address PR review feedback (#6629)

- validate render errors against workspace-file provenance before returning details\n- cover blocked provenance with a regression test

* Address PR review feedback (#6629)

- mark every non-throwing render failure as a failed dynamic read\n- cover all soft render failure paths with producer-level tests\n\nNote: pre-existing type-check failures in HEIC and provider files are not addressed by this PR.
2026-08-12 11:45:53 -07:00
Waleed 8a0b32862e fix(blocks): give a block one tile everywhere it is listed (#6634)
A block's tile disagreed with the card it named. The canvas brands only
third-party integrations and gives everything first-party its role accent,
but the command palette, connection lists, tag menus and output pickers all
painted straight from the catalog `bgColor` — so Webhook Trigger showed green
in the palette and blue on the canvas it was about to be dropped onto, and the
five roleless first-party triggers showed catalog blues where the canvas shows
neutral.

Read the canvas rule from one place (`hasBlockAccent`) and render it through
one component (`BlockTile`), then point every surface that lists a block at
them: canvas, editor header, preview, toolbar, palette, connection picker,
terminal, logs trace rows, connection lists, tag menus, output pickers and the
tables workflow sidebar.

Folds in the duplication the split had grown: three copies of `TagIcon`, five
hand-rolled tile divs, the toolbar's second encoding of the accent rule, a
third icon-contrast helper on its own brightness threshold, and the dead
`showColoredIcon` prop every caller passed. Tiles now share the chip radius,
and the tile forces its own icon colour so popover and command rows painting
`[&_svg]:text-*` can no longer wash out a pale brand tile.

Large detail headers (preview panel, trace-view detail) keep their own
treatment and are left for a follow-up.
2026-08-12 11:43:38 -07:00