Skip to content

feat(custom-blocks): let a publisher decide whether their block's runs reach consumer traces - #6950

Merged
icecrasher321 merged 4 commits into
stagingfrom
feat/custom-block-trace-policy
Aug 22, 2026
Merged

feat(custom-blocks): let a publisher decide whether their block's runs reach consumer traces#6950
icecrasher321 merged 4 commits into
stagingfrom
feat/custom-block-trace-policy

Conversation

@icecrasher321

Copy link
Copy Markdown
Collaborator

The problem

Joining a custom block's child run into its caller's trace shipped on by default, gated at read time by whether the person reading could already open the source workspace.

That gate does the wrong job. A custom block's whole point is that consumers need no access to the source — so the check refuses exactly the readers the feature exists for, and it makes the answer depend on who is looking rather than on what the block's owner agreed to publish.

The change

The decision moves to the party whose data it is. custom_block.trace_child_runs is set by the publisher in Settings → Enterprise → Custom blocks, applies org-wide, and is the entire policy — nothing downstream re-checks a caller.

getCustomBlockAuthority already resolves per invocation and is the one lookup both the canvas handler and the Agent-tool runner pass through, so a single column covers both surfaces with no per-surface plumbing, and no consumer input can assert it (there's a test for exactly that).

Removed: canStreamCustomBlockToViewer, the checkWorkspaceAccess call in hydrateChildTraces, and the 'denied' member of childTraceAccess.

It defaults to false — please sanity-check this

With the viewer check gone, an opted-in block publishes the source workflow's block names, inputs, outputs, and prompts to anyone who can read a consuming workflow's log. That's the same boundary curated outputs and redacted errors hold, so it opens by an affirmative act of the publisher or not at all — never as the residue of a column default on rows nobody revisited.

Practical effect: existing custom blocks stop joining traces until a publisher opts in. The trace-join feature is days old, so that population should be small.

Closed means no handle at all

Rather than persisting the handle behind a flag, a closed block emits no childExecutionId — there is nothing for a reader, a migration, or a later refactor to join. What replaces it is a _childTraceDisabled marker, because a boundary span with no children renders exactly like a leaf block and an untraced run would otherwise read as one that did nothing. The trace step shows Not traced.

The consumer-facing failure ref is untouched either way — it's the only thing that makes an untraced failure reportable.

Agent-tool invocations now join too

They never did. The child's handle already reached the agent's persisted toolCalls[].result (postProcessToolOutput strips only __-prefixed keys); nothing lifted it onto the tool span. Both span builders now lift and strip it, and hydrateChildTraces needed no change — its boundary walk already recurses into children.

The same handle is stripped from the model-facing copy in executeProviderTool, the single point where the raw and model copies diverge: an opaque execution id in a tool result reads to a model like data the tool returned.

One condition survives on the live stream

An identified consumer. Not an authorization check — no workspace query — but chat deployments and the public API leave liveTraceViewerUserId unset because their consumer may be anonymous, and opting into org-wide tracing is not consent to stream a publisher's raw agent tokens to the internet. Read-time joining has no such condition.

Copilot deliberately cannot set the field, with a comment saying it must never become a parameter.

Migration

0299ALTER TABLE custom_block ADD COLUMN trace_child_runs boolean DEFAULT false NOT NULL. Pure expand; check:migrations passes with no annotation needed (safe tier — the deployed app never writes the column and the default fills existing rows).

Verification

  • tsc clean, biome clean
  • 29,883 tests pass across all of apps/sim (0 failures)
  • check:migrations, check:api-validation:strict, check:boundaries, check:client-boundary, check:import-specifiers, check:utils, check:canvas-sentences, check:tool-registry-boundary all pass

Tests added cover: the publisher policy opening and closing the handle, a consumer input failing to assert it, the live-stream gate in both directions, no access query running at all, the failure ref surviving a closed policy, the tool-span lift on both provider paths (segments and raw tool calls), and hydration joining a tool span nested under an agent block.

Not exercised in a browser. The settings row uses the SettingRow + Switch pattern already in that form, but nobody has clicked it.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview Aug 22, 2026 1:08am

Request Review

@cursor

cursor Bot commented Aug 21, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Changes a trust boundary: opted-in blocks expose source workflow internals (names, I/O, prompts) to anyone who can read a consuming workflow’s logs, with no per-reader workspace check. Defaults closed, but live streaming and log hydration are security-sensitive.

Overview
Custom-block child traces are no longer gated by whether the reader can open the source workspace. The publisher sets traceChildRuns (default off) in Settings; that org-wide flag is the whole policy.

When off, the executor withholds _childExecutionId and writes _childTraceDisabled so the UI shows Not traced instead of looking like a no-op. Failures still return a consumer ref. When on, anyone who can read a consuming workflow’s log sees the source run’s steps; live SSE still requires an identified Sim user (not chat/public API). Copilot cannot set the flag.

Read-time hydrateChildTraces re-reads the live policy (so old handles are not treated as consent, and turning the flag off closes existing logs). Agent-tool invocations now lift the handle onto the tool span and strip it from the model-facing tool result. Existing blocks stay closed until a publisher opts in.

Reviewed by Cursor Bugbot for commit 952c22c. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR moves custom-block child-trace disclosure to an explicit publisher-controlled policy and extends trace joining to Agent-tool invocations.

  • Adds the trace_child_runs schema field, migration, API contract, and custom-block settings control.
  • Persists child execution handles only for opted-in blocks and checks the current publisher policy before hydrating historical logs.
  • Lifts child-trace metadata from Agent tool results into nested tool spans while stripping it from model-facing output.
  • Updates trace rendering, documentation, mocks, and tests for enabled, disabled, historical, and nested-tool behavior.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/sim/lib/logs/execution/hydrate-child-traces.ts Replaces reader workspace authorization with a fail-closed, live publisher-policy lookup for every child-trace boundary.
apps/sim/lib/logs/execution/trace-spans/span-factory.ts Promotes custom-block child-trace metadata from Agent tool results onto nested tool spans and removes the plumbing fields from displayed output.
apps/sim/executor/handlers/workflow/workflow-handler.ts Resolves publisher trace policy per invocation and withholds child execution handles when tracing is disabled.
apps/sim/ee/custom-blocks/components/custom-block-detail.tsx Adds publisher-facing state, dirty tracking, save behavior, and UI controls for the custom-block trace policy.
packages/db/migrations/0299_remarkable_gateway.sql Adds the non-null trace_child_runs column with a fail-closed default of false.
apps/sim/executor/handlers/workflow/workflow-handler.test.ts Tests publisher opt-in, consumer non-assertability, live-stream gating, failure references, and typed error narrowing for the new policy cases.

Sequence Diagram

sequenceDiagram
  participant Publisher
  participant Settings
  participant Executor
  participant Log
  participant Consumer
  Publisher->>Settings: Configure traceChildRuns
  Settings->>Executor: Persist publisher policy
  Executor->>Executor: Resolve policy for invocation
  alt Tracing enabled
    Executor->>Log: Persist childExecutionId
  else Tracing disabled
    Executor->>Log: Persist disabled marker only
  end
  Consumer->>Log: Open consuming workflow trace
  Log->>Settings: Read current publisher policy
  alt Handle exists and policy enabled
    Log-->>Consumer: Join child spans
  else Missing handle or policy disabled
    Log-->>Consumer: Show unexpanded boundary
  end
Loading

Reviews (3): Last reviewed commit: "fix(logs): sum the child-trace drop coun..." | Re-trigger Greptile

Comment thread apps/sim/lib/logs/execution/hydrate-child-traces.ts
Comment thread apps/sim/executor/handlers/workflow/workflow-handler.test.ts
Comment thread apps/sim/executor/execution/block-executor.ts
icecrasher321 added a commit that referenced this pull request Aug 21, 2026
…ot from the handle's presence

Treating a persisted `childExecutionId` as proof of publisher consent is only true
for handles this PR's writer produced. Every handle written before it meant
something else — "a child ran; authorize the reader" — and the rows carrying them
outlive the migration, so removing the reader check turned them into an open door:
a consumer could open an old parent log and receive the source workflow's block
names, inputs, outputs, and prompts from a block whose publisher never opted in.

`hydrateChildTraces` now resolves the policy live, per boundary, from
`custom_block.trace_child_runs`. The child log row's `workflowId` is the key —
publish enforces one block per workflow — which also covers an Agent-tool boundary,
whose span carries no block type to look up. A workflow with no block row (never
published, or since deleted) has no publisher left to consent and stays shut, as
does a failed policy read.

This is not redundant with the write-time withholding. The handler still emits no
handle for a block that was closed when the run executed, so such a run stays
closed forever even if the block is opened later; this check decides whether the
runs that DO carry a handle may still be shown. Turning the policy off therefore
also closes what is already recorded, which is what a governance switch has to do
to mean anything.

Reported by Greptile on #6950.

Also drops `any` from the trace-policy tests: outputs read through
`Record<string, unknown>` (the handler's declared return does not name these
internal keys) and failures narrow through `ChildWorkflowError.isChildWorkflowError`,
which pins the failure type as well as its fields.

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

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/logs/execution/hydrate-child-traces.ts
icecrasher321 added a commit that referenced this pull request Aug 21, 2026
…and-listed set

`totalDropped` re-listed four of the five counters, so a read whose only drops were
policy refusals computed zero and skipped the log entirely. That is the commonest
drop there is now — every handle written before the publisher policy existed refuses
at that gate — so the one signal telling an operator the live check is closing joins
went silent exactly when it started mattering.

Summed from the struct instead. A hand-maintained list beside a struct is stale the
moment a field is added, which is precisely how `policyClosed` was left out.

Reported by Cursor Bugbot on #6950.

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

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 952c22c. Configure here.

icecrasher321 and others added 4 commits August 21, 2026 17:56
…s reach consumer traces

Joining a custom block's child run into its caller's trace shipped on by default,
gated at read time by whether the person reading could already open the source
workspace. That gate is doing the wrong job: a custom block's whole point is that
consumers need no access to the source, so the check refuses exactly the readers
the feature exists for, and it makes the answer depend on who is looking rather
than on what the block's owner agreed to publish.

The decision moves to the party whose data it is. `custom_block.trace_child_runs`
is set by the publisher in Settings, applies org-wide, and is the entire policy —
nothing downstream re-checks a caller. `getCustomBlockAuthority` already resolves
per invocation and is the one lookup both the canvas handler and the Agent-tool
runner pass through, so one column covers both surfaces and no consumer input can
assert it.

It defaults to FALSE. With the viewer check gone, an opted-in block publishes the
source workflow's block names, inputs, outputs, and prompts to anyone who can read
a consuming workflow's log. That is the same boundary curated outputs and redacted
errors hold, so it opens by an affirmative act of the publisher or not at all —
never as the residue of a column default on rows nobody revisited.

Closed means the handle is withheld outright rather than persisted behind a flag:
with no `childExecutionId` there is nothing for a reader, a migration, or a later
refactor to join. What replaces it is a `_childTraceDisabled` marker, because a
boundary span with no children renders exactly like a leaf block and an untraced
run would otherwise read as one that did nothing. The consumer-facing failure
`ref` is untouched either way — it is the only thing that makes an untraced
failure reportable.

Custom blocks invoked as Agent tools now join too. The child's handle already
reached the agent's persisted `toolCalls[].result` (`postProcessToolOutput` strips
only `__`-prefixed keys); nothing lifted it onto the tool span. Both span builders
lift and strip it, and `hydrateChildTraces` needs no change — its boundary walk
already recurses. The same handle is stripped from the model-facing copy of the
tool result in `executeProviderTool`, the single point where the raw and model
copies diverge: an opaque execution id in a tool result reads to a model like data
the tool returned.

The live SSE stream keeps one condition beyond the policy: an identified consumer.
Not an authorization check — no workspace query — but chat deployments and the
public API leave `liveTraceViewerUserId` unset because their consumer may be
anonymous, and opting into org-wide tracing is not consent to stream a publisher's
raw agent tokens to the internet.

Copilot deliberately cannot set the field; exposing a team's internals org-wide is
a human decision, not one an agent makes while publishing on their behalf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ot from the handle's presence

Treating a persisted `childExecutionId` as proof of publisher consent is only true
for handles this PR's writer produced. Every handle written before it meant
something else — "a child ran; authorize the reader" — and the rows carrying them
outlive the migration, so removing the reader check turned them into an open door:
a consumer could open an old parent log and receive the source workflow's block
names, inputs, outputs, and prompts from a block whose publisher never opted in.

`hydrateChildTraces` now resolves the policy live, per boundary, from
`custom_block.trace_child_runs`. The child log row's `workflowId` is the key —
publish enforces one block per workflow — which also covers an Agent-tool boundary,
whose span carries no block type to look up. A workflow with no block row (never
published, or since deleted) has no publisher left to consent and stays shut, as
does a failed policy read.

This is not redundant with the write-time withholding. The handler still emits no
handle for a block that was closed when the run executed, so such a run stays
closed forever even if the block is opened later; this check decides whether the
runs that DO carry a handle may still be shown. Turning the policy off therefore
also closes what is already recorded, which is what a governance switch has to do
to mean anything.

Reported by Greptile on #6950.

Also drops `any` from the trace-policy tests: outputs read through
`Record<string, unknown>` (the handler's declared return does not name these
internal keys) and failures narrow through `ChildWorkflowError.isChildWorkflowError`,
which pins the failure type as well as its fields.

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

`totalDropped` re-listed four of the five counters, so a read whose only drops were
policy refusals computed zero and skipped the log entirely. That is the commonest
drop there is now — every handle written before the publisher policy existed refuses
at that gate — so the one signal telling an operator the live check is closing joins
went silent exactly when it started mattering.

Summed from the struct instead. A hand-maintained list beside a struct is stale the
moment a field is added, which is precisely how `policyClosed` was left out.

Reported by Cursor Bugbot on #6950.

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

Staging landed its own 0299 (`table_run_dispatches.heartbeat_at`) while this
branch was open. The two migrations are independent — different tables, no shared
statement — so only the number and drizzle's snapshot chain collided.

Regenerated rather than hand-merged: a drizzle snapshot is a full-schema dump
whose `prevId` links it to its parent, so editing one by hand to sit after a
migration it was not generated against is how the chain silently stops matching
the database. Staging's 0299 and its snapshot are taken verbatim; this is 0300,
generated against them, and its SQL is byte-identical to what it replaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@icecrasher321
icecrasher321 force-pushed the feat/custom-block-trace-policy branch from 952c22c to 5735a2a Compare August 22, 2026 01:03
@icecrasher321
icecrasher321 merged commit 71129cd into staging Aug 22, 2026
28 checks passed
@waleedlatif1
waleedlatif1 deleted the feat/custom-block-trace-policy branch August 22, 2026 18:46
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.

1 participant