Skip to content

v0.8.14: files finder, internal routes removal, tools audit, usage tab - #7204

Merged
waleedlatif1 merged 21 commits into
mainfrom
staging
Aug 28, 2026
Merged

v0.8.14: files finder, internal routes removal, tools audit, usage tab#7204
waleedlatif1 merged 21 commits into
mainfrom
staging

Conversation

@waleedlatif1

@waleedlatif1 waleedlatif1 commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

TheodoreSpeaks and others added 20 commits August 27, 2026 20:48
* fix(workflows): authorize automated runs by deployment

* test(credential-groups): bind deployed workflow authority
…#7188)

Contract phase for the table-jobs cutover. Migration 0233 moved import job
state into table_jobs and removed every application read and write, but
deliberately left the five import_* columns in place so the then-deployed app
version kept working across blue/green cutover. The follow-up drop was never
written.

The columns have been invisible to Drizzle ever since: the model lost them in
the same release, so every meta snapshot from 0233 onward already omits them
and `drizzle-kit generate` reports no diff. They exist only physically, which
is why this is a custom migration.
…share (#7187)

* fix(storage): stop workspace ledger locks from deadlocking on FK key-share

Workspace storage accounting locked the workspace, organization, and
user_stats rows with SELECT ... FOR UPDATE. Those rows are foreign-key
parents, so a transaction that has already written a billable child row
holds an implicit FOR KEY SHARE on the parent, and the stronger lock is
an upgrade that two concurrent uploads take on each other.

Take FOR NO KEY UPDATE instead. It does not conflict with FOR KEY SHARE,
still conflicts with itself, and is the lock a plain UPDATE of these
non-key counters takes anyway, so the ledgers stay serialized.

* test(storage): cover both payer kinds in the ledger lock-mode assertions

The lock-mode regression tests only exercised the organization payer, so
the user_stats lock branches were never asserted and a revert of just
those would have passed. Parameterize both tests over both payer kinds
and assert the exact call list, so a lock that stops being taken at all
fails too.

* docs(table): correct the stale FOR UPDATE reference in the quota note

The advisory quota check describes createTable's count as the authoritative
FOR UPDATE read; that lock is now FOR NO KEY UPDATE.
Add a workspaceIds allowlist clause to the shared AppConfig gate rules and
apply it to the credential-groups flag, so the feature can be enabled for a
specific workspace without a global rollout.
* chore(docs): refresh product screenshots

* fix(docs): align HubSpot image dimensions
* docs(library): update what-is-an-mcp-server

* Update apps/sim/content/library/what-is-an-mcp-server/index.mdx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Sim Pi Agent <pi@sim.ai>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* docs(library): update ai-agent-vs-chatbot

* Update apps/sim/content/library/ai-agent-vs-chatbot/index.mdx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Sim Pi Agent <pi@sim.ai>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* feat(mcp): add Codex client configuration

* fix(mcp): keep Codex key creation action
…7169)

* fix(docs): stop publishing unsettable params, fix comment blanking

generate-docs never read visibility, so every tool param appeared in the
public Input table -- including params marked visibility:'hidden', which are
shown to neither the user nor the LLM. Several are credential-shaped
(idToken, instanceUrl, apiToken, cloudId), so the docs told integrators they
could set values they cannot reach.

A hidden param is now dropped only when the block declares no subBlock for
it, matched on id or canonicalParamId -- a param can be hidden on the tool
because the block injects it while the block still renders it as a required
field the user types.

blankStringsAndComments kept the first and last character of every match.
That is right for a quoted string, where both are delimiters, but for a '//'
comment the last character is arbitrary source text, so a commented-out
'//   options: [' left an unbalanced bracket that derailed the subBlock
scan. Parsing now throws rather than silently reporting that a block
exposes nothing, since that fallback was the destructive one.

Also corrects the LinkedIn w_member_social consent-screen description, which
read 'Access LinkedIn profile' for a scope that posts on the user's behalf.

* fix(docs): keep hidden params the block mapper supplies

The carve-out only recognized an identity match between a subBlock id and a
tool param, so a block that renames or assembles the value in
tools.config.params was invisible to it -- and the row was dropped even
though the user types it.

Cal.com's attendee (required) is assembled from attendeeName/attendeeEmail/
attendeeTimeZone; JSM's workspaceId comes from assetWorkspaceId; Textract
writes parameters.file from a field whose canonicalParamId is 'document',
which left the Mistral PDF Parser documenting zero inputs.

Collects params written by any accumulator identifier, not just 'result',
since the two real mappers use different names. Object keys are collected
without proving they are top-level, so a nested key can produce a false
keep -- one hard-to-set row is better than hiding a required input.

* fix(tools): reject values that cannot be a path segment

toGuardedString coerced with String(value), so an object reached the wire as
%5Bobject%20Object%5D and a boolean as 'true' -- a doomed request instead of
a clean error, on 44 live call sites. Accepts string, bigint, and finite
non-exponential numbers; everything else throws a named error.

Rejects a number whose decimal text is a rewrite rather than the caller's
value: 1e21 stringifies to '1e+21', and an integer past 2^53 has already
lost digits. A snowflake cannot be repaired here at all -- JSON.parse
destroys it before this runs -- so the doc now says it must arrive as a
string, and cites Box folderId (root = 0) instead.

Corrects the claim that the parser removes only an exact '.' or '..'; the
spec defines 11 removable spellings. The guards are sufficient because
encodeURIComponent escapes '%', not because the others cannot occur.

* test(oauth): pin the LinkedIn write-scope description

Nothing guarded the consent-screen text: utils.test.ts covered only the
Bitbucket and Reddit overrides, and the modal test stubs
getScopeDescription to identity, so a regression to a read-only label for a
posting scope would pass silently.

* fix(docs): stop a comment hijacking the id scan, unhang a Firecrawl reference

The depth-1 walk copied from the raw source at indices where the blanked
copy was at depth 1, so 'id:' inside a string value or a comment landed in
the scanned text and won the first match. With the keep-bias that now means
a phantom id can retain a param the block never exposes. Matching runs on
the blanked text and reads the literal back through a source-index map. No
block in the repo trips this today -- verified across all 305 -- so this is
a latent fix.

Removing the unsettable scrapeOptions row left five Firecrawl Search output
descriptions referencing a name that no longer appears on the page. They
now describe the response condition instead. Pointing them at 'formats' was
not an option: that subBlock is conditioned on scrape/parse/batch_scrape and
the search tool declares no such param, so it would have swapped one
dangling reference for another.

* refactor(tools): drop the unused guard API from this PR

safeUrlPath, safeOpaqueUrlSegment and SafeUrlPathOptions had zero call
sites -- 469 lines of unused API in a docs-generator change, including an
allowEmptySegments flag whose own TSDoc documents a host-takeover footgun
('//evil.com' under new URL(relative, base)). They belong with the ~693
traversal call sites that use them, where they can be reviewed against real
usage.

What stays is the part with 44 live consumers: safeUrlPathSegment now
accepts number and bigint. Staging already rejected every non-string, so
this only widens acceptance -- a differential over 87 real call-site values
shows 87 identical, 0 differing.

Also: encodeURIComponent throws an unnamed URIError on a lone surrogate,
which JSON.parse accepts, so a truncated emoji lost the param name the file
claims as its invariant. And the exponential rejection told tiny values like
1e-7 they were 'too large' when they round-trip exactly; the rejection is
right -- a path segment should not rewrite 0.0000001 into other text -- but
the stated ground was not.

* fix(docs): abort before writing on a parse failure, unhang three descriptions

The guard's own TSDoc said to fail rather than guess, because the fallback
strips every hidden param from a page. The caller did the opposite: it
caught, recorded, and continued with an empty id set, and the non-zero exit
came after every page was already written. A developer who reran, saw red,
and missed the scrollback could commit a stripped page. Parse failures are
now detected in a dry pass before anything is emitted.

The zero-id guard also only fired when the array held a literal '{', so a
subBlocks built by a helper call was silently empty, while a subBlocks whose
literals only spread ({ ...sb, required: true }) hard-failed the build. It
now fires on the destructive case alone -- reporting an unreadable array
while leaving legitimate opaque spreads, which 23 blocks rely on, untouched.

Three descriptions referenced things the reader can no longer see: Dataverse
mandated base64 after its base64 row was removed, Vanta's mimeType described
itself as useful only on that removed path, and five Drive actions told the
reader to fetch a next page with no input left to accept the token.

* chore(tinyfish): use a white block background

Matches the dominant convention (97 blocks use #FFFFFF). Regenerates the
docs page, the tool metadata, and the deployment catalog, which each carried
the previous value.

* fix(docs): correct Drive/Firecrawl/Vanta output and param descriptions

Google Drive nextPageToken: the discovery doc says the field is *absent*
at the end of the list, not empty. Say absent, and name the resource
(files/comments/permissions/revisions) per tool.

Firecrawl search outputs: restore the per-format gate the v2 OpenAPI
states ("HTML content if requested in formats"), and add that Search
exposes no input for those formats.

Vanta mimeType: the route resolves a content type from storage on every
path, so the param is never read. Say so instead of describing it as a
fallback.

Dataverse: describe the request as sending the bytes as the raw body.

* fix(docs): correct the Vanta mimeType and Firecrawl reachability wording

Vanta: the base64 branch (route.ts:100) reads params.mimeType as its only
content-type source, so "not currently applied" was wrong. Say it applies
there and is a fallback on the File branch.

Firecrawl: scrapeOptions is declared in the block's inputs map with no
subBlock, so it is reachable by a direct tool call. "Exposes no visible
input" rather than "exposes no input".

* fix(docs): never abort the generator on an unreadable subBlocks array

An unreadable `subBlocks` value used to throw, and with no spread base to
fall back on the failure was fatal: the pre-scan recorded it and
`generateAllBlockDocs` returned false, so `main` exited 1 and nothing was
written at all. Nine shipped blocks already use the non-literal form and
are saved only because they happen to spread a base — the first block
authored as `subBlocks: myFields` without one would brick `generate-docs`
and `docs:check` for the whole repository.

The author's reason for aborting was sound: an empty `userSettableParamIds`
is indistinguishable from "nothing is settable", which strips every hidden
param and publishes a wrong page. So the fix is not to treat the failure as
empty — it is to represent UNKNOWN distinctly. `extractBlockSuppliedParamIds`
now returns `{ ids, mapperIds, parseError }` with `ids: null` for UNKNOWN,
that `null` flows through `BlockConfig.userSettableParamIds`, `getToolInfo`
and `extractToolInfo`, and the filter site skips filtering entirely when it
sees it — restoring the pre-filter behaviour for that one block instead of
killing the run. `getToolInfo`'s default is `null` for the same reason: `[]`
as a default silently meant "strip everything".

The mapper scan now runs before the subBlocks scan, so a spread-inheriting
block keeps its mapper's renames when only the subBlocks scan fails. With
nothing left that can record a fatal, the dry pre-scan and its reporting
are removed.

Also fixes a silent blind spot in the mapper scan: both key regexes require
a literal `:`, so a mapper returning a shorthand property (`{ file }`) or
writing a computed key (`result['file'] = …`) dropped a real user input from
the docs with no warning. Shorthand names are read from the depth-1 comma
segments of brace-matched regions, which keeps call argument lists from
contributing names.

Verified byte-identical output: `scripts/generate-docs.ts` and
`tool-metadata:generate` reproduce all 302 generated files unchanged, the
credential-shaped hidden params stay stripped, and `check:audits` passes.

* fix(docs): name the input that gates Firecrawl search scrape output

The previous wording ended each description with "for which the Search
operation exposes no visible input", a relative clause that attaches
ambiguously and never tells the reader what controls the field. Name
scrapeOptions and note that it is hidden.

* fix(docs): say the Vanta mimeType is ignored for File-input uploads

Every return path of downloadServableFileFromStorage yields a non-empty
contentType (a literal, getMimeTypeFromExtension's GENERIC_MIME_TYPE
fallback, or resolveServableDocBytes' constants/getContentType), so
resolved.contentType always wins at route.ts:79-81 and params.mimeType is
unreachable on that branch. It is not a fallback; it is ignored.

* fix(docs): note the hidden inputs that gate Pulse html and figures output

extractFigure and returnHtml are visibility: 'hidden' with no subBlock, and
parser.ts:135-144 only forwards them when defined, so neither output can be
produced today. Say so on the output rows rather than deleting them, since
removing an output field would break saved block references.

chunks is left alone: chunking/chunkSize are user-only with real subBlocks.

* fix(docs): report a spread-only subBlocks array as unknown, not empty

extractUserSettableParamIds answered [] for a subBlocks array whose every
element spreads a fields array it cannot follow (NotionV2Block's
`[...NotionBlock.subBlocks, ...getTrigger(x).subBlocks]`). [] asserts the
block supplies nothing, so the hidden-param filter stripped every hidden
param from every tool the block owns - silently, with no parseError and so
no warning. That is the exact false-drop the null UNKNOWN state exists to
prevent.

Return null in that case and propagate it: extractBlockSuppliedParamIds no
longer folds it into [], and the block pass no longer collapses it with
`supplied.ids ?? []`. A config-level spread base still narrows the filter to
its readable fields plus the mapper's renames; with no base the filter is
switched off. An array with at least one inline id, a genuinely empty array,
and the existing throw/warn paths are unchanged - all 8 warned blocks warn
identically and every generated page is byte-identical.

Also pin the hidden-param filter on extractToolInfo's source-parsing path,
which had no coverage at all: deleting it outright left the suite green.

* fix(daytona): stop the lifecycle tools crashing on a non-string sandboxId

start/stop/delete echo sandboxId back as the output id when the API returns
no body, via params.sandboxId.trim() inside transformResponse - after the
request has already gone out. sandboxId is declared type: 'string' but
arrives unvalidated, and now that safeUrlPathSegment accepts a numeric id a
number builds a URL, sends the DELETE/START/STOP, and only then throws an
unnamed TypeError. Both the old and new behaviour fail, so this is not a
regression of a working workflow, but for delete_sandbox the side effect is
irreversible and the caller cannot tell what happened.

Fixed with a shared resolveSandboxId in utils.ts rather than a coercion at
each of the three sites: utils.ts already owns every sandbox-id helper, the
three tools already import from it, and the reasoning belongs in one place.
The encoded value cannot be reused - it is percent-encoded and would be
wrong as an output id. Behaviour for a string is unchanged.

* docs(url-path): drop the false claim that widening restores prior behaviour

The module TSDoc said the number/bigint widening fixed 'a regression for the
call sites whose pre-guard form was a bare ${params.id} template that
stringified a number fine'. It did not. Every pre-guard form in a422990
used .trim() (`/v13/deployments/${params.deploymentId.trim()}`,
`sandboxId?.trim()`), so a numeric id threw there too - no importer has ever
accepted one. The cited examples were also wrong: only Vercel and Daytona
import this module, and neither Box nor X does.

Replaced with the real motivation - params are declared type: 'string' but
nothing enforces it before the guard, and the old coercion-to-'' turned a
supplied numeric id into a misleading 'is required'. Two test comments made
the same claim ('still stringifies', 'replaced bare ${params.id} templates')
and are corrected; no assertion is weakened.

* fix(docs): correct output rows that cite inputs Sim does not send

mistral_parse: the PR removed the includeImageBase64 input row but left the
image_base64 output citing include_image_base64=true, so the page referenced
an input it no longer documents. includeImageBase64 is visibility: 'hidden'
with no subBlock, mapper or canonicalParamId, so it is annotated the same way
Pulse's html and figures were.

The sibling rows are a stronger defect: table_format, extract_header and
extract_footer appear nowhere in the repo - not as tool params, not in the
request body parser.ts builds - so tables/header/footer cited options Sim
never sends. Worded accordingly rather than as hidden inputs.

pulse structured_output cited 'if schema was provided', but there is no
schema or structuredOutput param in the tool, in pulseParseInputSchema, or in
the outgoing body, so the field is always null.

No output field is deleted - removing one changes the block's output schema
and could break saved workflow references.

* style: wrap long description literals to satisfy biome

Formatting only — regenerating both artifacts produces a byte-identical
tree, so no description text changed.

* fix(docs): cite the real Mistral options behind tables, header and footer

The previous wording was self-contradictory on tables: it described
placeholder-referenced table objects and then asserted the list is empty.
Mistral's OCR API does expose table_format, extract_header and
extract_footer. table_format defaults to inline markdown, so the separate
tables list stays empty; extract_header and extract_footer default to
false, so neither field is returned. Sim sets none of the three.

Name the option and its default in each description instead of asserting
an outcome the request body alone does not establish.
* feat(usage): add enterprise organization usage monitoring

Enterprise org admins had no way to see where their pooled credits go. The
billing page hides the usage-limit field and the credit-usage drill-down for
enterprise, and the credit-usage view is personal-scope only — so the one
audience that negotiates a pooled commitment was the one audience that could
not watch it burn.

Everything needed already existed in `usage_log`, which is the single source of
cost truth and is stamped `billing_entity_type='organization'` at charge time.
This adds the read surface over it, plus one narrow write so BYOK usage is
captured rather than discarded.

Settings → Organization → Usage tracking, gated on hosted + enterprise with a
`USAGE_MONITORING_ENABLED` self-hosted override, matching the other enterprise
features.

Five tabs, each answering one question: Overview (how much, and what kind of
work), Members, Workspaces (drill into one for its Sources and Workflows),
Models, BYOK. Only the visible tab's dimension is fetched — half the dimensions
heap-scan the ledger, so a tab nobody opens never pays for one. Every ranked
list closes with an explicit `Other (N more)` row so it reconciles to the
headline figure.

Track unbilled (BYOK) model usage
---------------------------------
BYOK spans already reached `costSummary` with real token counts and were
discarded at a single `if (modelData.total > 0)` gate. A new `model_unbilled`
usage_log category records them at zero cost, written only at the terminal
execution boundary and only when billing attribution is already resolved, so a
BYOK-only run with no billable target still bails safely instead of hitting the
attribution requirement. Every billing read over `usage_log` is `SUM(cost)`, so
zero-cost rows change no total anywhere.

Collapse organization settings onto the workspace plane
------------------------------------------------------
`/organization/[id]/settings/*` was a second plane with no UI entry point
anywhere, whose nine sections all already render on the workspace plane. It is
deleted, along with its renderer and unavailable page. `planes.organization` is
replaced by a single `unified.organizationSection` marker that now derives both
`ORGANIZATION_PLANE_UNIFIED_SECTIONS` and the section map in the workspace gate,
removing a hand-maintained duplicate. Usage-threshold emails now link to the
workspace-scoped billing page, and the two org-provisioning paths that could
strand an admin with zero workspaces now backfill one.

Shared chart module
-------------------
`LineChart` moves to `components/charts/` with a matching `BarChart` sibling
built from the same geometry, tooltip, and theme modules. The move also breaks
the import edge from the chart to `logs/utils` → `@/blocks/registry`, which
would otherwise have pulled the entire block registry into the settings chunk.
Logs dashboard renders identically.

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

* chore(audits): record the usage-tracking module-graph baseline

`check:tool-registry-boundary` ratchets each route's module count, and the
settings section entry exceeded its allowance. Attribution, measured by stubbing
the section out and by walking `origin/staging` in a scratch worktree:

  origin/staging               2112  (baseline 2083 — already +29, unrecorded)
  this branch, section stubbed 2115
  this branch                  2130  (+15 for the whole UsageMonitoring subtree)

So the overrun is a shared budget: staging had spent 29 of the 42 allowed before
this branch existed, and 15 more tipped it over. The 15 are all first-party —
the panel, its contracts, hooks, and the shared chart module — with no accidental
edge into a registry or a heavy barrel, so there is nothing to cut.

Only the four entries this branch is responsible for are re-recorded. Running
`--update-baseline` wholesale rewrote 411 lines, absorbing staging's drift across
~60 unrelated routes into this PR; the six routes that shrank are left
informational for whoever earned them to claim.

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

* fix(usage): key the chart to the calendar the query grouped by

Three review findings, all real.

Densification walked UTC dates while `readUsageTimeSeries` groups by
`date_trunc($bucket, created_at AT TIME ZONE $timezone)` — the viewer's calendar.
For a non-UTC viewer the edge buckets never matched, so their cost stayed in the
headline while their bar read zero. Week and month were worse: Postgres aligns
those to Monday and the 1st, so a cursor stepping from an arbitrary period start
shared no key with the query at all and the whole chart came back zeroed against
a correct total. That is reachable today through an annual enterprise period,
which resolves to `week`.

`densifyUsageSeries` now takes the timezone, derives its first and last bucket
through `Intl.DateTimeFormat('en-CA', { timeZone })`, and truncates both to the
bucket boundary so the keys are the ones `date_trunc` emits. Stepping stays civil
`YYYY-MM-DD` arithmetic — UTC as a proleptic calendar, never converted back to an
instant, so no DST transition can shift a bucket.

The custom-range picker passed `showTime`, so it serialized its end bound as an
inclusive `…T23:59:59` local wall time; the resolver then added a further day.
Every custom range covered 24 hours too many, a legal 92-day selection measured
93 and was rejected, and the wall-clock string parsed as local while the rest of
the window logic is UTC. Dropped `showTime`: a time of day is precision a
day-bucketed panel cannot render, and bare `YYYY-MM-DD` bounds parse as UTC
midnight, which is what makes the half-open `+ DAY_MS` correct.

Admin organization provisioning answered 500 for state it had already committed,
and the existing-membership check then blocked the retry, leaving an organization
no workspace could reach. Attachment is a follow-on effect, not part of creating
the organization, and it is deliberately not folded into the creation
transaction: it runs its own under a lock order that exists to avoid deadlocking
against invitation acceptance, and re-deriving that in a route is how a deadlock
ships. Its failure is now caught and logged, and the endpoint returns the
organization it created.

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

* fix(usage): correct reconciliation, gating, and error classification

Second review round. The findings that were real, and what changed.

Billing aggregates. `getBillingPeriodWorkflowRunCount` counts distinct workflow
executions with no category predicate, and its own contract says executions with
no billable usage are excluded. A BYOK-only run with a zero base charge writes
nothing but a `model_unbilled` row, so it would newly appear in a figure that
feeds the enterprise billing preview; the count now excludes unbilled categories.
`recordUsage` also admits an unbilled entry only at exactly zero cost — the whole
safety argument for the category is that every aggregate is `SUM(cost)`.

Reconciliation. Breakdown rows and their remainder were each rounded to credits
independently and compared against a separately rounded total, so with sub-credit
fractions they could not add up — which is precisely what the `Other` row exists
to prevent. They now go through one `apportionCredits` pass. The event list and
the CSV also counted a row stamped exactly on the window end, which the summary
excluded, because the ledger filter is `lte` while an analytics window is
half-open; `endDateExclusive` makes the two agree.

BYOK ranking. The tab is denominated in tokens and every row costs zero, so
ranking by cost fell through to an alphabetical tiebreak — the "top providers"
were whichever sorted first, and the hidden tail's tokens were dropped entirely.
It ranks by tokens, and the remainder carries its own token total.

Gating. The usage entry carried `hideWhenBillingDisabled` copied from Members,
but the sidebar applies that filter before it consults `selfHostedOverride`, so
it hid the section from exactly the deployment the override exists to serve.
Members can carry the flag because it has no override to reach.

Error classification. An over-long custom range threw past the orchestration
policy and answered 500 on all four routes. A shared policy maps it to 400, the
export route makes the same classification in its catch, and the picker now
refuses the range up front rather than committing one the API will reject.

Also: the segmented meter's overage tone was unreachable (both counts clamped to
`segments`, so the comparison could never hold) and now scales both against
`max(total, used)`; the allowance is only compared against the current period,
since a rolling window can exceed a limit neither period did; `source` accepts a
scalar and validates against the source enum instead of an unchecked cast; the
bar chart's axis tick uses the same unit-aware formatter as its tooltip; and
`assertValidTimezone` strips control characters before echoing a rejected value.

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

* fix(usage): resolve custom bounds in the viewer calendar, bound the open period

Third review round.

Custom ranges were anchored on UTC instants. The picker offers calendar days and
sends `YYYY-MM-DD`, which arrives parsed as UTC midnight, so every non-UTC
viewer's selection was shifted by their offset — a range labelled "Aug 1–31"
covered half of Jul 31 and half of Aug 31 twelve hours east — and it contradicted
the series, whose buckets are already the viewer's calendar days. The resolver
now takes the timezone and reinterprets the same civil dates as midnight there,
through `zonedWallClockToUtc`, and counts the span in civil days so a range
containing a DST transition is not measured as 91.96. The timezone is threaded
through the breakdown, events, and export inputs as well; the contract already
carried it but only the summary consumed it, so the four surfaces would have
resolved one range four ways.

A deployment with no subscription resolves to `defaultBillingPeriod()`, the open
pair 1970…9999. Rendered as a period that produced a thousand monthly buckets
ending in 2053, stopped only by the densifier's loop guard — measured, not
inferred. Self-hosted is exactly where it is reachable, since the usage flag
opens the panel on deployments with no plan at all. An unbounded period now shows
a rolling 30-day window, and its predecessor steps back by that window rather
than by a span of eight millennia. Kept as one window rather than clamping only
the chart, so the series still sums to the headline.

The summary's delta used the `previous-period` preset, which must always return
something and therefore approximates a stripe period's predecessor by stepping
back the current period's length. Stripe periods are not equal-length, so the
comparison could be measured against a window that is not the previous period —
contradicting the comment directly above it. It now calls `resolvePreviousPeriod`
and shows no delta when there is no exact predecessor.

Reversed custom bounds measured a negative span, passed the cap, and returned an
inverted range matching nothing — "no usage" rather than a bad request. They now
throw, classified 400 alongside the too-large error.

Admin organization provisioning reported an unqualified success when workspace
attachment failed. It now returns `attachedWorkspaceIds` and records it on the
audit event, so the incomplete state is visible to the caller and durable after
the fact, rather than known only to the logs.

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

* fix(usage): repair the run-count predicate, align the ledger and analytics scopes

Fourth review round. The first item is a break this branch introduced last round.

`getBillingPeriodWorkflowRunCount` was rewritten to exclude unbilled categories via
`<> ALL(${UNBILLED_USAGE_CATEGORIES})`. Interpolating a JavaScript array into a
`sql` template emits parenthesized scalar binds, so the statement rendered as
`ALL(($1))` and Postgres rejects it: "op ANY/ALL (array) requires array on right
side" — verified against a real database. Its only caller builds the enterprise
billing preview, so that preview would have thrown on every request. It now uses
`notInArray`. Unit tests could not have caught it; `@sim/db` is mocked, so no
statement is ever rendered.

The ledger listing filtered on `created_at` while the analytics scope matches a
stripe or default period on the stamps rows carry. The event list and the CSV
therefore covered a different set than the totals above them — rows created inside
the period but stamped to another, and the reverse. Both now derive their filter
from one `usageWindowLedgerFilter`, which mirrors `buildUsageAnalyticsScope` case
for case, with a test asserting the two branch on the same discriminant.

An invalid timezone reached `assertValidTimezone` and surfaced as a 500 for what is
an ordinary bad query param; it is now refused by the contract as a 400, with the
SQL-boundary assertion left in place as the backstop it is.

A bookmarked workspace id that no longer resolves opened a detail view with an
untitled header and empty sections, against this repo's own deep-link rule. It now
falls back to the list once the list has loaded.

Also: `Cache-Control: no-store` on the CSV, which is every member's spend behind
session auth and the one response a browser will cache; Export no longer gated on
an unrelated summary query; the bar chart keeps its measurement ref on the empty
branch; and the admin create-organization contract declares `attachedWorkspaceIds`
plus `workspaceAttachmentFailed`, so a caller can tell an owner with no workspaces
from provisioning that is genuinely incomplete.

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

* fix(usage): keep reporting-only rows and rounding out of the credit views

Fifth review round, plus a migration renumber — staging took 0310, so the enum
addition is now 0311.

Unbilled rows leaked into every credit-denominated dimension. They carry a user, a
workspace, a workflow and `source = 'workflow'` like any other row, so a BYOK-only
member appeared in Members at zero credits and their runs inflated the event counts
on Workspaces and Sources. Excluded with `HAVING SUM(cost) > 0` rather than a
`category` predicate: `category` is not in
`usage_log_billing_entity_created_at_cost_idx`, so filtering on it would force a
heap fetch on `member` and `source`, the two dimensions that are index-only and the
reason first paint is cheap. `cost` is in that index, and only an unbilled row can
sum to zero.

Ranking BYOK by tokens last round was half a change: `share` still divided cost by
a total cost of zero, so every provider's bar rendered at the same minimum width
and the ranking was invisible. Share is now measured in whatever the list is ranked
by, derived from the same argument so the two cannot disagree.

Series buckets were each rounded to credits independently, so any day under half a
credit rendered as zero — an organization spending a fraction of a credit a day drew
a flat chart beneath a positive headline. They now go through one `apportionCredits`
pass, the same rule the breakdown rows use.

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

* fix(usage): drop inline workspace attachment, tighten window and chart edges

Removes the workspace attachment from admin organization creation, on the
maintainer's call. Three review rounds went into its failure semantics, and the
conclusion each time was that it cannot be made honest inline: it runs its own
transaction under a lock order that exists to avoid deadlocking against invitation
acceptance, so it could only ever be best-effort after the organization committed.

This codebase already solves the problem properly. `AdminMemberOperationView`
tracks workspace moves with `pending | processing | dead_letter | applied` and
per-workspace retry, and the enterprise-owner-claim path creates the workspace and
the organization in one transaction and enqueues an outbox event for the rest.
Provisioning belongs on one of those, not inline in a create call that also
relocates the owner's billing payer as a side effect. The endpoint is back to
creating an organization and its owner membership, and the contract says why.

The rest are edges found in review:

- A zero bucket rendered as a 3px colored bar. `chartPlotBand` clamps drawn
  geometry off the axis rule, but applied to zero it floored every densified empty
  day at the band — the opposite of what densifying zeros is for.
- `preset=custom` without both dates fell back to the raw period, bypassing the
  unbounded-period bound added last round. It now recurses through `current-period`
  so there is one rule rather than two copies.
- `Date.parse` accepts `2026-02-30` and rolls it forward, so a February request
  silently returned a window starting March 2. Dates now round-trip.
- `?limit=` coerced to `0` and answered 400 instead of using the declared default.
- The period picker bound to the raw URL preset, so a partial custom deep link read
  "Custom range" over current-period data — and suppressed the allowance that was
  exactly comparable to it.
- The CSV rendered a sub-credit charge as the string "0 credits", losing it, and
  left the column unsummable. Export rows now carry unrounded credits and the CSV
  writes a bare number.

Also regenerates the docs manifest, which CI flagged for the new docs page.

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

* fix(usage): surface export failures, make narrow charts scroll

A network-level export failure produced an unhandled rejection and no toast. The
action is fire-and-forget, so a dropped connection or a failed blob read was
indistinguishable from a click that never registered.

`resolveUsageBucket` ceiled its day count, so a 92-day range spanning the autumn
fall-back measured 92 days and one hour, counted as 93, and silently rendered
weekly bars for the longest range the picker allows. Rounded instead, with a test
on both sides of the threshold.

The client's custom-range guard only checked that both bounds were present, while
the contract — tightened last round — rejects a date that does not exist. A deep
link carrying `2026-02-30` therefore satisfied "resolved custom" and every query on
the page answered 400, where before it merely returned a shifted window. Tightening
one end without the other made a bad link worse; the client now applies the same
calendar round-trip, so it falls back as the partial-link guard intends. The
contract's own check also now covers the date portion of a datetime, not only the
bare form.

The export loop omitted `cursorCreatedAt`, whose documentation names this exact
caller. Each page therefore resolved its cursor against the primary before reading
the replica — up to 99 avoidable round-trips for a capped export.

Both chart roots become `overflow-x-auto`. `CHART_MIN_WIDTH` says the chart
"scrolls rather than compresses", and the code clipped: below the floor the
rightmost bars and axis labels were cut off. That comment was added by this branch
during the lift, so the contradiction is this PR's rather than inherited. At or
above the floor there is no overflow and nothing renders differently. Applied to
both charts because the floor lives in the shared hook.

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

* fix(usage): drop the redundant organization query param, harden deep links

`organizationId` was accepted and validated on every usage query and read by no
handler — all four map `params.id`, which is also the value that gets authorized.
It could never widen access, but it was an API that read as though the query
mattered. Removed from the shared window schema and from the hooks and export URL
that were sending it.

The client's custom-range guard checked that both bounds were real dates but not
their ordering or their span, while this PR added a 400 for each. A bookmarked link
with reversed or over-long bounds was therefore marked resolved and failed all four
queries, instead of degrading to the default window the guard exists to provide. It
now checks all three conditions the resolver enforces.

The charts' empty state pinned itself to the clamped minimum width, which forced
horizontal overflow in a narrow container — fallout from adding scrolling to the
chart root, which that branch returns before reaching. It takes the container width
now; the floor protects axis labels, and this branch draws none.

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

* fix(usage): apply the deep-link guard that was written but never wired

The previous round added `isUsableCustomRange` and left `isResolvedCustom` calling
`isCalendarDate` — the edit replacing the guard did not apply, so the function
shipped unused and the fix it described never took effect. It is wired now, and a
new `organization-usage.test.ts` covers the contract's side of these rules so a
dropped edit here fails a test rather than a review.

That contract check also had a hole of its own: `if (!datePart) return true` treated
a value with no `YYYY-MM-DD` prefix as nothing to verify, so anything `Date.parse`
accepted passed. `2026-08` was read as August 1 — a window the caller never asked
for, returned as though it had. The prefix is required now, anchored so a trailing
suffix cannot slip past, and the client mirrors it.

Both charts drop `useRef(generateShortId(7))` for `useId`. The reported hydration
mismatch is not real — both return early while `containerWidth === null`, which
holds on the server and on the first client render, so the gradient never exists in
hydrated markup. The waste is real: a ref initializer runs every render and all but
the first result is discarded, which is the repo's own lazy-init rule.

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

* fix(usage): accept only a bare calendar date for custom bounds

Rewriting the date refinement last round dropped the `Date.parse` guard that had
been there, so `2026-08-01Tgarbage` was accepted, the route built an `Invalid Date`,
and `civilBoundKey`'s `toISOString` threw — a 500 for a malformed query string,
which is the class of failure this PR has been converting into 400s. Verified
directly rather than inferred: the schema returned `accepted: true` for a value
whose `Date` was invalid.

A datetime carrying an offset was wrong in a quieter way. The check validated its
date part while the resolver read the UTC day off the whole value, so
`2026-08-01T22:00:00-05:00` displayed as August 1 and queried August 2.

Both contract and client now accept a bare `YYYY-MM-DD` and nothing else, which is
the only form the picker produces — `showTime` was removed early in this PR — so
the looser rules bought nothing and cost two defects. `isUsableCustomRange` also
drops its defensive `slice(0, 10)`: tolerating a shape we have decided not to
accept is how the loose rule crept in to begin with.

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

* fix(usage): reject an empty custom date instead of reading it as absent

`if (!value) return true` let `?start-date=` through, the route's ternary turned
the empty string into `undefined`, and the partial-selection fallback answered
about the current period rather than the range named in the request.

Absent stays valid — the picker clears the param rather than blanking it, and a
missing bound is a real state the resolver handles. Explicitly blank is only
reachable from a hand-built request, where a 400 beats a window nobody asked for.

Deliberately different from `usageLimitSchema`, which does coerce `''` to its
default: that field declares one, so omission has a documented meaning. These
bounds declare none, so treating blank as absent substitutes a different answer
rather than a default one.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(files): find in an open markdown document with Cmd/Ctrl+F

Adds find-in-document to the rich markdown editor, reusing the shared FindBar.
A ProseMirror plugin owns the match set and paints inline decorations, so the
search never touches the document, the undo history, or the collaborative Y.Doc,
and it re-searches on every document change so a highlight can't go stale.

Occurrence semantics come from the shared forEachSearchOccurrence rather than a
fourth private definition. That helper normalized case with a plain
toLowerCase(), which can grow a string and slide every later index, breaking its
documented guarantee that bounds index the caller's own string; it now folds
case length-preservingly for all three consumers.

The Cmd/Ctrl+F open shortcut was a fourth copy of the same effect. It moves to a
shared useFindShortcut beside FindBar, which the Files list now uses too.

* fix(files): close three find edge cases from review

- Keep context-sensitive lowercasing in the length-preserving case fold. A
  word-final sigma lowercases differently in a string than on its own, so
  folding character by character let one unrelated expanding code point change
  how every sigma in the string matched. The fallback now reads each
  character's replacement out of the whole-string result.
- Decline Cmd/Ctrl+F while a collaborative document is still seeding. The text
  on screen then belongs to the read-only placeholder's editor, not the empty
  hidden one find is attached to, so the bar answered "No results" for visible
  text. The browser's native find reads the placeholder correctly in that
  window; the shortcut becomes ours once the seed lands.
- Re-apply a pending term when the editor instance arrives, so a query typed
  before TipTap mounts is searched instead of sitting at zero matches until the
  next keystroke.
…7198)

The Cmd/Ctrl+F active match was drawn as a 1.5px ring in --highlight-match-text.
An outline around a run of text traces its line box, so on a heading it read as a
stray dark rectangle rather than as emphasis, and a match wrapping across two
lines drew two disjoint boxes.

The active hit is now a solid --brand-secondary fill with a fixed dark ink, so it
stays in the same family as the inactive tint and wraps cleanly. The ink is fixed
rather than tokenized because the fill is: --brand-secondary is the same blue in
both themes, so a theme-flipping text token would go white on light blue in dark
mode. Same pairing the note card's active search mark already uses.
* improvement(tools): prevent internal request self-hops

* fix(executor): preserve background execution principals

* fix(executor): authorize files with workflow principals

* fix(auth): bound derived workflow delegations

* fix(tools): close boundary review gaps

* fix(tools): close indirect boundary gaps

* fix(tools): resolve helper-built self hops

* fix(tools): normalize relative self hop paths

* fix(tools): fail closed on opaque URL helpers

* fix(tools): reject dynamic Sim-origin paths

* fix(tools): enforce external request origin

* fix(tools): close remaining self-hop bypasses

* fix(tools): block self-hosted loopback aliases
… mix (#7199)

* fix(usage): correct chart clipping, expand truncated rows, add source mix

Two rendering defects on the organization usage panel, plus the follow-on
cleanup they surfaced.

The y-axis maximum was clipped at the container's left edge. The chart family
used a fixed 26px left gutter, which leaves 18px of drawable width once the
label gap is taken out — four narrow glyphs — so every tick from `7.3k` up lost
its first character. Both charts now derive the gutter from the labels they are
about to draw, through one shared `resolveChartPadding` so a bar and a line
chart still line up when stacked.

Hovering near the foot of the plot raised a vertical scrollbar over the chart.
The scroll container sets `overflow-x`, which promotes `overflow-y` to `auto`,
and the tooltip's vertical clamp was a fixed inset that ignored the box's real
height. The clamp now measures the tooltip, and the container pins `overflow-y`.

Also on the panel:

- The axis rules were invisible. `hsl(var(--border))` is unparseable — the token
  is a hex — so the presentation attribute was dropped and SVG's initial
  `stroke: none` applied.
- GLM rendered without a mark: the settings provider-icon map held 11 of the
  registry's 24 providers. Completed, with a test that fails when the two drift.
  The server's parallel label map was the same 11-entry duplicate and now reads
  the registry directly.
- `Other (N more)` opens the tail in place, raising the row limit to the API's
  ceiling. Fixed the drill-down name lookup this exposed, which was pinned to
  the top ten and so refused to open for any row below it.
- A radar chart of the source mix sits beside the Sources list. The rows rank
  the sources; they cannot show whether spend is concentrated or spread.
- "Open logs" pointed at `/workspace/<id>/logs` for a workspace picked from an
  organization-wide list. Organization admin is not workspace membership, so for
  any workspace the admin had not joined it was a one-way trip to an access
  denial. It now opens the organization's audit feed scoped to that workspace,
  which required threading the workspace filter the query builder already
  supported through the internal contract, route, hook, and URL state.
- BYOK is withheld from the tab strip until the ledger carries BYOK rows.
- The chip number field suppresses the native stepper, which painted browser
  chrome inside a flat chip surface. The component owns it, not its callers.
- `ChartColumn` was a uniform 0.86 downscale of its source art, so it rendered
  ~2px small beside every other icon in the settings nav.

* fix(usage): repair the chart family's shared geometry and the audit export

Follow-up review of the previous commit, across the whole diff.

The CSV export ignored the workspace filter. The contract accepted it, the
on-screen feed applied it, and the export route dropped it on the floor — every
field of `AuditLogFilterParams` is optional, so omitting one still type-checks.
An admin exporting a workspace-scoped feed downloaded the whole organization,
under a truncation warning that blamed the date range. The route now forwards
the parsed query whole and refuses an out-of-organization id the way the list
route does, with tests for both.

The line chart's derived hover index was not clamped. The previous commit
replaced stored state with a derivation, but reproduced the clamp only for the
bar chart: `padding.left` follows the axis labels and `chartWidth` follows the
container, so a sidebar collapse mid-hover pushed the ratio past 1 and indexed
off the end — the dot, the rule and the tooltip all vanished until the cursor
moved.

Per-chart gutters de-aligned the logs dashboard, where three line charts sit in
one row. Deriving each from its own labels put their plot origins at 26, 27 and
32 where they had shared one. The gutter now rounds up to a step, which collapses
differences that small and leaves several pixels of slack instead of the
sub-pixel margin `Math.ceil` alone gave.

The radar chart, reviewed against its siblings:

- Its hover targets were triangles, whose far edge is the chord. Along its own
  spoke a triangle reaches only `reach·cos(π/n)` — at three axes, 50px against a
  74px radius — so the largest value's vertex, the one a reader aims at, sat
  outside every target. They are arc sectors now.
- The tooltip was positioned against the scroll container rather than the plot,
  so below the width floor it stayed nailed while the web slid under it. It now
  sits beside the hovered vertex through the family's own placer, instead of
  covering the densest part of the gradient.
- Captions below the centreline rode ~3px off the ring rather than the gap they
  were given, and captions beside the web were misaligned from their own vertex.
- Web opacities, stroke width, vertex radius, the per-theme fill relationship and
  the `screen` blend now match the bar and line charts rather than freelancing.
- Its rings read the shared grid fractions instead of dividing into even steps
  that agreed with the siblings only by coincidence.

Also: one `expanded` flag drove both lists in the workspace drill-down, so
opening either tail silently opened the other's; the Overview and tab lists
rendered an inert `Other` row while the same row two clicks away was a button;
the expand chevron knocked the value column out of alignment; row hover had
regressed to the chip surface where every other settings row uses the active
one; and the radar and the list beside it printed two different `Other (N more)`
counts under identical wording. The Overview's two readings of the source data
now share one section rather than drawing two half-width rules on one line.

Rendered-geometry tests cover the clipping and caption bugs against the real
SVG — a unit test of the helpers could not have caught either, since both came
from a callsite combining correct helpers wrongly.

* fix(usage): hide the workspace audit link where audit logs are disabled

Usage monitoring and Audit logs share their hosted and enterprise gates, so
reaching the usage panel proves both — but their self-hosted overrides are
separate flags. An install with usage monitoring on and audit logs off was
handed a drill-down action pointing at a section it had switched off.

The window is deliberately not carried across the link: the audit feed speaks in
rolling ranges and this panel in billing periods, so there is no honest mapping
for the current-period preset.

* fix(audit-logs): keep an unresolved workspace scope from widening the feed

A workspace id in the URL that no longer resolves — deleted since, or never one
of ours — dropped the filter, so a request for one workspace's history was
answered with the whole organization's, under a URL that still claimed to be
scoped. The CSV export followed the same filter and would have carried the same
widening.

Every other deep-linked id in the app degrades to the unfiltered view, which is
right where the fallback shows less than was asked for. An audit feed is the one
place where widening is the dangerous direction, so it now stays closed and says
so, with the filter chip still rendered so the scope can be cleared.

* fix(usage): correct the grid floor, tooltip height estimate, and Other row slot

Three findings from review.

A grid track minimum is a hard floor, so `minmax(320px, 1fr)` made the source
section wider than its column on a narrow viewport and overflowed instead of
collapsing. `min(320px, 100%)` caps the floor at the width actually available.

The tooltip height estimate used font sizes where it needed line boxes. The type
scale pairs no line-height with a size, so a line occupies the ambient 1.5 — a
10px date line is 15px, an 11px row is 16.5 — and the estimate came in ~1.5px
under the real box. Since it is what the clamp measures against and the chart
clips its overflow, an underestimate cuts the bottom off the box rather than
moving it up. Now derived from the line boxes, every part rounded up, with a test
that fails if any of the three constants drops below the rendered height.

The `Other` row drew its disclosure chevron bare at 14px while the rows above
reserved the 16px arrow or the 30px menu slot, pulling its figure out of the
column. It now centres in the same slot the rest of the list reserves.

* fix(audit-logs): present nothing when the workspace scope cannot be answered

Disabling the query was not enough. An unresolved scope drops the filter, so its
query key equals the unscoped feed's, and a disabled query still serves whatever
is cached under its key — an admin reading the organization-wide feed who then
followed a stale scoped link kept those rows on screen, with Export still armed
against them because that gate reads the same list.

The rule now has a name and a seam: `presentableAuditEntries` returns nothing
unless the feed can answer the scope the URL asks for, and the export action
states that condition where it is read rather than inheriting it through an empty
list.

* fix(audit-logs): make the placeholder scope-aware, and separate a failed lookup

Two more from review, both on the same surface.

The placeholder I added held previous pages across a workspace change, so
following a scoped link from the organization-wide feed painted the organization's
rows under a workspace-scoped URL until the scoped page arrived — with Export
armed against them. Gating presentation on `isPlaceholderData` would have fixed it
by throwing away the reason the placeholder exists, blanking the feed on every
keystroke again. The scope now leads the query key instead, ahead of the filters,
so "hold across a filter change, never across a scope change" is a prefix
comparison — the same shape the breakdown query already uses, and it retires the
hand-maintained key index.

A failed workspace lookup was reported as a workspace that is not part of the
organization. That is a wrong answer rather than a cautious one, and it offered
nothing to do about it. The two states are now distinct, the error one says so,
and Refresh retries the lookup alongside the feed so the control on screen can
actually clear the state it is showing.

* improvement(emcn): drop the number-stepper reset for plain text numeric fields

The stepper was suppressed with a vendor-pseudo-element class string inside
`ChipInput`. Removing browser chrome with custom CSS is the wrong end of the
problem: the fields never wanted a stepper in the first place.

They are text fields with a numeric input mode now — the choice the retry
settings field already documents ("the native spinner is all that buys, and it
does not fit the field chrome"). No CSS, the numeric keypad is unchanged, and
`ChipModalField` gained an `inputMode` prop so a modal field can say the same
thing without asking for the stepper.

This also fixes a real defect in the credit-limit field. A number input reports
`''` for anything the browser considers invalid, so a typo arrived
indistinguishable from a cleared field and saved as "no limit"; as text it reaches
the `Number.isInteger` check and is refused. The usage-limit field's `min`
attribute went the same way — the minimum is enforced on commit, where it can
explain itself, rather than silently by the browser.

* chore(audit-logs): scope the refresh refetch, drop a test that could not fail

`refetch` ignores `enabled`, so refreshing the unscoped feed fired a workspace
lookup it has no use for and could fail a refresh that otherwise succeeded. It
now runs only when a scope asked for it.

The hook test claiming to cover an unresolved workspace scope passed `enabled:
false` with no workspace at all, so it asserted TanStack's disabled handling and
would have passed with the scope protection removed. The rule it named is derived
in the component and is covered there by `presentableAuditEntries`; a test that
cannot fail for its stated reason is worse than none.

* fix(audit-logs): stop Refresh issuing the read the scope gate exists to prevent

`refetch` ignores `enabled`, so pressing Refresh while the workspace scope was
unresolved or its lookup had failed fired the audit query anyway — and its filter
carries no workspace in that state, so the request was the organization-wide read
the gate exists to prevent. The result was never presented, but it was still
asked for.

Refresh now repeats the gate: the feed is refetched only while the scope is
answerable, and the lookup — the thing that has to succeed for a closed feed to
reopen — is retried whenever a scope asked for it.
… summary (#7202)

The pills answered a question the line above them had already answered. "511,488
credits of 2,000,000" is the proportion, stated exactly; a 24-segment bar restates
it approximately, and directly under a chart of the same data in the same colour it
reads as a third series rather than as a summary.

The seat meter keeps `SegmentedMeter`, where it belongs: seats are countable, so a
pill per seat is legible in a way a pill per ~83,000 credits is not. The overage
signal the meter also carried is unaffected — the "Over limit" badge beside the
figure already states it, and states it in words.
…7195)

* fix(docs): pin the generator's sort locale to en-US

localeCompare with no locale argument uses the runtime default, which
varies with LANG and the ICU build. Against the real 254 catalog names,
tr-TR reorders 141 positions, et-EE 45, cs-CZ 2 and lt-LT diverges at
index 40 — so a contributor on any of those regenerates a different
integrations.json and fails CI with no obvious cause.

Pins en-US at all four sort sites. The committed artifacts are unchanged:
regenerating before and after leaves the tree byte-identical.

Adds a guard test asserting the committed catalog matches an explicit
en-US ordering, plus one that fails if an unpinned localeCompare returns.

* fix(vanta): remove the MIME Type field whose value was always discarded

The block rendered an advanced MIME Type input and forwarded it as the
tool's mimeType, but the upload path never reads it: file-input.ts sets
`resolved.contentType || userFile.type || input.mimeType || …`, and every
return path of downloadServableFileFromStorage yields a non-empty content
type — a literal, getMimeTypeFromExtension's GENERIC_MIME_TYPE fallback,
or resolveServableDocBytes' constants and getContentType fallback. The
placeholder claimed it was 'used when the file has no type of its own',
which never happens.

Removes the subBlock, its mapper write and its inputs entry, and marks the
tool param hidden so it is no longer advertised to the model on a path
where it cannot take effect. The param itself stays, because the base64
branch still reads it.

Letting a typed value win instead was rejected: for a compiled artifact
the storage-resolved type is the only one matching the bytes actually
sent, so an override would break the case the resolver exists to fix.

* fix(github): resolve the PR head SHA for file comments

github_comment sent commit_id as undefined for every file comment: the
param was hidden with no subBlock and no mapper write, so GitHub — which
marks commit_id required on POST /pulls/{n}/comments — answered 422 on a
path the commentType dropdown exposes.

When commitId is absent the tool now fetches the pull request first and
uses head.sha, mirroring how Jira resolves cloudId from domain.

Also removes the position param, which GitHub marks deprecated ("Use
line instead"); line is already a real subBlock.

* fix(google-drive): expose the page token so pagination is reachable

list, search, list_comments, list_permissions and list_revisions each
declared a hidden pageToken and forwarded it to Google, but the block had
no subBlock of that name and never has — so every list was capped at one
page and the nextPageToken output had nowhere to go.

Adds a per-operation Page Token field mirroring the block's existing
per-operation pageSize fields, collapses them onto the canonical
pageToken in the params mapper, and flips the tool param to user-only so
it is documented and settable.

* fix(confluence): stop documenting a cloudId users cannot supply

All 46 Confluence tools marked cloudId 'user-only', publishing it on 46
doc rows, but the block has no cloudId subBlock so no user could ever
fill it. createConfluenceClient already resolves the cloud id from the
domain through the shared Atlassian resolver, exactly as Jira does, and
Jira marks the same param hidden.

Marks cloudId hidden to match. domain stays user-only and settable — it
is now the only user-provided param on every Confluence tool.

* fix(docs): teach the source scanner about regex literals

blankStringsAndComments was a single regex with no concept of a regex
literal, so two shapes silently truncated a block's subBlock list:

  /don't/   the apostrophe opened a phantom string that swallowed the
            following entries
  /[}]/     the brace in the character class closed the enclosing object

Both returned a short list with no warning — a confident wrong answer,
which for the hidden-param filter means silently deleting a user-settable
row. No block file uses a regex literal today, so this was latent.

Replaces the regex with a linear scanner that distinguishes a regex
literal from a division by the previous significant character, blanks
regex bodies whole (their last character is arbitrary source, same reason
comments are blanked whole), and tracks ${} nesting so a backtick inside
a template expression cannot end the template early.

The scanner now returns null when it ends inside an unterminated
construct. All three call sites treat that as UNKNOWN rather than
guessing, so the filter switches off instead of stripping.

Generated artifacts are byte-identical and the warning count is unchanged.

* fix(github): gate the commit lookup on path, coerce line, name the failing field

Three corrections to the file-comment fix, from a validation sweep.

needsCommitLookup did not check `path`, and ran before the `path` branch in
request.url. A file comment with an empty File Path — reachable, since path
is not required on the block — went GET /pulls/{n} then POST /comments with
path undefined, a 422. On staging it posted to /pulls/{n}/reviews, which
GitHub documents as creating a pending review and where commit_id is
optional. The lookup is now gated on path, so only a request headed for
/comments triggers it.

The block has no tools.config.params, so `line` reached the tool as the
string the short-input produced while GitHub types it as an integer — file
comments would still have 422'd, one API call later. Coerced in
request.body, which runs at execution; anything non-finite is omitted
rather than sent as NaN.

readGitHubErrorMessage returned only the top-level message, so a 422 read
"Validation Failed" with no indication of which field was rejected. The
errors[] detail is now appended. Responses without errors[] are unchanged.

* test(github): cover the comment routing cases the gate changed

Adds the file_comment-without-a-path case (which the commit lookup now skips),
the untouched-block default where commentType is unset, a pr_comment carrying a
path, and the line coercion on both the direct and resolved-commit paths.

* fix(google-drive): let an agent feed the page token back in

A page token is an opaque continuation value produced by a previous tool
response, not an account-specific id the user has to supply, so 'user-only'
hid it from agent blocks: they saw nextPageToken in the result and could not
send it back, silently reporting page one as the whole answer. Every other
pagination token in the tool set is 'user-or-llm'.

Also covers the case the mapper guard actually defends — a per-operation page
token surviving an operation switch, which reaches inputs because
shouldSerializeSubBlock skips condition evaluation for advanced fields.

* docs(generator): name the load-bearing newline rule and report an unscannable source

- Record why '\\n' is in REGEX_ALLOWED_AFTER: formatters emit a binary '/' at
  end-of-line, so every line-leading '/' in blocks/*.ts is a real regex, including
  the ones in table.ts and table_v2.ts. Removing it silently mis-scans those two.
- Split a scanner failure out of the spread-only 'ids: null' case. Both scans come
  back empty for the same reason when blankStringsAndComments bails, so the mapper's
  renames were dropped with no warning; it now reports a parseError and warns. The
  spread case is unchanged, and its TSDoc no longer claims a cause that was false.
- Route every catalog sort through an exported compareCatalogNames so the ordering
  test exercises the generator's comparator instead of re-deriving it, and match
  localeCompare arguments whole so localeCompare() and a variable locale are caught.
- Note that downloadServableFileFromStorage guarantees a non-empty content type, so
  the Vanta mimeType fallback chain reads as deliberately defensive.

Artifacts regenerate byte-identically and the generator warning set is unchanged.

* fix(vanta): declare the removed uploadMimeType subblock as dropped

check-block-registry fails a PR that deletes a subblock id without a
migration entry, because a deployed workflow can still hold a value under
that id. The serializer already discards an orphan silently, but the repo's
contract is that the removal is declared rather than inferred.

Uses the _removed_ form, scoped to upload_document_file: the value has no
replacement field to move to.

* fix(github): run the two-phase PR comment on the secure transport

The file-comment flow posted its comment from `transformResponse` with a bare
global `fetch`, so that request carried no abort signal, no response ceiling and
no DNS/SSRF validation — cancelling a workflow still left the comment posted.

`transformResponse` cannot receive the signal; `directExecution` can. Both tools
now run the lookup and the POST through a new `secureGitHubRequest`, mirroring
`secureBitbucketRead`, with the signal forwarded to each. Routing, line coercion
and the `errors[]` detail are unchanged, and a failed response still throws an
error carrying `status`/`statusText`/`data` as the transport does.

Request bodies and the comment payload are explicitly typed instead of
`Record<string, any>`.

* fix(vanta): keep mimeType an ordinary upload parameter

`mimeType` was marked hidden, but `visibility: 'hidden'` is reserved for
system-injected params such as OAuth tokens. It also left the base64 upload path
with no way to set a content type, since `fileContent` is hidden too.

* fix(generator): lex regex-in-keyword-position and template interpolation

The scanner chose regex-vs-division from the previous significant character, so
a regex in operand position (`return /x/`, `typeof /x/`, `case /x/`, ...) lexed
as a division off the keyword's last letter and its body stayed in the
structural view; `azure_devops.ts` is inert today only because the braces in its
`return /^\d{4}-\d{2}-\d{2}$/` happen to balance.

The `${}` depth counter was also not string-aware, so a brace inside a quoted
expression miscounted, the closing backtick was lost and the block was reported
unreadable — which silently stops filtering resolver-derived hidden params.

Both scans now run on one set of lexer primitives: `${}` expressions are lexed
with the same string, comment, regex and template handling as top-level code.
Regenerated docs, tool metadata and the integration catalog are byte-identical
and the generator's warning count is unchanged.

* fix(github): stop forwarding the GitHub token across a redirect origin

secureGitHubRequest passed no redirectPolicy, and the transport only strips
credential headers when one is present, so an api.github.com redirect to another
origin carried the workspace's Authorization: Bearer header to the new host.

Adopts the standard policy already used by the internal Google Drive client.
stripAuthOnRedirect stays off: GitHub redirects same-origin for legitimate
reasons (a renamed repository answers 301), and dropping auth there would turn
a working call into a 401.

* fix(github): reject a fractional comment line instead of truncating it

toLineNumber ran Math.trunc, so line 3.9 posted the review comment on line 3 —
a silent change to what the caller asked for, on a field where landing on the
wrong line of the diff is invisible until someone reads the comment. A
non-integer now fails with a message naming the field, matching how a missing
head commit SHA fails on this path. Blank and unparseable input is still
omitted: line is optional and nothing usable was supplied.

* fix(github): select the comment endpoint by comment type, not by path

The endpoint was chosen by the presence of path, while the body was chosen by
commentType, so a pr_comment naming a file posted a review body to
POST /pulls/{n}/comments. GitHub documents body, commit_id and path as required
there, so that request can only ever 422 — it has been broken since before this
PR. The endpoint now follows the comment type: only a file comment carrying a
path uses /comments, everything else stays on /reviews. The test that codified
the broken routing is corrected, and the full type/path matrix is pinned.

* test(confluence): drop the cloudId visibility invariant test

Removed at request. The visibility change itself is unaffected; it loses
only the guard that would have caught a future edit reverting one of the
46 files.

* fix(github): send an explicit User-Agent and stop downgrading a redirected comment POST

`secureGitHubRequest` powers the GitHub comment tool's `directExecution`
path, which bypasses the declarative transport. Two behaviors the transport
provided did not survive the move.

User-Agent: the transport sets `User-Agent: Sim` on every request it formats
(`request-transport.ts`), and `secureFetchWithPinnedIP` adds none of its own —
it builds the request with raw `node:https` and passes headers through
verbatim. Production runs on Bun, whose `node:http` shim injects
`user-agent: Bun/x.y.z`, so GitHub does not reject these calls today; the
defect is that Sim's deliberate attribution is silently replaced by a runtime
version string, and that the tool depends on an undocumented runtime behavior
that does not hold under Node, where GitHub answers 403 "Request forbidden by
administrative rules". Set in the helper rather than in the tool's header map
so every future caller inherits it; a caller-supplied value still wins.

Redirect method: the policy was `mode: 'standard'`, under which
`resolveRedirectHop` rewrites a 301/302'd POST to a bodyless GET regardless of
origin. GitHub answers 301 within api.github.com for a renamed repository, so
commenting on a PR there would GET `/pulls/{n}/comments`, receive a JSON array,
fail the payload shape check, and report success with no comment created.
`legacy` keeps the method and body across that hop. Cross-origin credential
stripping is unaffected — the guarded follower strips Authorization,
Proxy-Authorization and Cookie whenever `sendCredentialsOnCrossOriginRedirect`
is false, in either mode.

* fix(vanta): drop the dead whenOperation from the removed-subblock entry

migrateBlockSubblockIds handles a _removed_ target before it consults
whenOperation, so the scope was never applied. Mine was the only _removed_
entry in the file carrying one.

Unconditional deletion is also what this case wants. Subblock values are
keyed by id and are not cleared when the operation changes, so a user who
filled the MIME field and then switched the block to another operation has
the value stored under that operation; a scoped delete would strand it
permanently. The field no longer exists for any operation, so it should go
regardless of the stored operation.

* fix(docs-gen): close a regex-vs-division gap and make three guards testable

`REGEX_ALLOWED_AFTER` was missing `'/'`, so a regex directly after a division
operator lexed as a second division: in `x / y / /[}]/` the character class was
left in the structural view and its `}` closed the enclosing object early,
truncating the block's subBlock ids with no warning. Add `'/'`, and guard the
`'+'`/`'-'` entries with a `++`/`--` lookbehind so a postfix update still reads
as a value and `i++ / 2` stays a division rather than a phantom regex that runs
to end-of-input and reports the block unreadable.

The `.`/`#` property guard and the `'\n'` entry both survived their mutants.
`counts.in / 2, m: preturn / 2` is self-cancelling — the mis-lexed regex closes
on the second slash and blanks nothing structural — so the fixtures now leave an
odd number of slashes on the line. The `'\n'` entry had no coverage at all: its
fixture is now the wrapped `.match(` newline `/re/` shape that `blocks/table.ts`
and `blocks/table_v2.ts` produce, which is what that entry (not the preceding
`(`, which the newline overwrites) actually decides. Its comment claimed a count
of line-leading regexes that drifts with the sources; restate it without one.

Drop the two locale tests that could not fail. CI runs under an `en-US` default,
where an unpinned `localeCompare` returns exactly what the pinned one does, so no
behavioural comparison discriminates; and asserting the committed
`integrations.json` against the comparator that produced it agrees by
construction. The source grep for a literal locale argument is the real guard.

Generated output is byte-identical and the extraction differential over
`apps/sim/blocks/blocks/` is empty.
* fix(copilot): disclose how far a withheld run got instead of one opaque sentinel

A tool result the egress projection cannot vouch for is reduced to a bare
success or to `TOOL_RESULT_UNAVAILABLE_ERROR`. Both drop the execution id
with the payload, and the sentinel also overwrites the real error text, so
a call rejected on its own arguments and a run that already executed come
back byte-identical. Those need opposite retry decisions.

Reproduced against real code by latching a registry the way production
latches one — a child run that returned no provenance envelope — and
driving the real handler and the real projection: a pre-dispatch rejection
and a post-dispatch failure were identical, and a completed run arrived as
`{"success":true}` with nothing to look it up by. Two distinct shapes for
three outcomes.

The registry is right to fail closed; the boundary was discarding facts it
never needed to redact. A tool may now declare a `ToolCallEffect` — a phase
and server-minted ids — which the projection preserves when it withholds
content, because neither is derived from that content. The exemption is
enforced rather than asserted: ids must match the identifier shape this
system mints, and one that does not voids the whole disclosure.

The phase is attached in the application layer from dispatch onward and
nowhere earlier, which is what makes the id's absence the positive
statement that nothing was created rather than an admission of not
knowing. Withholding also now reports its cause — a latched registry names
the guard that tripped, an absent one means no catalog was built, and a
content refusal means the registry was fine — so the next occurrence is
diagnosable from the logs it already writes.

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

* fix(copilot): keep the withheld-result tests out of the secret scanners

The withheld-run fixture was shaped like a live provider key, which is
exactly what a secret scanner is built to catch — it flagged the test file
itself. The value only has to clear the eight-character substitution floor,
so it says what it is instead. The id-shape guard likewise no longer needs
a credential-looking string to prove it refuses one.

Also routes the test's error-message mock through getErrorMessage rather
than reimplementing it inline, which check:utils bans.

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

* fix(copilot): name the dispatched run from the boundary that owns it

Review found the attempted-run id attached around the whole of
executeWorkflow, which validates workspace and billing attribution before
it can create anything. A preflight refusal therefore reported a run that
never existed, telling a caller to resolve an id with nothing behind it and
to skip a retry that was safe — the mirror of the defect this branch fixes.

Move the attachment inside executeWorkflow, at the point it enters the
execution core, which is the first moment a row may exist. Everything above
it now correctly carries nothing, and the copilot layer keeps only the
window executeWorkflow cannot see: a failure after the run already
returned, where the crossing import threw and an execution certainly
exists.

Also from review:
- Attach to any thrown object rather than only an Error, and normalize a
  thrown primitive past the dispatch boundary. Restricting to Error made
  the invariant silently invert for a thrown plain object — the id would
  not attach, its absence would read as "nothing started", and the caller
  would duplicate a real run.
- Void the disclosure when an id would take one of the record's own field
  names. A valid uuid under `effect` overwrote the phase the retry decision
  reads, on the same all-or-nothing terms as an unvouchable id.
- Drop `effect` from the provider model response. That path spreads every
  non-output field through verbatim, so the type's claim that the
  disclosure reaches the model only through the withheld-result projection
  was true by accident rather than by construction.

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

* fix(copilot): refuse an in-band tool call whose egress catalog is unavailable

Production shows 88 of these in fourteen days, every one from this route
and every one caused by a workspace id that no longer exists reaching the
in-band lane. The handler ran anyway, which is the worst pair of outcomes
available: the side effect happened, and because the projection can vouch
for nothing without a catalog, the caller got a bare success or an opaque
sentinel naming neither the cause nor whether anything had changed. It is
also where the reported "cannot tell whether the mutation occurred" came
from — of the tools affected, read and grep dominate, and the runs were
bursts inside single sessions.

Refuse before dispatch instead. Nothing runs, so there is nothing to be
uncertain about, and the caller is told which workspace and why.

A missing workspace also reported itself as an access denial, which sent
every deleted-workspace call down a permissions path nobody could
reproduce. `checkWorkspaceAccess` already distinguishes the two, so say
which one it was. The refusal log now carries the user and workspace it
refused; without them the only way to find the cause was to join by
timestamp.

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

* fix(copilot): put the dispatch boundary at the logging session, not the core

Review was right that entering the execution core is too early. The core
loads custom blocks, workflow state, and the environment before
`safeStart` writes a row, so a setup failure — which ran nothing and is
safely retryable — still reported a dispatched run and sent the caller
looking for it.

Move the marker to `loggingStarted`, read before the catch's own recovery
`safeStart` writes a row for the failure itself. That is the first point
blocks may have executed, so it is the honest line, and it lets
executeWorkflow go back to a plain rethrow.

The thrown value stays exactly as received, including a non-Error one:
the core's finalization guard identifies it, and three existing tests pin
that. A thrown primitive therefore carries no id, which costs nothing
today because every throw site past `safeStart` raises an Error — noted in
the code rather than papered over.

Also read the marker with `Object.hasOwn` rather than `in`, so an id
reached through a prototype chain can never disclose an unrelated run, and
cover the reserved-key branch of the disclosure guard.

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

* fix(copilot): log a withheld in-band result even when it withheld a success

The cause was written only on the failure branch, but a withheld success
keeps `projected.success` true — so the one case that leaves no other
trace, where the model reads a bare success and nothing says why, was also
the only one whose cause was never recorded. Report it on its own, as the
resume driver already does.

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

* fix(copilot): name the run from the executor, not the logging session

Review found the logging session wrong in both directions, and it is: the
result of `safeStart` is never checked, so blocks execute even when it
fails — reporting that nothing started for a run that did, which is the
direction that duplicates work — and it flips before trigger resolution
and serialization, reporting a run for failures that never reached a
block. A resume whose conditional update matches no row returns true and
named a run that does not exist.

Entering the executor is the only honest answer to "could a side effect
have occurred", because side effects come from blocks rather than from log
rows. Moving the marker there settles all three at once.

Also from review:
- Stop returning the thrown environment or database error to the model
  when the egress catalog is unavailable. Nothing there can project it —
  the catalog it would need is the very thing that is missing — so the
  reason stays in the log and the response carries fixed text plus the
  workspace id the caller itself supplied.
- Guard the attach against a frozen failure, which would otherwise throw
  and replace the original error partway through cleanup, making a
  diagnostic aid the thing that loses the diagnosis.

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

* fix(copilot): key the dispatched-run id off the failure instead of writing to it

Guarding the write was trading one failure for the worse one: a frozen or
sealed error kept the process alive but dropped the marker, which turns
"this run exists" into "nothing started" — the single direction that
duplicates work.

Record the id in a WeakMap keyed by the thrown value, the same shape
markExecutionFinalizedByCore already keeps for the same reason. Nothing is
written to the error, so a non-extensible one is recorded like any other
and there is no throw to guard. Identity keying also retires the
prototype-chain concern, and the error's own surface stays clean, so a
serialized failure no longer carries a stray field.

A thrown primitive still cannot be keyed, which costs nothing today
because every throw site past the dispatch boundary raises an Error.

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

* fix(copilot): let the executor say when a block could first run

Review was right that entering `execute` is still too early: DAG
construction, snapshot restoration and pipeline assembly all happen inside
it and reject a malformed graph having changed nothing, so a validation
failure reported a run to resolve.

Only the executor knows where that line falls, so it reports it. A
`onBlocksMayRun` context extension fires immediately before `engine.run`
on both entry points, and execution-core records the run from there rather
than guessing at it from outside. A rejected graph now correctly says
nothing started.

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

* fix(copilot): report the run from the engine, and never let recovery erase it

Two findings, both real.

Firing before `engine.run` was still one step early: the engine's
cancellation subscription is fallible and rejects having run nothing, so
that failure claimed a run. The signal now fires inside the engine,
immediately before the loop that processes blocks and past every startup
step that can refuse a request — DAG construction, pipeline assembly and
the subscription. The executor no longer guesses at the line from outside;
the engine states it.

Separately, the copilot catch path could throw while recording the failed
crossing or releasing the execution slot. Either one propagated a
different error — one the dispatched-run id was never recorded against —
so an existing run reported itself as never started and invited the
duplicate the id exists to prevent. Both are recovery work and neither may
replace the failure it is describing, so both are contained and logged.
Contained with try/catch rather than a rejection handler, since a
synchronous throw has to be caught too.

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

* fix(copilot): mark a run that failed after the core returned

Once `executeWorkflowCore` returns, the run happened. Everything after it
in `executeWorkflow` — analytics, pause persistence, post-execution
settling — is bookkeeping that can still throw, and the core's own catch
no longer runs, so those failures named no run. The copilot handler then
reported `not_attempted` for an execution that had already produced side
effects, which is the one direction that duplicates work.

Mark it as soon as the core settles, so any later failure carries it.

The `finally` had the same shape and is now contained: a throw there
replaces whatever the function was about to do, turning a successful run
into an error or an error that names its run into one that does not.
Settling post-execution work is bookkeeping and must not be able to do
either.

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

* fix(copilot): stop calling a cancelled run performed

`performed` claims the run reached the end of its work, so a caller reads
it as "never retry, just read the outcome". Every returned result carried
it, including a cancelled or paused one — which stopped partway and may
have run every block, one, or none.

Those are `attempted`: an execution exists under this id, resolve it
before deciding anything. That is true whether the cancellation landed
before the first block or after the last.

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

* fix(copilot): derive the run phase from what the executor saw, not the result's shape

Nine review rounds found the same class of defect, which makes it a design
problem rather than nine bugs. "Did a side effect occur" had two sources
that disagreed: a precise marker on the thrown path, and on the returned
path an inference from whatever the outcome happened to look like. Every
property used for that inference is a proxy that breaks on the paths that
matter — an engine failing before its first block still carries an
ExecutionResult, and a run that ends without one still ran every block it
had — so each round found another path where the proxy lied.

There is now one source. The engine reports the moment a block handler is
first about to run, which is terminal: no fallible step remains between it
and the handler, so there is nothing left for a later reviewer to find in
front of it. The signal is threaded to the caller and recorded against the
outcome, and the copilot adapter reads it on every exit path instead of
inspecting status or the presence of an attached result.

The phase then follows from two stated facts rather than a guess: nothing
dispatched is not_attempted whatever the result looks like, a run that
stopped partway is attempted, and one that reached the end is performed.

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

* fix(copilot): report dispatch from the block handler itself

I claimed last round that nothing could precede the signal. That was
wrong: `executeNode` returns early on a cache hit, initializes loop and
parallel scopes, and handles a sentinel that never reaches a handler — all
after the point it fired. Both reviewers found the same thing.

Move it to the line before `blockExecutor.execute`, which is the handler
call. Nothing separates the two, so unlike every previous position this
one cannot have something in front of it. Fired per block rather than
once, since observers record a boolean and repeats cost nothing.

Also accept functions as carriers of the run markers. They key a WeakMap
exactly as objects do, so excluding them dropped the record for a thrown
function and lost the distinction the markers exist to make.

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

* refactor(copilot): decide the run phase where the caller lives, not in the executor

Ten review rounds chased the same question — when exactly may a side effect
have occurred — through six positions in the executor, ending with a
callback on every block of every execution in the product. Against 543k
executions a week, serving a disclosure read about fifty times a week. The
precision was never the point: `attempted` and `performed` both mean an
execution exists under this id, and the caller was already handed the id
that resolves it.

Revert all of it. The engine, the orchestrator, both context types and the
callback threading through execute-workflow and execution-core go back to
staging untouched; the executor's only remaining change is the id carrier
in utils/errors.ts.

The phase now comes from what the copilot layer already holds. Its `try`
opens on the executor call, so everything it catches is post-dispatch by
construction while authorization, admission and provenance export throw
past it having created nothing — no id means nothing exists, an id means
resolve it. A result in hand says how the run ended, which separates
cancelled and paused from completed.

The harness that motivated this is now in the diff: every outcome the run
path can produce, driven through the real handler and the real projection,
asserted on the retry decision a caller can reach and on no run content
crossing. Six mutations were used to confirm it fails for the right
reasons; one of them found the dispatch flag this refactor introduced was
already dead, and it is gone.

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

* docs(copilot): state that a named run may resolve to nothing

Both reviewers read an id on a preflight failure as a defect. It is the
one place this contract is deliberately coarse, so say so where each of
them was looking rather than leave it to be rediscovered.

`attempted` already means "zero or one executions exist under this id" —
the id is a correlation key, not a promise that a row exists. A caller
resolves it, finds nothing, and retries, which is the right outcome at the
cost of one lookup.

Buying that lookup back means an executor-side dispatch marker: a callback
on every block of every execution in the product, which this branch just
reverted for that reason. It would also gain nothing, since all four
preflight throws are invariant violations — no workspace id, no billing
attribution, no principal, attribution mismatch — and a retry fails
identically.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… stale facts (#7203)

The screenshot predates the source-mix radar and still shows the allowance pills
this branch removes. Replaced, with the declared height following the new aspect
ratio and alt text describing what is actually on screen.

Two things beside it had gone stale with the same release. The tab table listed
BYOK, which is withheld until the ledger carries rows for it, so the table
advertised a tab nobody can see; the section further down described that tab's
contents as though it were reachable. Both now state the billing behaviour, which
is unchanged and still worth documenting, without claiming a surface that is not
there. And "Open logs" no longer goes to the workspace's execution logs — it opens
the organization audit feed scoped to that workspace.
@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Aug 28, 2026 7:45am

Request Review

@greptile-apps

greptile-apps Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Too many files changed for review (3000 files, 100 file limit).

* docs(library): update openai-vs-n8n-vs-sim

* Change author name in index.mdx

Updated author from 'andrew' to 'emir' in the document metadata.

---------

Co-authored-by: Sim Pi Agent <pi@sim.ai>
Co-authored-by: Waleed <walif6@gmail.com>
@waleedlatif1
waleedlatif1 merged commit d59b02c into main Aug 28, 2026
42 of 43 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants