ADR-0073: Automation execution identity — a built-in non-human principal for user-less runs, runAs as authorization posture, attribution always concrete
Status: Proposed (2026-06-25)
Deciders: ObjectStack Protocol Architects
Builds on: ADR-0002 (environment-per-database isolation; Control-Plane / Data-Plane split), ADR-0004 (ObjectKernel per environment), ADR-0049 (enforce-or-mark gate; stage by whether the feature exists), ADR-0057 (sys_role is platform-native; ExecutionContext.roles; scheduled/lifecycle jobs), ADR-0066 (capability / assignment / requirement separation — resources declare a capability, never "who"), ADR-0068 (EvalUser / current_user; identities are roles, not booleans)
Consumers: @objectstack/spec (the identity contract + runAs semantics), @objectstack/cli (author-time validation), @objectstack/plugin-security (resolve-execution-context, RLS, seeded roles — M2), @objectstack/service-automation (the engine's runAs resolution — M2), @objectstack/trigger-schedule + @objectstack/trigger-api (user-less trigger surfaces), @objectstack/plugin-reports (the report scheduler — today hand-rolls SYSTEM_CTX), @objectstack/runtime (audit actor), and the ADR-0057 lifecycle/retention jobs.
Premise: pre-launch — specify the target end-state, then land only the non-speculative slice now (ADR-0049). This ADR completes the identity model: ADR-0068 unified the human identity surface (current_user, identities-as-roles); this ADR adds the non-human / automation identity that user-less runs need, in the same idiom. It is deliberately mostly a decision record: the acute risk is already mitigated (see Severity), so v1 builds almost nothing — it pins semantics before the AI authors a large body of metadata against the wrong model, and ships one author-time guardrail.
Trigger: #1888 enforced flow
runAs, and the follow-up #2308 surfaced that a schedule-triggered flow with the defaultrunAs:'user'has no trigger user → the data-layer security middleware skips (it skips on no-identity by design, delegating auth to the auth layer) → the run executes UNSCOPED (effectively elevated). #2308 made that fail-open audible (a build-time lint + a runtime warning, and fixed the example flows) but deliberately did not change the runtime identity — because the right fix is a platform-identity decision, recorded here.
- [model] Automation is a first-class non-human identity, expressed as a built-in role (the ADR-0068 idiom): the environment's
automationprincipal — a Data-Plane identity living in that environment's own kernel/DB. A user-less run resolves to anEvalUserwhoseidis the env's stable automation principal and whoserolescarry theautomationrole. There is no anonymous run. (Cross-environment, platform-wide automation is a Control-Plane concern — ADR-0002/0004 — out of scope; see D4.) - [model]
runAsdeclares authorization posture, not identity — decoupling the two axes the platform conflates today:user— run as the triggering human (only valid when one exists);automation(the target default for user-less triggers) — run as the automation principal with RLS enforced against its grants (Salesforce "with sharing"; the safe middle);system— full elevation (isSystem, RLS-bypassing) — explicit opt-in, back-compat unchanged, always audited.
- [model] Attribution is always concrete — every run carries an identity, so the audit actor is the human or the environment's automation principal. No more
created_by = NULLautomation writes. Attribution is recorded at the audit layer and is decoupled from record ownership (automation must not silently own the rows it writes, or owner-RLS would hide them from humans). - [ruled] User-less +
runAs:'user'is a configuration error (no user to scope to). Mainstream platforms do not offer "as the triggering user" for scheduled work; neither do we. - [staging — the key call] Build almost nothing now. v1 = this decision record + the author-time guardrail (extend the #2308 lint to every user-less trigger; turn user-less
runAs:'user'into a validation error). Everything runtime — seeding the roles, the principal, attribution wiring, theautomationdefault — is M2, gated on the first real consumer, not a date. Rationale below.
"What identity does a run execute under when there is no human?" is the classic confused-deputy problem. Mature platforms keep two axes separate:
- Authorization — what may the run read/write? (RLS / permission context)
- Attribution — who is recorded as having done it? (audit identity)
ObjectStack collapses both into "is context.userId present?", which is why the user-less case both (a) loses authorization (skips → unscoped) and (b) loses attribution (created_by = NULL).
| Run | Authorization | Attribution |
|---|---|---|
runAs:'user' (human present) |
trigger user + full RLS | created_by = userId |
runAs:'system' |
{isSystem:true} → bypasses everything (object perms + RLS) |
created_by = NULL (orphan) |
user-less + user (schedule/webhook) |
skips → UNSCOPED (the #2308 fail-open) | created_by = NULL (orphan) |
Two findings sharpen the problem:
- Automation creates orphan records.
created_byis stamped fromsession.userId(@objectstack/objectqlinsert hook); asystem/user-less run has nouserId→created_by = NULL. The existence ofclaimSeedOwnership(re-owning NULL rows to the first admin) is the tell. "NULL-then-claim" works for one-time seeds (a single claim event) but fails for perpetual automation — a nightly sweep has no claim event, so unattributed rows accumulate forever. - The standing system user was deliberately removed.
SystemUserId.SYSTEM(usr_system) is vestigial ("NO LONGER AUTO-PROVISIONED",system-names.ts). That removal was about seed ownership ergonomics, not automation — so it does not bar a non-human execution identity, but it warns us not to recreate a seed-ownership crutch.
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.
- #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 fullsystemelevation, not the RLS-respecting middle. Theautomationmode this ADR introduces has zero consumers in the current app set.
Building the runtime machinery now would therefore be the speculative enforcement ADR-0049 explicitly warns against. The real, present value is (a) pinning runAs semantics + the automation-identity model before the AI authors metadata at scale, and (b) the author-time guardrail.
| Platform | Authorization for automation | Attribution |
|---|---|---|
| Salesforce | System Context with author choice: without sharing (god-mode) vs with sharing (elevated object/FLS, record sharing still enforced). Never "as the triggering user". | dedicated "Automated Process" system user. |
| ServiceNow | scheduled jobs/flows have a "Run as" service account; ACLs evaluated against it. | the service account. |
| AWS IAM | per-task execution role — a first-class non-human principal, least-privilege, assumed per-invocation. | the role, in CloudTrail. |
| Kubernetes | every workload (incl. CronJobs) runs under a ServiceAccount; RBAC against it. | the ServiceAccount. |
| iPaaS (Workato/Zapier) | steps run as the connection owner (the configured credential). | the connection. |
| GitHub Actions | scoped GITHUB_TOKEN; default shifted broad → read-only (least-privilege trend). |
the workflow token. |
| Postgres | SECURITY DEFINER = "run as definer" — documented as a privilege-escalation footgun. |
the role. |
Four invariants they converge on: (1) automation always runs as a concrete, named, non-human principal — never "no one", never ambient god-mode; (2) authorization ≠ attribution; (3) safe, explicit defaults, least-privilege as the trend; (4) elevation is explicit and audited. ObjectStack violates (1) and (2) for the user-less case.
This primitive is not flow-specific: plugin-reports already hand-rolls SYSTEM_CTX = {isSystem:true}, audit writes, the ADR-0057 retention/lifecycle jobs, future queue/webhook consumers, and autonomous AI agents all need the same non-human identity. Building it inside the flow engine would be a mistake — it is a platform identity primitive, which is why this ADR exists rather than a one-off flow patch.
D1–D5 describe the end-state. What is built now vs. deferred is in Scope.
Extend ADR-0068's "identities are roles" to the non-human case. A single reserved, managed sys_role row per environment (sibling of platform_admin / org_*), carrying label + description:
| name | scope | meaning |
|---|---|---|
automation |
the environment (its own kernel/DB) | the identity for app-authored scheduled / user-less / system-initiated runs within this environment. |
Because isolation is environment-per-database (ADR-0002, see D4), there is no in-kernel cross-tenant automation role — the DB boundary is the tenant boundary. Cross-environment, platform-wide automation (retention across customers, fleet telemetry) is a Control-Plane actor (ADR-0002/0004) that iterates over environments; it is not an in-kernel sys_role and is out of scope here.
A user-less run resolves to an EvalUser (ADR-0068) whose id is the environment's stable automation principal id and whose roles include the automation role. It appears as current_user like any other identity — so RLS, formulas, and audit treat it uniformly, with zero bespoke booleans (ADR-0068 D2). The principal is non-loginable and excluded from human/admin enumerations (the existing usr_system exclusion guards already model this). This does not resurrect usr_system as a seed-ownership crutch (seeds keep NULL-then-claim); it adds a non-human execution + attribution identity in the modern idiom.
runAs stops meaning "system vs user" (which conflates the two axes) and becomes a declaration of authorization posture, resolved against the run's available identity:
runAs |
authorization | when |
|---|---|---|
user |
the triggering human + full RLS | only valid when a human triggered the run |
automation |
the automation principal, RLS enforced against its grants (object perms + record-level RLS both apply) | the target default for user-less triggers (schedule, unauthenticated webhook, internal execute) |
system |
{isSystem:true} — full elevation, RLS-bypassing |
explicit opt-in; semantics unchanged from today (back-compat) |
automation is the Salesforce-"with sharing" middle that ObjectStack's binary system/user cannot express today: elevated enough to act as the platform, but still subject to row-level security and its own permission grants. (Note: it has no consumer in the current app set — see Severity — so its runtime is M2; the enum value + semantics are reserved now.)
- Every run carries an identity, so the audit actor is always concrete: the human or the environment's
automationprincipal. The anonymous/NULLautomation write is eliminated. - Attribution ≠ ownership. Automation must not be force-stamped as
created_by/owner_idof the rows it writes — owner-RLS keys oncreated_by == current_user.id(ADR-0057/0068), so automation-owned rows would become invisible to the humans they are for. Salesforce models this exactly:CreatedBy = Automated Process(audit) whileOwnerIdis set by flow logic. Therefore: record the automation actor at the audit layer; let flow logic set ownership explicitly (or leave the normal default), not the execution identity.
ObjectStack is environment-per-database (ADR-0002): each tenant environment is its own ObjectKernel + DB (ADR-0004), with a hard Control-Plane / Data-Plane split. This dissolves the multi-tenant scoping question rather than answering it:
- The
automationprincipal is a Data-Plane identity that lives in the environment's own kernel/DB. There is no cross-tenant data access from inside a tenant kernel — the database boundary is the tenant boundary — so no "unscoped cross-tenant" automation role is needed (or wanted). - Its RLS is therefore purely intra-environment: it respects this environment's users' ownership/sharing (
runAs:'automation') or bypasses them (runAs:'system'). Theautomationvssystemdistinction is about the env's own users, never about tenants. - Cross-environment / platform-wide automation (retention across customers, fleet telemetry, migrations) is a Control-Plane concern that iterates over environments — each a separate DB — and acts within each via that env's own principal. It is not a kernel-resident role with cross-tenant reach, and is out of scope for this (Data-Plane) ADR.
A scheduled / unauthenticated-webhook trigger has no user; runAs:'user' there is incoherent. Validation rejects it (extending the #2308 lint into a hard author-time rule for all user-less trigger types). The author picks automation (target default) or system.
v1 — land now (no runtime machinery):
- This decision record — pins the model (D1–D4) +
runAsposture 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.) - Author-time guardrail (D5) — extend the #2308
flow-schedule-runas-unscopedlint to every user-less trigger type (api/webhook/queue), and make user-lessrunAs:'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).
M2 — gated on the FIRST REAL CONSUMER, not a date. Build the principal with its first user, in this order:
- Attribution wiring (non-breaking): user-less runs carry the automation principal as the audit actor (D3). Likely first domino: migrating
plugin-reports/ audit / ADR-0057 lifecycle jobs off ad-hocSYSTEM_CTXonto the principal — that migration is what first makes the principal earn its keep. - Authorization (the behavior change): seed the roles (D1);
runAs:'automation'becomes the user-less default and runs RLS-enforced (D2); assign capabilities/permission-sets to the automation role (ADR-0066). Ship default-broad → tighten (the GitHub-Actions playbook) with each flow's effective grants visible and restrictable.
- Other trigger to start M2: a real volume of scheduled CRUD flows appears (orphan attribution starts to bite). (Multi-tenant is not a trigger — it is already handled physically by env-per-DB, ADR-0002.)
runAs:'system'stays god-mode throughout.
Non-goals / deferred:
- A full IAM-style per-flow custom-role surface (over-engineering pre-MVP; two built-in roles + permission-set assignment suffice).
- Capability-gating of the automation identity (follows ADR-0066 / cloud#474).
- Re-introducing
usr_systemfor seed ownership (explicitly out — seeds keep NULL-then-claim).
Good
- Pins
runAssemantics + the automation-identity model before the AI authors metadata at scale — the cheap, high-leverage move while the surface is small. - The v1 guardrail extends the #2308 protection to all user-less triggers and closes the incoherent
runAs:'user'-on-schedule at author time. - The end-state closes the fail-open at its root (RLS-enforced named principal) and ends anonymous automation writes (audit/compliance) — when a consumer makes it worth building.
- One identity model (automation is just another
current_userwithroles[], ADR-0068) → no new concept for RLS/formula/audit/AI-authoring; and a reusable principal that replaces scatteredSYSTEM_CTXhacks. runAs:'system'unchanged → no migration break.
Bad / costs
- A third
runAsvalue enlarges the author surface (justified: it is the safe default and the most-requested real mode) — but reserved-now/built-later means the enum is documented before it is enforced, a small "spec says more than runtime does" window (acceptable per ADR-0049 because it is marked target-state, not silently unenforced). - The M2 authorization flip is a behavior change for user-less flows that today run unscoped — mitigated by default-broad-then-tighten and the long advance notice (the #2308 warning + v1 lint).
- 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.
- Reuse
runAs:'system'for scheduled (silent elevation). Rejected: hides author intent; the ambient god-mode the four invariants warn against.
v1 (now):
@objectstack/spec— document the automation identity as anEvalUser(D1) and the three-posturerunAssemantics (D2) inFlowSchema.runAsdescribe, marked target-state forautomation.@objectstack/cli— extendflow-schedule-runas-unscopedto all user-less trigger types; make user-lessrunAs:'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.
4. service-automation — resolveRunContext resolves user-less runs to the automation EvalUser; runAs:'automation' threads an RLS-enforcing context (not isSystem); stamp the audit actor (D3).
5. runtime / audit — record the automation actor; do not stamp created_by/owner_id to it (D3).
6. plugin-reports, ADR-0057 jobs — adopt the principal in place of ad-hoc SYSTEM_CTX (the likely first consumer that triggers M2).
- #1888 (flow
runAsenforcement), #2308 (schedule/user-less fail-open made audible — this ADR's trigger). - ADR-0049 (enforce-or-mark staging), ADR-0057 (sys_role / scope / lifecycle jobs), ADR-0066 (capabilities), ADR-0068 (EvalUser / identities-as-roles).