Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .changeset/user-less-run-data-ops-refused.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
---
"@objectstack/service-automation": major
"@objectstack/cli": major
"@objectstack/plugin-approvals": patch
"@objectstack/metadata-protocol": patch
"@objectstack/runtime": patch
"@objectstack/spec": patch
---

feat(automation)!: a flow run with no trigger user may no longer touch data (#3760)

An effective `runAs:'user'` run that resolves **no trigger user** used to execute
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; failing to resolve it must
never resolve to a grant (ADR-0049). It now **refuses** the operation
(`UnscopedRunDataAccessError`), naming `runAs:'system'` as the fix.

**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
ever matched that shape. But the runtime predicate is "no user", and 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 exactly three first-party paths set it — so 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`.
Ordinary users reach those writes routinely (submitting for approval mirrors a
status onto the target record), so the fail-open was reachable by unprivileged
input and was the common case, not the rare one.

Deliberately **not** implemented as "inherit the triggering write's posture and
run as `isSystem`". That reads like a relabel but is a privilege escalation: the
security middleware's `isSystem` short-circuit fires *before* its
package-managed-row, system-row, audience-anchor and delegated-admin gates, all
of which a principal-less context still has to clear. Such a run cannot write
`sys_user_position` today; as `isSystem` it could. "Unscoped" was never
equivalent to "system".

**Breaking — how to migrate.** A flow that reacts to system writes and needs to
act beyond one user's grants declares `runAs: 'system'`, making the elevation
explicit and audit-attributable. Otherwise ensure the trigger supplies a user.
Flows that touch no data are unaffected (`runAs` is moot), and the failure is
isolated: the trigger already swallows flow errors, so the originating write
still succeeds. The engine warns at run *setup*, before any node executes.

**#3712's user-less provenance path is subsumed, not broken.** That fix let a
run with no trigger user write its own approval-locked record by carrying a
provenance-only ObjectQL context (the run id, nothing else). Such a run can no
longer perform a data operation at all — presenting no principal is exactly what
made the write unscoped — so it is refused before the lock is consulted. The
capability survives via the explicit route: a schedule that must write records
declares `runAs:'system'`, which the lock hook exempts on its own `isSystem`
branch. The `flowRunId` exemption itself stays live and load-bearing for what
#3703 built it for — a `runAs:'user'` run that *does* have a user — where the
exemption is still provenance rather than privilege.

Also in this change:

- **`flow-schedule-runas-unscoped` → `flow-runas-unscoped`, and it now fails the
build.** It read as a gate and behaved as a comment — `os compile` documented
that the flow lint "NEVER fails the build" — which is close to no net at all
for the audience it protects, very often an AI generating flows in bulk. It now
also covers the other provably user-less triggers (`time_relative`, `api`), per
ADR-0073 D5. It still cannot cover `record_change`, which is undecidable at
authoring time — that is exactly why the runtime refusal exists.
- **Three seed writes stopped firing automation.** The seed loader's pass-2
deferred-reference back-fill and both of `AppPlugin`'s basic-insert fallbacks
inlined a bare `{ isSystem: true }` instead of the shared seed options, so they
seeded with record-change automation live — the self-trigger vector
`skipTriggers` exists to prevent, on the writes that skipped it.
- **ADR-0073 amended.** Its severity rationale ("an unprivileged user cannot
trigger a schedule, so there is no untrusted-input path") is falsified, and its
rejection of fail-closed ("breaks legitimate scheduled CRUD — 2/3 example flows
relied on the default") expired when those flows were fixed to declare
`runAs:'system'`. Refusal is an interim posture, forward-compatible with the
ADR's `automation` principal: when that lands, the refusal point becomes the
place that resolves it.
4 changes: 3 additions & 1 deletion content/docs/automation/approvals.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,9 @@ A flow declares `runAs` (ADR-0049), and for approvals this is the decision that
- `runAs: 'user'` (default) — the flow's data operations run as the **submitter**, respecting their RLS. Good when the flow only touches records the submitter can already see.
- `runAs: 'system'` — **elevated**, bypasses RLS. Needed when the flow must read/write records the submitter can't (e.g. post to a ledger, notify an approver who owns rows the submitter can't see). Declare it **explicitly** so the elevation is visible, not accidental.

A schedule-triggered escalation has no triggering user, so under the default `runAs: 'user'` its data operations run **unscoped** (elevated, RLS-bypassing) anyway — declare `system` to make that elevation explicit and intended rather than an implicit fail-open (the engine warns, and `os lint` flags the bare shape as `flow-schedule-runas-unscoped`).
A run that resolves **no** triggering user has nothing to scope to, so under the default `runAs: 'user'` its data operations are **refused** — declare `system` to make the elevation explicit and intended. `os lint` rejects the shapes it can prove at authoring time (`flow-runas-unscoped`, covering schedule / time-relative / api triggers), and the engine warns at run setup before refusing.

This is **not** a schedule-only concern, and for approvals it is the common case: the approvals service mirrors a decision back onto the target record with a **system** write, which carries no user. Any record-change flow bound to that object then runs with no triggering user — so a flow left at the default `runAs: 'user'` is refused. Nothing can flag that at authoring time (whether a given write carries a user is only knowable at run time), so declare `runAs: 'system'` on record-change flows that react to approval outcomes.

### 3. The approval node

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/api/automation-api.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,7 +108,7 @@ const result = AutomationApiErrorCode.parse(data);
| **nodes** | `{ id: string; type: string; label: string; config?: Record<string, any>; … }[]` | ✅ | Flow nodes |
| **edges** | `{ id: string; source: string; target: string; condition?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; … }[]` | ✅ | Flow connections |
| **active** | `boolean` | optional | Is active (Deprecated: use status) |
| **runAs** | `Enum<'system' \| 'user'>` | optional | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A schedule-triggered run has no trigger user, so under user it runs UNSCOPED (elevated) — declare system to make that explicit. |
| **runAs** | `Enum<'system' \| 'user'>` | optional | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. |
| **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; retryDelayMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/automation/flow.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,7 @@ const result = Flow.parse(data);
| **nodes** | `{ id: string; type: string; label: string; config?: Record<string, any>; … }[]` | ✅ | Flow nodes |
| **edges** | `{ id: string; source: string; target: string; condition?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; … }[]` | ✅ | Flow connections |
| **active** | `boolean` | optional | Is active (Deprecated: use status) |
| **runAs** | `Enum<'system' \| 'user'>` | optional | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A schedule-triggered run has no trigger user, so under user it runs UNSCOPED (elevated) — declare system to make that explicit. |
| **runAs** | `Enum<'system' \| 'user'>` | optional | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. |
| **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; retryDelayMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
Expand Down
24 changes: 20 additions & 4 deletions docs/adr/0073-automation-execution-identity.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,22 @@

---

## Amendment: user-less data ops are now REFUSED (2026-07-28, #3760)

Two load-bearing claims below turned out to be wrong. Both were about *severity*, not about the model — D1–D5 stand unchanged, and D5 shipped in full.

**1. "There is no untrusted-input path to the fail-open" — false.** The Severity section argues the risk is acute-mitigated because schedules are admin/AI-authored and "an unprivileged user cannot trigger a schedule". That is true of schedules and irrelevant to the actual exposure. The fail-open predicate is `runAs !== 'system' && !userId` — *any* run that resolves no user — while the lint only ever covered the schedule shape. The dominant real-world shape is a **record-change flow fired by a write that carried no user**: `isSystem` does **not** suppress trigger dispatch (only `skipTriggers` does, and exactly three first-party paths set it), so every plugin/service system write, the approvals status mirror, and a `runAs:'system'` flow's own data node all dispatch record-change flows with `userId: undefined`. Ordinary users reach those writes routinely — submitting for approval mirrors a status onto the target record. So the fail-open was reachable by unprivileged input, and was the common case rather than the rare one.

**2. "Fail-closed … Rejected in #2308: breaks legitimate scheduled CRUD (2/3 example flows relied on the default)" — expired.** Those example flows were fixed as part of #2308 itself; every first-party schedule-triggered flow now declares `runAs: 'system'`. The stated cost of fail-closed no longer exists.

**Consequently `runAs:'user'` + no trigger user now REFUSES the data operation** (`UnscopedRunDataAccessError`, thrown from `resolveRunDataContext` — the single place every data node resolves its context). This implements D5's ruling ("user-less `runAs:'user'` is a configuration error") at the only layer that can see the record-change case, since whether a triggering write carries a user is not knowable at authoring time.

Note what this deliberately is **not**: it does not re-badge these runs as `isSystem`. The security middleware's `isSystem` short-circuit precedes its package-managed-row / system-row / audience-anchor / delegated-admin gates, all of which a principal-less context still has to clear — so "unscoped" was never equivalent to "system", and elevating would have *widened* these runs (e.g. letting them write `sys_user_position`) rather than preserving the status quo.

This is an **interim** posture, not a replacement for D2. Refusal is strictly safer than today and forward-compatible: when the `automation` principal lands, the refusal point becomes the place that resolves it, and these runs go from *refused* to *RLS-enforced* with no third state. Ordering is unchanged — D2 still waits for a real consumer.

---

## TL;DR

1. **[model] Automation is a first-class non-human identity, expressed as a built-in role** (the ADR-0068 idiom): the **environment's `automation` principal** — a Data-Plane identity living in that environment's own kernel/DB. A user-less run resolves to an `EvalUser` whose `id` is the env's stable automation principal and whose `roles` carry the `automation` role. There is **no anonymous run**. (Cross-environment, platform-wide automation is a **Control-Plane** concern — ADR-0002/0004 — out of scope; see D4.)
Expand DownExpand Up@@ -52,7 +68,7 @@ Two findings sharpen the problem:

This is a **footgun / hardening** issue, not an actively exploited hole, and the acute risk is **already mitigated**:

- Scheduled flows are **admin/AI-authored metadata**; an unprivileged user **cannot trigger a schedule**, so there is no untrusted-input path to the fail-open.
- ~~Scheduled flows are **admin/AI-authored metadata**; an unprivileged user **cannot trigger a schedule**, so there is no untrusted-input path to the fail-open.~~ **Falsified (#3760)** — see the Amendment. True of schedules, but schedules were never the boundary: a record-change flow fired by a user-less system write hits the identical fail-open, and unprivileged users reach those writes routinely.
- **#2308 already shipped** the cheap mitigations: a build-time lint, a runtime warning, and fixing the example flows to explicit `runAs:'system'`. The bleeding is stopped.
- Tenant isolation is **physical — environment-per-database** (ADR-0002): each tenant environment is its own kernel + DB. So the hard problem (cross-tenant RLS for an automation principal) **does not exist in this architecture** — the automation principal is a purely *intra-environment* Data-Plane identity with no cross-tenant data reach to scope. (The platform is also pre-launch / single-operator.)
- The live automation surface is **tiny**, and — decisively — the existing scheduled flows (`stale_opportunity_sweep`, the app-todo sweeps) all want **full `system` elevation**, not the RLS-respecting middle. **The `automation` mode this ADR introduces has zero consumers in the current app set.**
Expand DownExpand Up@@ -128,7 +144,7 @@ A scheduled / unauthenticated-webhook trigger has no user; `runAs:'user'` there

**v1 — land now (no runtime machinery):**
1. **This decision record** — pins the model (D1–D4) + `runAs` posture semantics, so the AI authors flows against the right target and M2 has a contract. (Cheapest to land pre-scale, exactly the ADR-0068 v1 argument.)
2. **Author-time guardrail (D5)** — extend the #2308 `flow-schedule-runas-unscoped` lint to every user-less trigger type (api/webhook/queue), and make user-less `runAs:'user'` a **validation error** at compile. Small, non-breaking, and the real present value: it stops the AI from generating the wrong pattern before there is a large body of it.
2. **Author-time guardrail (D5)** — extend the #2308 `flow-runas-unscoped` lint to every user-less trigger type (api/webhook/queue), and make user-less `runAs:'user'` a **validation error** at compile. Small, non-breaking, and the real present value: it stops the AI from generating the wrong pattern before there is a large body of it.

Runtime behavior is otherwise **unchanged** from #2308 (the audible warning stays). **We do not seed the roles, mint the principal, or touch `runAs` resolution in v1** — there is no consumer, so doing so would be inert/speculative (unlike ADR-0068 v1, whose seeded roles had a live `current_user` consumer).

Expand DownExpand Up@@ -163,14 +179,14 @@ Runtime behavior is otherwise **unchanged** from #2308 (the audible warning stay
- **Build the whole model now (seed roles + principal + runtime).** Rejected: zero current consumer, single-operator, acute risk already mitigated → the speculative over-build ADR-0049 warns against.
- **Keep NULL-then-claim for automation.** Rejected: no claim event for perpetual automation, so attribution never converges; does nothing for authorization.
- **Stop at the #2308 runtime warning.** Necessary but insufficient as the *end-state*: makes the fail-open audible without eliminating it, and leaves writes unattributed — hence this ADR fixes the *model* even though the *build* waits.
- **Fail-closed (deny user-less data ops).** Rejected in #2308: breaks legitimate scheduled CRUD (2/3 example flows relied on the default) and gives no attribution.
- **Fail-closed (deny user-less data ops).** ~~Rejected in #2308: breaks legitimate scheduled CRUD (2/3 example flows relied on the default) and gives no attribution.~~ **ADOPTED 2026-07-28 (#3760)** — see the Amendment above. The example flows this protected now declare `runAs:'system'`, so the stated cost expired; and the attribution objection does not apply, since refusing an operation attributes nothing either way (attribution remains D3's job, unchanged).
- **Reuse `runAs:'system'` for scheduled (silent elevation).** Rejected: hides author intent; the ambient god-mode the four invariants warn against.

## Conformance checklist

**v1 (now):**
1. **`@objectstack/spec`** — document the automation identity as an `EvalUser` (D1) and the three-posture `runAs` semantics (D2) in `FlowSchema.runAs` describe, **marked target-state** for `automation`.
2. **`@objectstack/cli`** — extend `flow-schedule-runas-unscoped` to all user-less trigger types; make user-less `runAs:'user'` a hard validation error (D5).
2. **`@objectstack/cli`** — extend `flow-runas-unscoped` to all user-less trigger types; make user-less `runAs:'user'` a hard validation error (D5).

**M2 (gated on first consumer):**
3. **`plugin-security`** — seed the per-environment `automation` `sys_role` row (sibling to `bootstrap-declared-roles`); extend the non-human exclusion guards.
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,7 +89,7 @@ The genuine overlap is `after-*` side effects (write succeeded → notify / audi

### 4. Loud-not-silent — the two new `os build` lints (the only code in v1)

Authored alongside the existing flow lints in `packages/cli/src/utils/lint-flow-patterns.ts` (which already ships `flow-schedule-runas-unscoped`, `flow-double-brace-interpolation`, etc.):
Authored alongside the existing flow lints in `packages/cli/src/utils/lint-flow-patterns.ts` (which already ships `flow-runas-unscoped`, `flow-double-brace-interpolation`, etc.):

- **`flow-record-before-cannot-mutate`** — *error*. A flow bound to `record-before-*` that contains a `create_record`/`update_record` node targeting the **triggering object/record**, or otherwise reads as expecting to change the in-flight record. Message points to **hook** (rewrite) or **validation rule** (veto).
- **`flow-record-before-cannot-veto`** — *error*. A `record-before-*` flow whose shape implies it intends to stop the write (e.g. a decision branch ending in an error/`end` node presented as rejection). Message: *"record-change flows cannot abort the triggering write — its errors are isolated by design; use a validation rule to reject, or a hook to throw."*
Expand Down
Loading
Loading