Skip to content

fix(automation)!: refuse data ops for a run with no trigger user (#3760) - #3784

Merged
os-zhuang merged 1 commit into
mainfrom
fix/user-less-fail-open-3760
Jul 28, 2026
Merged

fix(automation)!: refuse data ops for a run with no trigger user (#3760)#3784
os-zhuang merged 1 commit into
mainfrom
fix/user-less-fail-open-3760

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes#3760.

The finding that changed the decision

The issue offered four options and favoured Option 1 — "inherit the triggering write's posture → run explicitly isSystem; effective permissions unchanged, so breakage is close to zero."

That premise is false.isSystem: true is strictly wider than the principal-less context these runs carry today. The security middleware's isSystem short-circuit is at security-plugin.ts:722before four fail-closed gates a principal-less context still has to clear:

linegateprincipal-lessisSystem
795package-managed rowrunsskipped
809system row (sys_position/sys_capability)runsskipped
817audience anchorrunsskipped
844delegated admin (RBAC link tables)throws on no userIdskipped
852empty-principal fall-opennext()

Concretely: today one of these cascade flows cannot write sys_user_positiondelegated-admin-gate.ts:172 throws, and the repo already has a test locking that. Under Option 1 it could. So Option 1 is a real privilege escalation, not a relabel.

Option 3 as literally written also doesn't work: a manufactured {isSystem:false, positions:[], permissions:[]} satisfies the line-852 predicate exactly and falls open again. Genuine "restricted" needs a middleware change.

Maintainer chose Option 2 (runtime fail-closed).

What ships

resolveRunDataContext — the documented single place every data node resolves its context — now throws UnscopedRunDataAccessError instead of handing back a principal-less envelope. The error names runAs:'system' as the fix.

This is not a new policy: ADR-0073 D5 already ruled that "user-less + runAs:'user' is a configuration error". It deferred the runtime half on two grounds this PR falsifies:

  1. "An unprivileged user cannot trigger a schedule, so there is no untrusted-input path." True of schedules, but schedules were never the boundary. The predicate is runAs !== 'system' && !userId, and the dominant shape is a record-change flow fired by a write that carried no userisSystem does not suppress trigger dispatch, only skipTriggers does, and exactly three first-party paths set it. Every plugin/service system write, the approvals status mirror, and a runAs:'system' flow's own data node dispatched record-change flows with userId: undefined. Submitting for approval mirrors a status onto the target record — so ordinary users reach it.
  2. "Fail-closed breaks legitimate scheduled CRUD — 2/3 example flows relied on the default." Those flows were fixed as part of fix(security): surface schedule/user-less flow runAs fail-open (#1888 follow-up) #2308; every first-party schedule-triggered flow now declares runAs:'system'. The stated cost expired.

ADR-0073 is amended in place rather than quietly contradicted, and the refusal is marked interim and forward-compatible: when its automation principal lands, the refusal point becomes the place that resolves it.

Also in this PR

  • flow-schedule-runas-unscopedflow-runas-unscoped, and it now fails the build.compile.ts documented in-line that the flow lint "NEVER fails the build" — it read as a gate and behaved as a comment, which is close to no net for the audience it protects. Extended to time_relative and api (ADR-0073 D5). It still cannot cover record_change — undecidable at authoring time, which is exactly why the runtime refusal exists; that limit is pinned by a test so nobody "fixes" the gap by guessing.
  • Three seed writes stopped firing automation. The seed loader's pass-2 deferred-reference back-fill and both AppPlugin basic-insert fallbacks inlined a bare {isSystem:true} instead of the shared seed options, seeding with record-change automation live — the self-trigger vector skipTriggers exists to prevent, on the writes that skipped it.
  • Approval: a schedule-triggered run still can't write its own locked record — it carries no ObjectQL context to hold flowRunId (#3456 residual) #3712's user-less provenance path is subsumed, not broken. Those runs are refused before the approvals lock is consulted; a schedule reaches its own record via runAs:'system', which the lock hook exempts on its own isSystem branch. The flowRunId exemption stays live and load-bearing for what fix(approvals): a dead approval run no longer leaves the record RECORD_LOCKED (#3456) #3703 built it for — a runAs:'user' run that does have a user.

Migration (breaking)

A flow reacting to system writes that must act beyond one user's grants declares runAs: 'system' — one line of metadata, and the elevation becomes explicit and audit-attributable. Otherwise ensure the trigger supplies a user. Flows touching no data are unaffected. The failure is isolated: the record-change trigger already swallows flow errors, so the originating write still succeeds, and the engine warns at run setup, before any node executes.

Verification

  • pnpm build (real type-check, no OS_SKIP_DTS) and pnpm test132/132 tasks green.
  • Both new gates proven able to go red. Reverting the refusal turns the new end-to-end test red with expected 'done' to be undefined — the flow really did write the record under the old behaviour; reverting the seed fix turns its test red too.
  • The live dogfood stack (flow-runas-schedule.dogfood.test.ts) previously pinned the fail-open explicitly, asserting a user-less run reads and writes an admin-owned record a member cannot touch. Its own header said a fail-closed change must turn it red and "force the product decision to be revisited deliberately" — that is what happened; it is inverted to pin the refusal, with the unchanged-record assertion carrying the weight.
  • Generated spec docs regenerated (gen:schema + gen:docs); check:api-surface clean.

Filed separately (Prime Directive #10)

🤖 Generated with Claude Code

An effective `runAs:'user'` run that resolves no trigger user executed its
data nodes UNSCOPED: it presented no principal, and the data security
middleware skips when there is no principal, so the run read and wrote every
row. `runAs:'user'` is an access-NARROWING declaration, and ADR-0049's standing
rule is that failing to resolve one must never resolve to a grant. It now
throws `UnscopedRunDataAccessError` from `resolveRunDataContext` — the single
place every data node resolves its context.
This was never really about schedules. The docs, the spec, the runtime warning
and the lint all described a schedule-shaped problem, and the lint only matched
that shape, but the runtime predicate is "no user". The commonest way to have
no user is a record-change flow fired by a write that carried none: `isSystem`
does not suppress trigger dispatch — only `skipTriggers` does, and three
first-party paths set it — so plugin/service system writes, the approvals
status mirror, and a `runAs:'system'` flow's own data node all dispatched
record-change flows with `userId: undefined`. Ordinary users reach those writes
routinely, so the fail-open was reachable by unprivileged input.
Deliberately NOT implemented as "inherit the write's posture and run as
isSystem". That reads like a relabel but is an escalation: the middleware's
isSystem short-circuit (security-plugin.ts:722) fires before the
package-managed-row, system-row, audience-anchor and delegated-admin gates
(795/809/817/844), all of which a principal-less context still clears. Such a
run cannot write sys_user_position today; as isSystem it could.
- Lint `flow-schedule-runas-unscoped` -> `flow-runas-unscoped`, now FAILS the
build and covers time_relative + api (ADR-0073 D5). It documented itself as
"NEVER fails the build" — a gate that behaved as a comment. It still cannot
cover record_change, which is undecidable at authoring time.
- Three seed writes (seed-loader pass-2 back-fill, both app-plugin fallbacks)
inlined a bare `{isSystem:true}` and so seeded with automation live.
- #3712's user-less provenance path is subsumed: such runs are refused before
the approvals lock is consulted, and a schedule reaches its own record via
`runAs:'system'`. The flowRunId exemption stays live for runs with a user.
- ADR-0073 amended: its "no untrusted-input path" severity claim is falsified,
and its rejection of fail-closed expired when the example flows it cited were
fixed to declare `runAs:'system'`.
Both new gates were verified to go red against the pre-fix behavior, including
the live dogfood stack, which previously pinned the fail-open explicitly.
Refs #1888, #3712, #3749, #3456, ADR-0049, ADR-0073.
Co-Authored-By: Claude <noreply@anthropic.com>
@vercel

vercelBot commented Jul 28, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredJul 28, 2026 4:32am

Request Review

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Jul 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 8 package(s): @objectstack/cli, @objectstack/metadata-protocol, @objectstack/plugin-approvals, packages/qa, @objectstack/runtime, packages/services, @objectstack/spec, packages/triggers.

119 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx(via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx(via packages/cli, @objectstack/spec)
  • content/docs/ai/skills.mdx(via @objectstack/spec)
  • content/docs/api/client-sdk.mdx(via @objectstack/cli, packages/runtime, @objectstack/spec)
  • content/docs/api/data-flow.mdx(via @objectstack/cli)
  • content/docs/api/environment-routing.mdx(via @objectstack/cli, @objectstack/spec)
  • content/docs/api/error-catalog.mdx(via @objectstack/cli, @objectstack/spec)
  • content/docs/api/error-handling-client.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx(via @objectstack/spec)
  • content/docs/api/index.mdx(via @objectstack/runtime, @objectstack/spec)
  • content/docs/api/wire-format.mdx(via @objectstack/runtime)
  • content/docs/automation/approvals.mdx(via @objectstack/plugin-approvals, packages/spec)
  • content/docs/automation/flows.mdx(via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx(via packages/cli, @objectstack/runtime, packages/spec)
  • content/docs/automation/hooks.mdx(via @objectstack/spec)
  • content/docs/automation/index.mdx(via @objectstack/spec)
  • content/docs/automation/webhooks.mdx(via packages/services, @objectstack/spec)
  • content/docs/automation/workflows.mdx(via @objectstack/spec)
  • content/docs/concepts/architecture.mdx(via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx(via packages/spec)
  • content/docs/concepts/index.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/metadata-protocol, packages/spec)
  • content/docs/concepts/north-star.mdx(via packages/runtime, packages/spec)
  • content/docs/data-modeling/analytics.mdx(via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/runtime, @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx(via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx(via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx(via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx(via @objectstack/spec)
  • content/docs/data-modeling/index.mdx(via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx(via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx(via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx(via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx(via @objectstack/spec)
  • content/docs/deployment/backup-restore.mdx(via @objectstack/cli)
  • content/docs/deployment/cli.mdx(via @objectstack/cli, @objectstack/spec)
  • content/docs/deployment/index.mdx(via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx(via @objectstack/runtime)
  • content/docs/deployment/self-hosting.mdx(via @objectstack/cli)
  • content/docs/deployment/single-project-mode.mdx(via @objectstack/runtime)
  • content/docs/deployment/troubleshooting.mdx(via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx(via @objectstack/spec)
  • content/docs/deployment/vercel.mdx(via @objectstack/runtime)
  • content/docs/getting-started/build-with-claude-code.mdx(via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx(via @objectstack/spec)
  • content/docs/getting-started/examples.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx(via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/cli, @objectstack/runtime, @objectstack/spec)
  • content/docs/kernel/cluster.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx(via packages/spec)
  • content/docs/kernel/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/audit-service.mdx(via packages/services)
  • content/docs/kernel/runtime-services/data-service.mdx(via packages/cli)
  • content/docs/kernel/runtime-services/email-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx(via packages/cli, packages/services, packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/settings-service.mdx(via packages/services)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx(via packages/spec)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/spec)
  • content/docs/permissions/authentication.mdx(via @objectstack/cli, @objectstack/runtime)
  • content/docs/permissions/authorization.mdx(via packages/qa, packages/runtime, @objectstack/spec)
  • content/docs/permissions/delegated-administration.mdx(via packages/qa)
  • content/docs/permissions/permission-sets.mdx(via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx(via @objectstack/spec)
  • content/docs/permissions/positions.mdx(via @objectstack/spec)
  • content/docs/permissions/rls.mdx(via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx(via @objectstack/spec)
  • content/docs/plugins/development.mdx(via @objectstack/spec)
  • content/docs/plugins/index.mdx(via @objectstack/spec)
  • content/docs/plugins/packages.mdx(via @objectstack/cli, @objectstack/plugin-approvals, @objectstack/runtime, packages/services, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx(via @objectstack/spec)
  • content/docs/protocol/diagram.mdx(via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/i18n-standard.mdx(via packages/services, @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/cli, @objectstack/spec)
  • content/docs/protocol/kernel/realtime-protocol.mdx(via @objectstack/cli)
  • content/docs/protocol/kernel/runtime-capabilities.mdx(via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx(via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx(via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx(via @objectstack/cli, @objectstack/plugin-approvals, @objectstack/runtime, @objectstack/spec)
  • content/docs/releases/index.mdx(via @objectstack/spec)
  • content/docs/releases/v12.mdx(via @objectstack/spec)
  • content/docs/releases/v13.mdx(via @objectstack/spec)
  • content/docs/releases/v16.mdx(via @objectstack/cli, @objectstack/spec)
  • content/docs/releases/v9.mdx(via @objectstack/plugin-approvals, @objectstack/spec)
  • content/docs/ui/actions.mdx(via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx(via @objectstack/spec)
  • content/docs/ui/dashboards.mdx(via @objectstack/spec)
  • content/docs/ui/forms.mdx(via @objectstack/spec)
  • content/docs/ui/index.mdx(via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx(via @objectstack/spec)
  • content/docs/ui/setup-app.mdx(via @objectstack/spec)
  • content/docs/ui/translations.mdx(via @objectstack/spec)
  • content/docs/ui/views.mdx(via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The #1888 user-less fail-open is wider than the lint that guards it — record-change flows fired by a system write run UNSCOPED, unlinted

1 participant

@os-zhuang