Cross-Workspace References
A flow step’s action: can point at another workspace’s action directly — owner_workspace.action_name — with no server configuration and no library. The step then runs with that owner workspace’s files, secrets, and connections; the caller only supplies plain input values.
This is separate from Libraries: a library is an explicit, admin-configured shared source merged into every workspace. A cross-workspace reference is a direct, ad-hoc pointer from one workspace to another, resolved live at job-creation time.
Syntax
Section titled “Syntax”tasks: daily: flow: recalc: action: jobs.recalc-agg-sessions # owner workspace: "jobs", action: "recalc-agg-sessions" input: dataset: "{{ run.output.dataset }}"jobs.recalc-agg-sessions is resolved against the jobs workspace’s configuration — no libraries: entry, no import step. The action must simply exist in that workspace.
No library needed
Section titled “No library needed”Libraries require a libraries: entry in server-config.yaml (Git repo or folder) that gets imported and prefixed into every workspace at load time. Cross-workspace references need none of that — any workspace can reference any other workspace’s action by name, as long as both workspaces are configured on the same server.
Precedence: library first, then workspace
Section titled “Precedence: library first, then workspace”A dotted name is resolved in this order:
- Library item. Libraries are flattened into each workspace’s config at load time under the
libname.prefix, sofoo.baris already a literal key inworkspace_config.actionsiffoois a configured library. This is checked first and is unchanged from existing behavior. - Cross-workspace reference. If there’s no local match, the name is split on the first
.into(workspace, item), anditemis looked up in that workspace’s config.
Unqualified (non-dotted) names are always local — this is fully backward-compatible; existing workspaces and library references keep working exactly as before.
Owner-context execution
Section titled “Owner-context execution”The key rule: a cross-workspace action runs in the workspace that owns it.
- The flow step’s
input:map still renders in the caller’s context —{{ run.output... }},{{ input... }}, and previous-step outputs all resolve against the calling job, exactly as for a local step. - The action body — its script/command/env templates, connection-typed inputs, and any
{{ secret.* }}references — renders against the owner workspace’s files, secrets, and connections.
This means the caller passes only plain input values; it does not need any of the owner’s connections, connection types, or secrets configured locally. A connection-typed input on the cross-workspace action (for example, an input defaulting to clickhouse-prod) resolves by bare name in the owner workspace, not the caller’s.
Revision pinning
Section titled “Revision pinning”The owner workspace’s revision is pinned when the job is created (the same discipline as job.revision), so a mid-run change to the owner workspace’s config cannot shift the action under an in-flight job. The worker fetches the owner workspace’s tarball at that pinned revision for the step — a cross-workspace step downloads exactly one workspace tarball (its owner), never two.
A step can also pin the owner to a branch, tag or commit with ref: —
see Git Refs. Without ref the owner’s live config is
used, as described here.
Connections
Section titled “Connections”A connection-typed input may name another workspace’s connection directly,
and a type may be another workspace’s type. Both use the same workspace.name
addressing and are independent of each other:
# ai_traffic_model — no ClickHouse type, connection, or secret defined heretasks: daily: input: clickhouse: type: jobs.clickhouse # the TYPE lives in workspace `jobs` default: jobs.clickhouse-prod # the CONNECTION lives in `jobs` too flow: run: action: score-all # a LOCAL action input: clickhouse: "{{ input.clickhouse }}"shared: true
Section titled “shared: true”A connection can be referenced from another workspace only if its owner marks it shared:
# jobs workspaceconnections: clickhouse-prod: type: clickhouse shared: true host: ch.internal password: "{{ secret.ch_password }}"Unshared connections are private to their workspace. Whether — and when — a reference to one from elsewhere is rejected depends on where the reference appears:
- Literal references — task input defaults, and a flow-step value written
as a plain string containing none of
{{,{%or{#— are checked at job creation and fail immediately with400 Bad Requestand a message ending inis not shared. - Templated flow-step values (anything containing
{{,{%or{#) can’t be checked before the job runs, since the template may resolve to a different connection depending on prior step output. These are resolved when the step is claimed and fail that step instead — the job is created successfully, and the reason (“… exists but is not shared”) appears in the step’s error message.
Inside its own workspace the flag is ignored.
How types match
Section titled “How types match”Every type reference is normalised to (workspace, type). A bare name means
the workspace the YAML is written in, so type: clickhouse in ai_traffic_model
is a different type from type: clickhouse in jobs, even if both exist.
A connection satisfies an input only when both resolve to the same pair.
Consequences:
| Input declares | Connection value | Connection’s own type: |
Result |
|---|---|---|---|
jobs.clickhouse |
jobs.clickhouse-prod |
clickhouse (in jobs) |
OK if shared |
jobs.clickhouse |
infra.ch-eu |
jobs.clickhouse |
OK if shared — type and connection in different workspaces |
clickhouse (local) |
jobs.clickhouse-prod |
clickhouse (in jobs) |
Rejected: caller.clickhouse ≠ jobs.clickhouse |
jobs.clickhouse |
local-ch (local) |
jobs.clickhouse |
OK — a local connection may adopt a foreign type |
Untyped connections (no type:) match any input type, as they do locally.
A connection whose type: is in another workspace gets that type’s property
defaults applied and its required fields checked when the job is created (the
owner’s load-time validation never saw the type).
Cross-workspace actions and the shared gate
Section titled “Cross-workspace actions and the shared gate”On a step whose action is owner.action, connection names the caller
supplies resolve in the caller first and then, if not found, in the owner —
but only shared ones. Names that come from the owner action’s own default:
resolve in the owner without the gate. Before this release the caller could
name any owner connection bare; now the owner must mark it shared: true or
the step fails with ... exists but is not shared. Owner-side defaults are
also never re-rendered against a caller-supplied value — only fields the
caller actually left out are filled in and rendered — so a caller cannot
smuggle out the owner’s secrets through a value it supplies itself.
Dropdown
Section titled “Dropdown”The task form lists every eligible connection: local ones by bare name, then
shared foreign ones as workspace.name.
Redaction and visibility
Section titled “Redaction and visibility”Resolved connection values are stored with the job. Job detail redacts every
workspace’s secrets values and every connection property whose type marks it
secret: true. Everything else in a shared connection is visible to anyone
with View permission on a task that uses it, in any workspace — mark
credentials secret: true in the connection type.
- Redaction is computed from the workspaces currently loaded on the server, not from what was loaded when the job ran. If an owner workspace becomes unhealthy (fails to reload, Git source unreachable, etc.), its secret-marked values are not masked in already-created jobs until that workspace loads successfully again.
- A job that touches a git ref is
also masked with the secrets of every pinned commit it is connected to.
Those are loaded from the commit itself, and if one cannot be loaded these
responses never fall back to a partial mask (
503, or every value masked). Job logs and artifacts are not masked. - The same masking now also applies to the sync webhook response, the
webhook job-status poll, MCP
get_job_statusand the recent steps on worker detail, not only to job detail.
Trust model for owner-side rendering errors
Section titled “Trust model for owner-side rendering errors”If a template of an OWNER workspace (a different workspace than the
caller’s) fails to render — for example a default like {{ secret.TOKEN | round }} where TOKEN isn’t numeric — the caller’s job never sees the
details. This applies in two places:
- Dispatching a
type: taskstep: the action’s own defaults, or the task’s own defaults, rendered against the action owner’s or the task owner’s config. - Claiming a step of a cross-workspace action (
owner.action): the action’s owninputdefaults and the owner connections they resolve, and the action body (script,cmd,env,args,image,manifest), which renders with the owner’s secrets. The step fails withrendering action '<action>' of workspace '<owner>' failed; details withheld— in its error, in the job log and in the claim response. The action body is withheld whichever value failed in it, including one that came from the caller’s input. Agent prompts are not withheld: they render in the caller’s context.
The persisted step error is a fixed message naming the workspace whose
rendering failed. At dispatch it reads Failed to prepare input for task step '<step>': rendering in workspace '<owner>' failed (details withheld from this job; see the server log or validate the owner workspace); at claim it is the
shorter rendering action '<action>' of workspace '<owner>' failed; details withheld, which does not mention the server log (the full error is logged
there all the same). Neither ever contains any representation of the value
that caused the failure, however that value was
encoded (raw, JSON-escaped, or otherwise — a filter chain can produce
arbitrarily many encodings, which is exactly why this is withheld outright
rather than scrubbed).
The server log is value-free for template errors too: full detail is only
available locally, via stroem validate or stroem run. Other server-log
text carries the workspace’s known secret values scrubbed out — but that scrub is
best-effort (it matches a secret’s exact value plus its JSON-escaped and
Rust-debug-escaped forms), not a guarantee: an unusual encoding, such as a
long or unusual filter chain, could in principle still leave a trace of the
value in the log text. The server log is an operator-trusted surface, so
this is an acceptable tradeoff there — it is precisely why the caller-facing
job record gets the stronger guarantee (withholding, not scrubbing) instead.
What is withheld is decided by where the failing template comes from, not by
whose value broke it. An error in the caller’s own step input: (rendered in
the caller’s context) and an error about a connection name the caller
supplied (for example, an unshared owner connection) are the caller’s own
data and are always shown as before, scrubbed of known secret values — at
dispatch and at claim alike. The other way round, an error rendering a
foreign owner’s action body or image at claim is withheld even when the
value that broke it came from the caller’s input, because the template is
the owner’s. Likewise, a structural problem (the referenced task
doesn’t exist, a required field is missing, a database error) is never
withheld either, whichever workspace it’s reported against — with one
exception: a structural problem INSIDE the owner’s own task default (for
example, a task-level default names a connection that doesn’t exist) is
withheld the same as a rendered-secret failure, because at the point this is
decided it is indistinguishable from one — both are “the owner’s own task
schema failed to resolve.” A step whose owner IS the caller’s own workspace
is unaffected by any of this — its rendering errors are scrubbed and
persisted as always.
Offline CLI
Section titled “Offline CLI”stroem validate warns on qualified type references (they are validated at job
creation). stroem run cannot resolve a qualified connection and fails with
cross-workspace connection references require a server.
Open access
Section titled “Open access”Any workspace may reference any other workspace’s actions — there is no ACL gate on cross-workspace action references in this release. This is an intentional, low-friction choice for deployments where all workspaces are internal and already gated at the task-execution boundary.
Connection references are narrower: a foreign connection is only reachable when its owner opts in with shared: true (see Connections above). There’s no workspace-level exports: allowlist — the flag is per-connection and global to the server, not scoped to specific callers.
Before / after example
Section titled “Before / after example”This mirrors the incident that motivated this feature: ai_traffic_model’s daily task needed to call jobs.recalc-agg-sessions and use the jobs workspace’s clickhouse-prod connection, without either being configured locally.
The jobs workspace side is unchanged between “before” and “after” except for one flag:
# jobs workspaceconnections: clickhouse-prod: type: clickhouse shared: true # required for any other workspace to name this connection host: ch.internal password: "{{ secret.ch_password }}"Before (fails — no jobs library, no local clickhouse-prod connection):
# ai_traffic_model / daily.yamltasks: daily: input: day: { type: date, required: false } clickhouse: { type: clickhouse, required: true } # local connection-type + connection needed flow: run: action: score-all recalc: depends_on: [run] action: jobs.recalc-agg-sessions # no "jobs" library configured -> unresolved for_each: "{{ run.output.periods | json_encode() }}" input: clickhouse: "{{ input.clickhouse }}" dataset: "{{ run.output.dataset }}" date: "{{ each.item.date }}" date_to: "{{ each.item.date_to }}" company_nums: "{{ run.output.companies }}"After (works — cross-workspace reference, no local ClickHouse config at all):
# ai_traffic_model / daily.yaml — no ClickHouse connection/type/secret defined locallytasks: daily: input: day: { type: date, required: false } # note: clickhouse input REMOVED flow: run: action: score-all recalc: depends_on: [run] action: jobs.recalc-agg-sessions # cross-workspace action (owner: jobs) for_each: "{{ run.output.periods | json_encode() }}" input: clickhouse: "clickhouse-prod" # resolved in jobs (owner) context dataset: "{{ run.output.dataset }}" date: "{{ each.item.date }}" date_to: "{{ each.item.date_to }}" company_nums: "{{ run.output.companies }}"- Job creation succeeds:
"clickhouse-prod"is a caller-supplied value on a cross-workspace action’s input, so it is looked up in the caller (ai_traffic_model) first, misses (no such local connection), and falls back to the owner (jobs) — where it exists and is markedshared: true, so the fallback is allowed. - The
recalcstep is stamped with the owner workspace (jobs) and its pinned revision. - The worker fetches the
jobstarball for that step, so files likeagg_sessions_4.sqlare present, andclickhouse-prodresolves usingjobs’ own secrets.
Calling tasks in other workspaces
Section titled “Calling tasks in other workspaces”A type: task action can name a task in another workspace, two ways:
# Form A — directactions: call-deploy: type: task task: platform.deploy # workspace "platform", task "deploy"
# Form B — through the owner's own wrapper actiontasks: release: flow: deploy: action: platform.run-deploy # platform defines run-deploy as type: taskThree workspaces can be involved: the caller (where the flow step
lives), the action owner (the workspace that owns the type: task
action — the caller for form A, platform for form B), and the task
owner. The task: name is looked up in the action owner first —
library-flattened names like common.deploy live there — and only on a
miss is it split into workspace.task.
The child is a real job of the task owner: it runs with that workspace’s
files, secrets, connections and revision, appears in its own job list,
fires that task’s own on_* hooks, and is governed by that workspace’s
ACL. The caller’s step receives the child’s status and output exactly as
for a local child.
Inputs
Section titled “Inputs”- The flow step’s
input:renders in the caller’s context. - The
type: taskaction’s owninputdefaults are read from the definition persisted on the step at job creation (so editing the action does not change an in-flight job), rendered with the action owner’s live secrets. - Connection-typed fields of the task’s input schema resolve by where
the value came from: a caller value resolves in the caller first, then
in the task owner if the connection is
shared: true; an action default resolves in the action owner first, then the task owner if shared; the task’s own defaults resolve in the task owner without a gate. - A value that crosses a workspace boundary into a connection-typed field
must be a connection name — an object, array, number, boolean, or null
is rejected. This is a
400at submit only for a literal value supplied by the caller’s flow step, on a step with nowhenguard, when the caller isn’t the task’s own workspace; a templated value, a value on awhen-guarded step, or a foreign action default instead fails the step at dispatch, if the step is reached. Within one workspace an object still passes through as before. - The parent job’s own step
input(whatGET /api/jobs/{id}shows for thetype: taskstep itself, not the child) only ever shows what the caller supplied — never the action owner’s resolved defaults — when the owner is a different workspace from the caller. This keeps the owner’s unshared connections, resolved into plain values by its defaults, out of view for anyone who can only see the caller’s job. The child job’s own input is unaffected and still carries the full merged input.
Revision
Section titled “Revision”A same-workspace child inherits its parent’s revision. A cross-workspace child gets the task owner’s current revision at the moment it is created — the value the owner’s own triggers would use — unlike cross-workspace actions, whose owner revision is pinned when the parent job is created.
With ref: on the type: task action, the child runs the task owner’s
commit at that ref, resolved when the parent job is created (see
Git Refs).
Trust model
Section titled “Trust model”Calling a task is delegation to its author. Any workspace can call any
task; the Run permission on the caller’s task is the authorization, and
there is no per-child check. Write a task other workspaces may call as you
would a webhook-triggered task: any input the schema accepts may arrive.
The connection rules above cover connection-typed inputs declared on the
task only — a plain input that your own task templates into a connection
name on one of its actions binds in your own workspace, with no shared
gate, exactly as when your own trigger runs it. The child’s output is
returned to the caller verbatim, so a task that emits credentials in its
output discloses them to every caller.
A child is not a scheduler run of the owner’s task: it takes no task-level retry, fires no workspace-level hooks, and is cancelled when the caller’s job is cancelled (see the cancellation caveat below).
The child’s own job detail, logs, and artifacts are governed by the task
owner’s ACL, independently of the caller’s. A caller without View on the
task owner’s task gets a 404 on the child’s detail even though its own
job succeeded; an admin, or anyone with View on the owner side, can open
it.
A chain of type: task calls across workspaces is bounded by the same
maximum task depth (10) as any indirect recursion within one workspace.
Ancestry-based cycle detection is deliberately not added: bounded indirect
recursion guarded by a when condition is a legitimate pattern it would
break.
The parent → child link
Section titled “The parent → child link”The job detail’s step view lists every child job ever created for a
type: task step, newest first. This is execution history, not attempt
identity — a step retry resets the row before its replacement child
exists, and a duplicate dispatch (see the caveats below) can create two
children for one attempt — so the list makes no claim about which entry is
“current.” Each entry links to /jobs/{id}, subject to the child’s own
ACL.
Caveats a cross-team caller must know
Section titled “Caveats a cross-team caller must know”These apply to every type: task child today, same-workspace or not, and
are tracked as carried risks for a separate fix:
- Double dispatch. Two concurrent settlement passes on the same job can both see the same step ready and both create a child — the owner’s task runs twice.
- A stale dispatcher can fail a winner’s step. A dispatcher working from a stale read that then hits a render or default-merge error fails the step even though another dispatcher already succeeded and created a child.
- Cancellation can miss a child committed just after the cancellation’s enumeration snapshot, by a dispatcher working from an earlier readiness read.
- A timed-out parent step does not cancel its child. The child’s later completion is still written over the step; with a step retry, the stale child can settle the replacement attempt instead.
- Removing a task that other workspaces call strands their running
jobs. A job whose task is removed mid-run is not settled by its own
steps; absent a job timeout or an operator cancel, it stays
runningand the caller’s step waits indefinitely. - A child whose first step is an approval fires no
on_suspendedhook — it waits silently until someone notices and approves it directly. - Terminal delivery is not atomic. A crash between a child’s one-shot terminal claim and the write to the parent step loses that delivery.
Errors calling a task
Section titled “Errors calling a task”task: nope.deploy (unknown workspace), task: platform.nope (no such
task), and a task that references itself, are all 400 at submit; the
task owner being temporarily unavailable is 500. This check runs for
every type: task action reference, not just a cross-workspace
(qualified) one — a plain, same-workspace task: deploy naming a task
that does not exist is also a 400 at submit, and it runs whether or not
the flow step has a when guard (only the literal connection input
check below is skipped for a when-guarded step). Only the submitted
task’s own steps are checked at submit time — a nested reference that
fails resolves when its step is dispatched, and fails that step only.
Errors
Section titled “Errors”An unresolvable action reference — either the named workspace doesn’t exist, or the workspace exists but has no action by that name (for example a typo like jobs.recalc-agg-session, missing the trailing s) — returns 400 Bad Request with a precise message, never a 500. The same fix applies to a missing/misnamed local connection reference.
This does not cover the owner workspace being transiently unavailable (for example, a Git-backed workspace that failed to load on its last poll) — that’s a server/load-health condition, not a caller mistake, and still surfaces as 500.
A step guarded by a when condition still has its type: task action’s target task existence checked at job creation (see above) — a missing task is a config error regardless of when. What a when guard skips is the literal connection input pre-check, since the condition may make the step never run: a bad literal connection value on a when-guarded step surfaces as a step failure only if and when that step is actually reached.
Not yet supported
Section titled “Not yet supported”The following are deliberately out of scope for this release:
- Cross-workspace agent actions. An
agentstep that is a cross-workspace reference still renders its prompt, system prompt, and MCP/task tools against the caller’s workspace config, not the owner’s — only script/docker/pod action bodies (and their connection-typed inputs) render in the owner context. refon agent actions — rejected with400, for the same reason as above.- Cross-workspace hook actions.
on_success/on_error/on_cancel/on_suspendedhook actions are not resolved cross-workspace — only flow-stepaction:references are. Atype: taskhook action naming another workspace’s task is rejected at runtime, before any hook job is created: no hook job is created at all, and the error is logged to the source job (the job whose completion fired the hook), not to a hook job. (A config-load-time validator that would catch this when the workspace is loaded exists instroem-commonbut is not yet wired into any server load/reload path — this is a pre-existing gap tracked indocs/internal/TODO.md, not something this release added.)