fix(logs): stop requiring a human subject on actorless runs
Summary
Scheduled, webhook, and public-API runs are actorless by design — no human behind them. Since #7190, execution-core.ts only sets executorDelegationOrigin.subjectUserId when the run's principal resolves to a sim_user, so the executor delegation for those runs carries no subject. Every use case downstream that called requirePrincipalSubjectUserId then threw PrincipalSubjectUserRequiredError, which isn't an OrchestrationError and surfaced as an opaque 500.
The reported symptom was Logs — logs_get, logs_get_run_details, and logs_get_execution returned Failed to fetch log for every scheduled run while logs_query kept working — but the same break hit several domains at once.
Authorization for an actorless caller was never the problem and is unchanged. workspace-authorization.ts authorizes those callers on the workflow running a deployment, with no userId involved; that's exactly why the query tools kept working. What broke was code treating the userId as required when it was doing something else entirely. Each site now treats it as what it actually is:
| Domain | What the userId actually was | Treatment |
|---|---|---|
Logs — read-log-detail, read-execution-snapshot | Attribution for large-value materialization and secret projection. The read path never writes, so LargeValueStoreContext.userId is already optional and unread. | Resolved optionally; viewerUserId optional through fetch-log-detail and hydrate-child-traces |
Tables — rows.ts | A tables-v2-api feature-flag dimension | Resolved optionally — a missing user resolves the admin clause to false with no query, so the gate only ever narrows |
Credential groups — create-invite-link | created_by, a nullable on delete set null column | An actorless run issues an unattributed link rather than being refused. send-invite reuses the subject its own guard already resolved |
MCP — execute-tool, use-cases | Genuinely load-bearing: the stored credentials presented to a third-party server, plus the permission-group gate | Preserves pre-existing behavior — see below |
MCP
This one is a real identity, so it preserves what was already happening rather than changing it. Before these tools moved in-process, the executor minted an internal token from ExecutionContext.userId and the MCP route ran as that user; the intermediate version passed the same value as subjectUserId. An unattended run has therefore always reached MCP as the execution actor.
That value is now passed explicitly as executionActorUserId instead of arriving implicitly, and is documented for what it is: for a schedule, webhook, or anonymous public-API run the actor is the workspace system actor resolved during preprocessing — the billing payer, not the workflow's author. A principal that names its own subject always wins, so this can't be used to nominate someone else's credentials, and it is not an authorization input.
An external_user webhook subject falls back too, deliberately: it is a real identity but never a Sim user, so it has no Sim credentials of its own, and those runs have always connected as the actor. Refusing them would break working workflows over a boundary the old path never drew.
Supporting changes
@sim/authgainsresolvePrincipalSubjectUserId, withrequirePrincipalSubjectUserIdreimplemented on top. Theresolve/requirepair makes the choice visible at each call site — pickingrequirewhereresolvewas correct is what caused this.- New
identity-faults.tscentralizes the identity-error classification that was copy-pasted across 8 internal tool handlers:PrincipalSubjectUserRequiredError→ 403 naming the cause, and a now-typedExecutorDelegationOriginRequiredError→ 401 (it was a bareError, so a request with no execution context fell through to a generic 500). Each handler keeps its own response envelope. - New
check:actorless-executor-operationsaudit keeps this class visible instead of waiting on a canary: it resolves operation policies through spreads and same-file factories, fails closed forlib/internal/**and for shared use-case factories in executor-admitting domains, and requires// actorless-unsupported: <reason>on the 14 sites where a person genuinely is required.
Known gap / follow-ups
- An unattended run reaches a third-party MCP server as the workspace billing payer. That is pre-existing and preserved here on purpose; the real fix is a workspace-level MCP identity, which is a product decision rather than part of a regression fix.
- A cheaper refinement: the user is only strictly required for
authType === 'oauth'servers.headers(the DB default) andnoneneed no person unless the config references a personal env var, andresolveMcpConfigEnvVarsruns withstrict: trueso a missing var throws rather than silently sending an empty header.
Type of Change
- Bug fix
Testing
Tested manually. bun run type-check, bun run lint:check, check-block-registry, and bun run check:audits (39/39, including the new audit) all pass. 434 test files / 4807 tests pass across logs, mcp, credential-groups, tables, internal, and auth.
New regression coverage: the actorless path at the readLogDetailUseCase boundary (exercising the real authorization path, not a stub), in hydrateChildTraces, in readLogDetail, for an unattended run and an external-subject webhook reaching MCP as the execution actor, for an authenticated subject taking precedence over it, for the 403 and 401 identity-fault mappings, and fixture-driven tests for the new audit script.
Checklist
- Code follows project style guidelines
- Self-reviewed my changes
- Tests added/updated and passing
- No new warnings introduced
- I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)