From 6215705169dc04f056f19113299ef40473d113cc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 00:42:58 +0000 Subject: [PATCH 1/2] fix(service-automation, objectql): runAs refusal and run-setup warning texts hold on both kernels The UnscopedRunDataAccessError message, the run-setup [runAs] warning and the HookUnscopedDataAccessError message said a user-less runAs:'user' data operation "would execute UNSCOPED (elevated, RLS-bypassing)". Since ADR-0096 D5 a kernel with plugin-security refuses an operation that carries no principal, so that counterfactual held only where no security plugin is composed. Each text now says: refused by the security plugin where one is composed, unscoped where none is. The remedy, codes, class and order are unchanged; only string text moves. The three pins that matched the old word move to the new clause. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .changeset/22362-unscoped-run-strings.md | 15 +++++++++++++++ packages/objectql/src/hook-run-as.test.ts | 2 +- packages/objectql/src/hook-run-as.ts | 6 ++++-- .../src/builtin/crud-runas.test.ts | 2 +- .../services/service-automation/src/engine.ts | 7 ++++--- .../service-automation/src/runtime-identity.ts | 6 ++++-- .../src/schedule-runas-e2e.test.ts | 2 +- 7 files changed, 30 insertions(+), 10 deletions(-) create mode 100644 .changeset/22362-unscoped-run-strings.md diff --git a/.changeset/22362-unscoped-run-strings.md b/.changeset/22362-unscoped-run-strings.md new file mode 100644 index 00000000000..63c76e179f4 --- /dev/null +++ b/.changeset/22362-unscoped-run-strings.md @@ -0,0 +1,15 @@ +--- +'@objectstack/service-automation': patch +'@objectstack/objectql': patch +--- + +fix(service-automation, objectql): the `runAs: 'user'` refusals and the run-setup warning say what a principal-less operation meets on both kernels + +Clause-②: no + +Three author-facing texts told a flow or hook author that a `runAs: 'user'` data operation with no trigger user "would execute UNSCOPED (elevated, RLS-bypassing)" if it were not refused. That holds only on a kernel with no security plugin. Since ADR-0096 D5, a kernel with `@objectstack/plugin-security` refuses an operation that carries no principal (`403 PERMISSION_DENIED`). Each text now says both: without a user the operation would carry no principal, refused by the security plugin where one is composed, unscoped where none is. + +- `@objectstack/service-automation`: the `UnscopedRunDataAccessError` message, and the `[runAs]` warning logged at run setup for a flow whose trigger resolved no user. +- `@objectstack/objectql`: the `HookUnscopedDataAccessError` message. + +Only the wording changes. The refusals, their order, their codes (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`, `HOOK_UNSCOPED_DATA_ACCESS` with its `403` status) and their remedy are unchanged: declare `runAs: 'system'` to make the elevation explicit and intended, or arrange for the trigger to supply a user. diff --git a/packages/objectql/src/hook-run-as.test.ts b/packages/objectql/src/hook-run-as.test.ts index 18094af7cca..3fc784ae8e4 100644 --- a/packages/objectql/src/hook-run-as.test.ts +++ b/packages/objectql/src/hook-run-as.test.ts @@ -207,7 +207,7 @@ describe('#14010 Hook.runAs — the identity a hook\'s ctx.api presents', () => expect(thrown.hook).toBe('stamp_grade'); // The remedy the author can act on, and the reason. expect(thrown.message).toContain("runAs: 'system'"); - expect(thrown.message).toContain('UNSCOPED'); + expect(thrown.message).toContain('refused by the security plugin where one is composed, unscoped where none is'); // The decisive half: refusing means the operation did NOT run unscoped. expect(engine.seen).toEqual([]); }); diff --git a/packages/objectql/src/hook-run-as.ts b/packages/objectql/src/hook-run-as.ts index 315739a3507..b486090a13b 100644 --- a/packages/objectql/src/hook-run-as.ts +++ b/packages/objectql/src/hook-run-as.ts @@ -117,8 +117,10 @@ export class HookUnscopedDataAccessError extends Error { .join(', '); super( `[runAs] refusing a data operation (${where}): this hook's runAs is 'user' but no trigger user ` + - `could be resolved, so the operation would execute UNSCOPED (elevated, RLS-bypassing) rather ` + - `than restricted to a user. Declare \`runAs: 'system'\` on the hook to make the elevation ` + + `could be resolved, so the operation cannot be restricted to a user. ` + + `Without one it would carry no principal: ` + + `refused by the security plugin where one is composed, unscoped where none is. ` + + `Declare \`runAs: 'system'\` on the hook to make the elevation ` + `explicit and intended, or arrange for the trigger to supply a user (a write made with a system ` + `context carries none). Branch on \`code === '${HOOK_UNSCOPED_DATA_ACCESS_CODE}'\` (ADR-0112) ` + `to detect this. (ADR-0049)`, diff --git a/packages/services/service-automation/src/builtin/crud-runas.test.ts b/packages/services/service-automation/src/builtin/crud-runas.test.ts index 073b2d4fc2d..f5f3e321cb0 100644 --- a/packages/services/service-automation/src/builtin/crud-runas.test.ts +++ b/packages/services/service-automation/src/builtin/crud-runas.test.ts @@ -235,7 +235,7 @@ describe('resolveRunDataContext (#1888 unit)', () => { expect(err).toBeInstanceOf(UnscopedRunDataAccessError); expect((err as UnscopedRunDataAccessError & { code: string }).code).toBe('AUTOMATION_UNSCOPED_RUN_DATA_ACCESS'); expect(err!.message).toMatch(/runAs: 'system'/); - expect(err!.message).toMatch(/UNSCOPED/); + expect(err!.message).toMatch(/refused by the security plugin where one is composed, unscoped where none is/); // Names WHERE, so a refusal in a busy log is traceable to a flow + record. expect(err!.message).toContain("object 'invoice'"); expect(err!.message).toContain("run 'run_1'"); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 864cbaf51b0..e6cc9c25e25 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -6132,9 +6132,10 @@ export class AutomationEngine implements IAutomationService { if (runIsUnscopedUserMode(runContext) && flowTouchesData(flow)) { this.logger.warn( `[runAs] flow '${flow.name}' executes with runAs:'user' but its trigger resolved no user ` + - `— its data operations will be REFUSED. Running them would execute UNSCOPED ` + - `(elevated, RLS-bypassing) rather than restricted, which is the fail-open ADR-0049 ` + - `forbids. Declare runAs:'system' to make the elevation explicit and intended, or arrange ` + + `— its data operations will be REFUSED. Without a user they would carry no principal: ` + + `refused by the security plugin where one is composed, unscoped where none is ` + + `(the fail-open ADR-0049 forbids). ` + + `Declare runAs:'system' to make the elevation explicit and intended, or arrange ` + `for the trigger to supply a user. Note a user-less trigger is NOT only a schedule: a ` + `record-change flow fired by a system write carries no user either (ADR-0049).`, ); diff --git a/packages/services/service-automation/src/runtime-identity.ts b/packages/services/service-automation/src/runtime-identity.ts index 19e1dde08e8..0f0b7e546c2 100644 --- a/packages/services/service-automation/src/runtime-identity.ts +++ b/packages/services/service-automation/src/runtime-identity.ts @@ -86,8 +86,10 @@ export class UnscopedRunDataAccessError extends Error { .join(', '); super( `[runAs] refusing a data operation${where ? ` (${where})` : ''}: this run's effective runAs is ` + - `'user' but no trigger user could be resolved, so the operation would execute UNSCOPED ` + - `(elevated, RLS-bypassing) rather than restricted to a user. Declare \`runAs: 'system'\` on the ` + + `'user' but no trigger user could be resolved, so the operation cannot be restricted to a user. ` + + `Without one it would carry no principal: ` + + `refused by the security plugin where one is composed, unscoped where none is. ` + + `Declare \`runAs: 'system'\` on the ` + `flow to make the elevation explicit and intended, or arrange for the trigger to supply a user ` + `(a write made with a system context carries none). (ADR-0049)`, ); diff --git a/packages/triggers/trigger-schedule/src/schedule-runas-e2e.test.ts b/packages/triggers/trigger-schedule/src/schedule-runas-e2e.test.ts index a8e3513169b..311d9d20fde 100644 --- a/packages/triggers/trigger-schedule/src/schedule-runas-e2e.test.ts +++ b/packages/triggers/trigger-schedule/src/schedule-runas-e2e.test.ts @@ -119,7 +119,7 @@ describe('schedule trigger -> engine: user-less runAs fail-open via the REAL cro const w = runAsWarns(warns); expect(w).toHaveLength(1); expect(w[0]).toContain("flow 'nightly_sweep'"); - expect(w[0]).toMatch(/UNSCOPED/); + expect(w[0]).toMatch(/refused by the security plugin where one is composed, unscoped where none is/); expect(w[0]).toMatch(/runAs:'system'/); }); From 7b2cdf7255afc265405b5939a54541eae67eba12 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 00:44:09 +0000 Subject: [PATCH 2/2] fix(service-automation, objectql): keep each message's literal-piece count unchanged The reworded texts now span the same number of concatenated template pieces and source lines as before, so a projection with comments and literal content blanked is byte-identical to the base for every changed file. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- packages/objectql/src/hook-run-as.ts | 6 ++---- packages/services/service-automation/src/engine.ts | 3 +-- .../services/service-automation/src/runtime-identity.ts | 6 ++---- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/objectql/src/hook-run-as.ts b/packages/objectql/src/hook-run-as.ts index b486090a13b..26d796cde2d 100644 --- a/packages/objectql/src/hook-run-as.ts +++ b/packages/objectql/src/hook-run-as.ts @@ -117,10 +117,8 @@ export class HookUnscopedDataAccessError extends Error { .join(', '); super( `[runAs] refusing a data operation (${where}): this hook's runAs is 'user' but no trigger user ` + - `could be resolved, so the operation cannot be restricted to a user. ` + - `Without one it would carry no principal: ` + - `refused by the security plugin where one is composed, unscoped where none is. ` + - `Declare \`runAs: 'system'\` on the hook to make the elevation ` + + `could be resolved, so the operation cannot be restricted to a user. Without one it would carry no principal: ` + + `refused by the security plugin where one is composed, unscoped where none is. Declare \`runAs: 'system'\` on the hook to make the elevation ` + `explicit and intended, or arrange for the trigger to supply a user (a write made with a system ` + `context carries none). Branch on \`code === '${HOOK_UNSCOPED_DATA_ACCESS_CODE}'\` (ADR-0112) ` + `to detect this. (ADR-0049)`, diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index e6cc9c25e25..902f16ba587 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -6133,8 +6133,7 @@ export class AutomationEngine implements IAutomationService { this.logger.warn( `[runAs] flow '${flow.name}' executes with runAs:'user' but its trigger resolved no user ` + `— its data operations will be REFUSED. Without a user they would carry no principal: ` + - `refused by the security plugin where one is composed, unscoped where none is ` + - `(the fail-open ADR-0049 forbids). ` + + `refused by the security plugin where one is composed, unscoped where none is (the fail-open ADR-0049 forbids). ` + `Declare runAs:'system' to make the elevation explicit and intended, or arrange ` + `for the trigger to supply a user. Note a user-less trigger is NOT only a schedule: a ` + `record-change flow fired by a system write carries no user either (ADR-0049).`, diff --git a/packages/services/service-automation/src/runtime-identity.ts b/packages/services/service-automation/src/runtime-identity.ts index 0f0b7e546c2..613932cfc20 100644 --- a/packages/services/service-automation/src/runtime-identity.ts +++ b/packages/services/service-automation/src/runtime-identity.ts @@ -86,10 +86,8 @@ export class UnscopedRunDataAccessError extends Error { .join(', '); super( `[runAs] refusing a data operation${where ? ` (${where})` : ''}: this run's effective runAs is ` + - `'user' but no trigger user could be resolved, so the operation cannot be restricted to a user. ` + - `Without one it would carry no principal: ` + - `refused by the security plugin where one is composed, unscoped where none is. ` + - `Declare \`runAs: 'system'\` on the ` + + `'user' but no trigger user could be resolved, so the operation cannot be restricted to a user. Without one it would carry no principal: ` + + `refused by the security plugin where one is composed, unscoped where none is. Declare \`runAs: 'system'\` on the ` + `flow to make the elevation explicit and intended, or arrange for the trigger to supply a user ` + `(a write made with a system context carries none). (ADR-0049)`, );