From 5a9bf414cf27e307083bde55bd0f66884398d2a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 00:20:07 +0000 Subject: [PATCH] docs(spec): the runAs description names the system run's refused identity-assignment writes, with its ADR-0087 D3 entry The flow runAs describe and its source comment say that a system run's governed writes to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED, and name the remedies. A protocol-18 semantic entry records the change of meaning. Derived reference pages regenerated. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013yig39ZouqWBxABRfM5FN4 --- ...25554-runas-identity-assignment-wording.md | 17 +++++++ .../docs/references/api/automation-api.mdx | 10 ++-- content/docs/references/automation/flow.mdx | 4 +- packages/spec/src/automation/flow.zod.ts | 18 +++++++ ...ystem-identity-assignment-write-refused.ts | 50 +++++++++++++++++++ 5 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 .changeset/25554-runas-identity-assignment-wording.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.flow-run-as-system-identity-assignment-write-refused.ts diff --git a/.changeset/25554-runas-identity-assignment-wording.md b/.changeset/25554-runas-identity-assignment-wording.md new file mode 100644 index 00000000000..f3b5e01c8bb --- /dev/null +++ b/.changeset/25554-runas-identity-assignment-wording.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': minor +--- + +docs(spec): the flow `runAs` description says a `system` run's writes to the four identity-assignment objects are refused, and the ADR-0087 D3 entry records that change of meaning + +Clause-②: no (describe text and ADR registration only; no accept-set change) +Metadata-protocol: semantic (records the change of meaning #25483 landed) + + + +`@objectstack/plugin-security` now refuses a `runAs: 'system'` flow's governed writes (insert, update, delete, transfer, restore, purge) to `sys_user_position`, `sys_user_permission_set`, `sys_invitation` and `sys_member` with `PERMISSION_DENIED`: the data middleware refuses them from a context that carries `isSystem` and a `flowRunId`, ahead of its system bypass. The published `FlowSchema.runAs` description still said a system flow keeps system privileges without exception. It now names the refusal and its remedies. + +- **`runAs` description.** One sentence: a system run's governed writes to the four objects are refused with `PERMISSION_DENIED`. Declare `user` for position holdings and permission-set grants, so the ADR-0090 D12 delegated-administration gate judges the user the run acts for. Change invitations and memberships through the organization endpoints. The source comment above the key says the same and adds that a platform writer whose system context carries no flow run is unchanged. +- **ADR-0087 D3 entry** `flow-run-as-system-identity-assignment-write-refused` in protocol 18's semantic list, so `os migrate meta` and the upgrade guide list the change for an author upgrading a flow that wrote those rows. It carries no conversion: changing `runAs` changes what every other node of the flow may do, and moving an invitation or membership write to an endpoint is a new call, so no rewrite can make the choice for the author. + +No key, default or accepted value changes, and every flow that parsed before parses the same way. diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 30585366155..0975406e959 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -120,7 +120,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **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). | @@ -244,7 +244,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **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). | @@ -772,7 +772,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **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). | @@ -1082,7 +1082,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **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). | @@ -1147,7 +1147,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **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). | diff --git a/content/docs/references/automation/flow.mdx b/content/docs/references/automation/flow.mdx index bdc45c63ecb..7a28aafd6ff 100644 --- a/content/docs/references/automation/flow.mdx +++ b/content/docs/references/automation/flow.mdx @@ -41,7 +41,7 @@ const result = FlowSchema.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **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). | @@ -262,7 +262,7 @@ const result = FlowSchema.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. 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. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **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). | diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 0b4978714ab..17e0ba62006 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -1142,6 +1142,19 @@ export const FlowSchema = lazySchema(() => strictObject( // `ctx.user` in the hooks those writes fire, name the decider too. A flow // with no human decision node runs as its trigger user throughout. // + // [#25251, ruling 6354966423 letter B] …except identity assignment. The data + // middleware refuses, ahead of its `isSystem` bypass, a governed write + // (insert, update, delete, transfer, restore, purge) to `sys_user_position`, + // `sys_user_permission_set`, `sys_invitation` or `sys_member` from a context + // that carries `isSystem` AND a `flowRunId`, which is the context a `system` + // run's data nodes write under. The answer is `PERMISSION_DENIED` (403) and + // nothing is written. Reads of the four, and writes to every other object, + // are unchanged. The remedy for position holdings and permission-set grants + // is `runAs: 'user'`, so the ADR-0090 D12 delegated-administration gate + // judges the user the run acts for; invitations and memberships change + // through the organization endpoints. A platform writer whose system context + // carries no flow run is unchanged. + // // A run under `user` that resolves NO trigger user has nothing to scope to, so // its data operations are REFUSED (#3760). This is NOT a schedule-only case — // it is any run whose trigger supplied no user, and the commonest by far is a @@ -1166,6 +1179,11 @@ export const FlowSchema = lazySchema(() => strictObject( 'this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes ' + "after an approval decision run with the decider's identity and permissions, so a node the decider may " + 'not perform is refused). ' + + "Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, " + + 'transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member ' + + "are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the " + + 'ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations ' + + 'and memberships through the organization endpoints. ' + '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. Except behind an anonymous door: publish refuses ' + diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-run-as-system-identity-assignment-write-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-run-as-system-identity-assignment-write-refused.ts new file mode 100644 index 00000000000..819e19cc021 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.flow-run-as-system-identity-assignment-write-refused.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #25251, ruling 6354966423 letter B — the D3 entry for the change of meaning +// the run-time arm landed in plugin-security: a `runAs: 'system'` run's governed +// writes to the four identity-assignment objects are refused. No key of any +// schema is added, removed, renamed or re-shaped (`runAs` keeps its two values +// and every flow still saves), so there is no tombstone and no D2 conversion: +// the move is an authoring choice no rewrite can make for the author. +export const entry: SemanticMigration = { + id: 'flow-run-as-system-identity-assignment-write-refused', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span and a table cell. + surface: + 'a flow declaring runAs system whose data nodes, or the writes they set off under the same context, ' + + 'insert, update, delete, transfer, restore or purge a row of sys_user_position, ' + + 'sys_user_permission_set, sys_invitation or sys_member', + replacement: + 'For position holdings and permission-set grants, set the flow\'s `runAs` to `\'user\'`: the ADR-0090 ' + + 'D12 delegated-administration gate then judges the user the run acts for, admits a tenant-level or ' + + 'delegated administrator\'s grant, and records that user as the grant\'s `granted_by`. For invitations ' + + 'and memberships, call the organization endpoints (`POST /api/v1/auth/organization/invite-member`, ' + + '`cancel-invitation`, `update-member-role`, `remove-member`), which apply the invitation\'s placement ' + + 'and role-cap checks.', + reason: + 'The data middleware returned early for every `isSystem` context, ahead of its gates, and a ' + + '`runAs: \'system\'` run\'s data nodes write under `isSystem` plus the run\'s `flowRunId`. So such a ' + + 'flow wrote position holdings and permission-set grants that the D12 gate never judged and that ' + + 'carried no `granted_by`, and invitation and membership rows that never passed the organization ' + + 'endpoints\' checks. The middleware now refuses, before that early return, an insert, update, delete, ' + + 'transfer, restore or purge of a row of the four objects from a context carrying both `isSystem` and ' + + 'a `flowRunId`, with `PERMISSION_DENIED` (403); nothing is written, the write node fails, and the ' + + 'run\'s result carries the refusal. This holds at any depth of the flow\'s own write, a cascade leg ' + + 'or a hook\'s write under the same context included. Unchanged: reads of the four objects, a system ' + + 'run\'s writes to every other object, a `runAs: \'user\'` run (it carries a `flowRunId` without ' + + '`isSystem` and meets the D12 gate as its user), and every platform writer whose system context ' + + 'carries no flow run (the seeders, better-auth and its reconciliation, invitation placement, the ' + + 'organization-administrator grant). ⛔ NOT convertible: flipping `runAs` changes what every other ' + + 'node of the flow may do, and moving an invitation or membership write to an endpoint is a new ' + + 'call, not a rewrite of the node. A census at the change found no flow in this repository or in ' + + 'hotcrm aimed at the four objects.', + acceptanceCriteria: + 'No flow that declares `runAs: \'system\'` has a create, update, delete or upsert record node, or a ' + + 'hook its writes fire, that writes `sys_user_position`, `sys_user_permission_set`, `sys_invitation` ' + + 'or `sys_member`. Each formerly affected flow either runs as `\'user\'` and, triggered by an ' + + 'administrator, completes with the grant recorded under that user\'s `granted_by`, or no longer ' + + 'writes the row and the invitation or membership change goes through the organization endpoint. ' + + 'Running each such flow ends without a `PERMISSION_DENIED` refusal naming one of the four objects.', +};