Skip to content

Commit fdfdd7e

Browse files
fix(service-automation, objectql): runAs refusal and run-setup warning texts hold on both kernels (#22390)
Fixes #22362 Clause-②: no `domain:services` seat 1, branch `claude/issue-22362-unscoped-run-strings`, dispatched under the claim `6071780429`, executing triage `6070703882` (unlocked at `6071449995`). This is the runtime-strings pass of the pre-D5 family. The other members are not touched here: `packages/spec` is #22302, comments and docstrings were #22345 (PR #22357), and the skills surface is #22372. ## What this does Three author-facing runtime strings told a flow or hook author that a `runAs: 'user'` data operation with no trigger user "would execute UNSCOPED (elevated, RLS-bypassing)". That counterfactual holds only on a kernel with no security plugin. Since ADR-0096 D5, a kernel with `plugin-security` refuses an operation that carries no principal: `security-plugin.ts:2655` throws `403 PERMISSION_DENIED` for every verb when `isPrincipalLessContext` (`:383`) holds. D5 landed in `a3bcbcf3ca`; `git merge-base --is-ancestor a3bcbcf 16096e8` exits 0. Each string now states what such an operation meets on both kernels, in the triage's words: refused by the security plugin where one is composed, unscoped where none is. Each remedy is kept word for word. | # | Site | Lane | |:-|:-|:-| | A | `service-automation/src/runtime-identity.ts:87-92`, the `UnscopedRunDataAccessError` message | `domain:services` | | B | `service-automation/src/engine.ts:6133-6140`, the `[runAs]` warning `resolveRunContext` logs at run setup | `domain:services` | | C | `objectql/src/hook-run-as.ts:118-125`, the `HookUnscopedDataAccessError` message | `domain:engine` (strings only, declared below) | The triage named A and B. The enumeration found C: the hook-side twin of A, whose docblock says its "wording mirrors" A's. It states the same counterfactual. `withRunAs('user')` hands a hook with no `userId` an `UnscopedHookApi` instead of `{ ...triggering context, isSystem: false }`, and that context would carry no principal. ### Before and after (as the runtime prints them; `WHERE` and `FLOW` are placeholders) **A, before:** `[runAs] refusing a data operation (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 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)` **A, after:** `[runAs] refusing a data operation (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 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)` **B, before:** `[runAs] flow 'FLOW' 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 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).` **B, after:** `[runAs] flow 'FLOW' 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). 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).` **C, before:** `[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 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'` (ADR-0112) to detect this. (ADR-0049)` **C, after:** `[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 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'` (ADR-0112) to detect this. (ADR-0049)` A and C after are read back from the rebuilt `dist` (`new UnscopedRunDataAccessError(...)` and `new HookUnscopedDataAccessError(...)` through each package's `exports`). B is the source template. Codes, class names, the `403` status on C and the order of every refusal are unchanged. ### No non-string token moved Each changed `.ts` file was projected through `scripts/js-comment-mask.mjs` `scanSource`: comment bytes and literal CONTENT bytes blanked, literal delimiters and `${...}` interpolation code kept, whitespace runs collapsed. All 6 projections are byte-identical to base `16096e8d7b`, and so are the line counts: | File | Lines (base → head) | Projection sha256 (first 16), base = head | |:-|:-|:-| | `objectql/src/hook-run-as.ts` | 208 → 208 | `5df1af062d3f07f7` | | `objectql/src/hook-run-as.test.ts` | 426 → 426 | `f42dd65d4b606a02` | | `service-automation/src/runtime-identity.ts` | 335 → 335 | `7b34e026b883b71e` | | `service-automation/src/engine.ts` | 12888 → 12888 | `dd43f6d3d3fd367c` | | `service-automation/src/builtin/crud-runas.test.ts` | 502 → 502 | `22f4d9aa2de782a3` | | `trigger-schedule/src/schedule-runas-e2e.test.ts` | 142 → 142 | `446edc7895f0eb06` | To keep that true, each message keeps the number of concatenated template pieces it had. Control leg (in memory, no disk write): the same projection DIFFERS when the warning's `this.logger.warn(` becomes `this.logger.error(` (anchor hit 1), and when one extra empty piece is concatenated. The only other file is the changeset. ## The enumeration (the pin) **Method.** A comment-level `git grep` cannot tell a string from a comment, and #22345 already closed the comments. So the sweep reads only string, template and regex literal content: each tracked `.ts/.tsx/.mts/.cts/.js/.jsx/.mjs/.cjs` file outside `packages/spec` (6,420 files) is projected through `scanSource` with everything that is not literal content blanked. It reports every literal line naming a user-less or principal-less run or context (SUBJECT) with a claim term (CLAIM) within 3 lines. Run it from the repository root: ```js // node --input-type=module, with this file on stdin, from the repository root import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { scanSource } from './scripts/js-comment-mask.mjs'; const SUBJECT = /principal-?less|no principal|without (a |any )?principal|user-?less|no (trigger )?user\b|without (a |any )?user\b|no identity|identity-?less|no (execution ?)?context|context-?less|no session|anonymous|provenance[- ]only/i; const CLAIM = /unscoped|fall(s|ing)?[- ]open|fell[- ]open|fail(s|ed|ing)?[- ]open|\badmit(s|ted|ting)?\b|\bskip(s|ped|ping)?\b|bypass|straight (through|to)|\belevat|unrestricted|full access|unfiltered|wave[sd]? through|hand(ed|s)?[- ](off|through)|pass(es|ed)? through|not scoped/i; let n = 0; for (const f of execFileSync('git', ['ls-files'], { encoding: 'utf8', maxBuffer: 2 ** 28 }).split('\n')) { if (!/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(f) || f.startsWith('packages/spec/')) continue; const text = readFileSync(f, 'utf8'); const { literal, interpolation } = scanSource(text); const lines = text.split('').map((c, k) => (c === '\n' || (literal[k] && !interpolation[k]) ? c : ' ')).join('').split('\n'); lines.forEach((l, i) => { if (SUBJECT.test(l) && CLAIM.test(lines.slice(Math.max(0, i - 3), i + 4).join(' '))) { n++; console.log(`${f}:${i + 1}: ${l.replace(/\s+/g, ' ').trim().slice(0, 160)}`); } }); } console.error(`${n} line(s)`); ``` It gives **92 lines at base `16096e8d7b`** and 94 at head `7b2cdf7255`. The two extra lines are the new "would carry no principal" sentences of B and C, which name a principal-less operation and are in the reworded class. ### The 92 base lines, each with its class **Reworded here: false on a kernel with `plugin-security` (5 lines, 3 strings)** - `objectql/src/hook-run-as.ts:119` (C) - `service-automation/src/engine.ts:6134, :6138, :6139` (B; `:6138-6139` are its "Note a user-less trigger is NOT only a schedule" tail, which is true and kept) - `service-automation/src/runtime-identity.ts:89` (A) **Production, true on both kernels, left (7)** - `lint/src/lint-flow-patterns.ts:1612`: the `flow-runas-unscoped` finding says the data node "will be REFUSED at run time", and its hint says "the runtime refuses the operation rather than run it unscoped". Both state the refusal, which holds on both kernels; neither says what the middleware would do. - `runtime/src/action-execution.ts:2369`: an explicit system elevation (`isSystem`) of an action body. - `runtime/src/route-ledger.ts:435, :441, :445, :459`: fail-closed on an absent `executionContext`; the anonymous floor answers 401 first. - `service-knowledge/src/knowledge-service.ts:340`: the knowledge service's own corpus filter fails closed ("rather than searching the whole corpus unscoped"). That counterfactual is the knowledge service's own, not the data middleware's. **Production, another subject, left (2)**: `cloud-connection/src/cloud-connection-route-ledger.ts:262` (an anonymous browser surface of the catalog proxy) and `metadata-core/src/contract-suite.ts:189` (an "anonymous exception" in a contract suite). **Repository tooling, another subject, left (2)**: `scripts/pm/check-half-states.mjs:34597` and `scripts/tenant-audit-census.mjs:2813`. **Test files: test titles, assertion messages and fixture strings, left (76).** They are not error messages, warnings or logs a runtime prints, and none ships (`files` is `dist`, `README.md`, `CHANGELOG.md` in every package touched). By what they say: - *State the refusal or name the D5 denial (true):* `objectql/src/hook-run-as.test.ts:190`; `plugin-auth/src/auth-manager.org-slug-guard-system-context.test.ts:156, :160`; `plugin-security/src/authored-row-write-verdict.test.ts:525, :541` (explicit elevation); `plugin-security/src/controlled-by-parent-master-widener.test.ts:583`; `plugin-security/src/public-form-grant-masking.test.ts:233, :234` (negation); `plugin-sharing/src/share-link-service.test.ts:580`; `qa/dogfood/test/declarative-endpoint-anonymous-guest.dogfood.test.ts:283`; `runtime/src/sandbox/hook-run-as.integration.test.ts:198`; `service-automation/src/builtin/crud-runas.test.ts:289, :290, :337`; `trigger-record-change/src/record-change-integration.test.ts:406`. - *Assertion messages that name the failure shape the test exists to catch:* `qa/dogfood/test/flow-runas-schedule.dogfood.test.ts:128, :133, :140, :143`; `trigger-record-change/src/record-change-integration.test.ts:448`; `service-automation/src/runas-grant-resolution.integration.test.ts:102`; `runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts:571`; `service-automation/src/builtin/crud-runas.test.ts:91, :118`. - *Test titles that use the pre-D5 word for the mechanism (see Acceptance notes):* `plugin-security/src/security-plugin.test.ts:2317, :2571, :2674` and `can-write-object-admission.test.ts:640` ("gate is before the fall-open"); `trigger-schedule/src/schedule-runas-e2e.test.ts:95, :96` ("user-less runAs fail-open", "runs the flow UNSCOPED"). - *A guard's or hook's own carve-out for a context-less call (another subject: the guard skips itself, not the data middleware):* `plugin-audit/src/comment-access-hooks.test.ts:178, :187`; `plugin-audit/src/comment-read-visibility.test.ts:201`; `plugin-auth/src/identity-write-guard.test.ts:84, :172`; `plugin-security/src/system-write-guard.test.ts:82`; `service-storage/src/attachment-access-hooks.test.ts:139, :152, :678, :818`; `service-storage/src/attachment-read-visibility.test.ts:253`. - *Another subject:* `qa/dogfood/test/authz-conformance.matrix.ts` (17 lines: `:202, :203, :243-:247, :285, :286, :302, :309, :320, :324, :361, :460-:462`, the anonymous HTTP posture rows); `driver-sql/src/sql-driver-tenant-scope.test.ts:351` and `driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts:250` (driver tenant scope); `lint/src/lint-flow-patterns.test.ts:363` (the rule's name); `objectql/src/engine-repo-execute-elevation.test.ts:155`; `plugin-auth/src/audience-posture.test.ts:843`, `send-verification-email.test.ts:63`, `set-initial-password.test.ts:65`; `qa/dogfood/test/flow-runas-schedule.dogfood.test.ts:157` (explicit `runAs:'system'`); `qa/dogfood/test/form-self-auth.dogfood.test.ts:35`; `rest/src/ui-view-route-identity.measurement.test.ts:343`, `ui-view-route-tenancy.measurement.test.ts:508`; `runtime/src/action-body-identity.test.ts:114, :235`; `runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts:577`; `runtime/src/endpoint-policy.test.ts:272`; `runtime/src/http-dispatcher.mcp-oauth.test.ts:207`; `service-automation/src/builtin/crud-runas.test.ts:376` (the predicate's name); `service-automation/src/runas-attribution-contract.test.ts:244`. ### Claim-word scans the window cannot pair, production only Each claim term was also scanned alone over the same literal projection (`unscoped` 330 lines, `bypass` 408, fall/fail-open 236, `admit` 1,069, subject terms 315). The production lines on this subject that the window above does not pair: - `lint/src/lint-flow-patterns.ts:728`: "an unscoped run" names the refusal class among guard refusals. True. - `plugin-security/src/security-plugin.ts:401`: the D5 refusal itself. True. - `plugin-dev/src/dev-plugin.ts:887, :889`: "plugin-security not installed — skipping security". True: no security plugin is composed. - `service-analytics/src/plugin.ts:806-812`: no `admitObjectRead` and no security service, so analytics queries are "admitted the same way". True: it fires only where no security service is composed. `:819-821` is the fail-closed branch. - `runtime/src/domains/actions.ts:805` and `metadata-core/src/object-schema-fls-contract.ts:348`: an explicit `isSystem` elevation. True. - `service-storage/src/storage-routes.ts:465`: upload routes "accept anonymous requests" when no session resolver is wired (bare kernel). Another subject: an HTTP route's session gate, not a data context. None of these is false on a kernel with `plugin-security`, so there is no fourth string. ### A re-runnable pin over the result ``` git grep -n -i -E "would execute unscoped|RLS-bypassing\) rather" -- ':!packages/spec/**' ':!**/CHANGELOG.md' git grep -n -F "refused by the security plugin where one is composed, unscoped where none is" -- ':!packages/spec/**' ``` At base, the first gives 9 lines: A (`runtime-identity.ts:89, :90`), B (`engine.ts:6135, :6136`) and C (`hook-run-as.ts:120`), plus 4 that are not runtime strings (see Acceptance notes). At head it gives the same 4 and the changeset's quotation of the old text. The second gives nothing at base. At head it gives the three strings (`hook-run-as.ts:121`, `runtime-identity.ts:90`, `engine.ts:6136`), the three moved pins, and the changeset. ## Pins moved with the wording | Pin | Before | After | |:-|:-|:-| | `service-automation/src/builtin/crud-runas.test.ts:238` | `toMatch(/UNSCOPED/)` | `toMatch(/refused by the security plugin where one is composed, unscoped where none is/)` | | `trigger-schedule/src/schedule-runas-e2e.test.ts:122` | `toMatch(/UNSCOPED/)` | the same regex | | `objectql/src/hook-run-as.test.ts:210` | `toContain('UNSCOPED')` | `toContain('refused by the security plugin where one is composed, unscoped where none is')` | The triage named the first two. The third pins C and matched the old word, so it moves with it. No pin was added; each pin is still one matcher, now on the clause the triage dictated. The `REFUSED` and `runAs:'system'` pins beside them are unchanged and still pass. ### Reverse verification: each source-resolved moved pin rejects the old counterfactual The fix was committed first (HEAD `7b2cdf7255`). Then `node scripts/ablation-replace.mjs` ran in WRAP mode: it puts the old text back on disk, runs the test, and restores from `HEAD` with proof. Both legs ran under `scripts/pm/os-verify-lock.sh`. The expected direction was red, and red is what was observed. - **`objectql/src/hook-run-as.ts`.** The anchor `refused by the security plugin where one is composed, unscoped where none is.` (1 hit, 1 → 0) was replaced by `would execute UNSCOPED (elevated, RLS-bypassing) rather than restricted to a user.` (0 → 1). Blob `26d796cde2d7` → `e524cf30db66`. - `vitest run src/hook-run-as.test.ts`: `Tests 1 failed | 13 passed (14)`. The one failure is the moved pin: `expected '[runAs] refusing a data operation (ho…' to contain 'refused by the security plugin where …'`. - Restored: blob == HEAD (`26d796cde2d7`), and `git diff HEAD` is empty. - **`service-automation/src/runtime-identity.ts`.** The same anchor and replacement (1 → 0, 0 → 1). Blob `613932cfc20f` → `8a5b98066be8`. - `vitest run src/builtin/crud-runas.test.ts`: `Tests 1 failed | 23 passed (24)`. The one failure is the moved pin, in "the refusal names the fix": `expected '[runAs] refusing a data operation (ob…' to match /refused by the security plugin where …/`. - Restored: blob == HEAD (`613932cfc20f`), and `git diff HEAD` is empty. - After both legs, `git status --porcelain` printed 0 lines and `git diff HEAD` 0 bytes. Both test files import their subject from source (`./hook-run-as.js`, `../runtime-identity.js`), so no build was involved and none is owed. The third pin (`trigger-schedule`) is different: it reads `@objectstack/service-automation` through `dist`, and `KNOWN_UNALIASED_TEST_IMPORTS` lists that pair. It was not ablated. Its green run reads the rebuilt `dist`, which carries the new clause 2 times and the old phrase 0 times. ## Changeset `.changeset/22362-unscoped-run-strings.md`: `@objectstack/service-automation` `patch` and `@objectstack/objectql` `patch`, `Clause-②: no`. Both packages ship the moved text: after `turbo run build`, `service-automation/dist/index.js` and `dist/index.cjs` each hold the new clause 2 times and `RLS-bypassing) rather` 0 times. `objectql/dist/index.js` and `dist/index.mjs` hold it 1 time and the old phrase 0 times. As a control, the unchanged `a write made with a system` is present 1 time in each. `@objectstack/trigger-schedule` changes a test only, and its `files` is `dist`, `README.md` and `CHANGELOG.md`, so it gets no line. ## Cross-lane paths (strings and one test pin; declared for the owning seat) - `domain:engine`: `packages/objectql/src/hook-run-as.ts` (string C) and `packages/objectql/src/hook-run-as.test.ts:210` (its pin). - `packages/triggers/trigger-schedule` is this lane's. ## Verification (head `7b2cdf7255`) All builds and tests ran under `scripts/pm/os-verify-lock.sh`, and each printed `VERDICT command-exit 0`. **Builds** - Closure build: `turbo run build --filter=@objectstack/trigger-schedule... --filter=@objectstack/service-automation... --filter=@objectstack/objectql... --concurrency=1` gave `Tasks: 31 successful, 31 total` (19 cached). - Full build: `turbo run build --filter=!@objectstack/docs --concurrency=1` gave `Tasks: 72 successful, 72 total` (71 cached). **Tests** - `@objectstack/service-automation`, full suite (`vitest run --maxWorkers=2`): `Test Files 178 passed (178)`, `Tests 2177 passed (2177)`. - `@objectstack/trigger-schedule`, full suite: `Test Files 8 passed (8)`, `Tests 174 passed (174)`. - `@objectstack/objectql`, the `test` script's project (`vitest run --project local --maxWorkers=2`): `Test Files 388 passed (388)`, `Tests 7633 passed (7633)`. - `@objectstack/runtime` `src/sandbox/hook-run-as.integration.test.ts`, the one other test that reads C (by its code, which C still names): `Tests 4 passed (4)`. **Typecheck** - `service-automation`: `check:test-typecheck: OK … 0 file(s) / 0 error(s)`. - `objectql`: `check:test-typecheck: OK … 40 file(s) / 234 error(s) / 65 pinned signature(s) held in test-typecheck-debt.json`. The ledger is unchanged. - `trigger-schedule`: `tsc --noEmit` exits 0, and its program includes `schedule-runas-e2e.test.ts` (`--listFilesOnly` count 1). **ESLint, narrowed to the 6 changed `.ts` files** (`--no-inline-config --format json`): 6 files, errors=0, warnings=0. The narrowing is a measurement, on three pieces of evidence: - The population is the config's `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block (`eslint.config.mjs:971`). - The file count, 6, is read from the JSON output. - `eslint.config.mjs:327-328` states the config "never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules) for ANY file". So this diff cannot move any untouched file's verdict. The full `pnpm lint` is CI's. **Gates** - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 68 commands from the 7 changed paths at `7b2cdf7255`. All 68 exit 0. - `--ran` with recorded exit codes: "✓ dispatch-gates --ran: 68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED (a DERIVED zero — all 68 recorded an exit code and none of them is 3)". - Seven were run again after the full build and a history deepening, and the recorded codes are from those runs. In the first pass, `check:dual-build-cjs-loads` exited 3 (`PREREQUISITE NOT MET`, 36 packages unbuilt) and `check-engine-split-ratio --days 90` refused on the shallow clone (exit 2). `check:dts-closure` and `check:sourcemap-no-sources-content` had swept only the 31 built packages. Verdict lines from the gates: - `check:nul-bytes`: "OK (scanned 10368 text file(s) … no raw ASCII control bytes)". - `check:doc-authoring`: "doc authoring guard: … 89492 string(s) read in 1285 parsed source(s) … no growth". - `check-adr-0087-registration`: "this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)". - `check-changeset-no-major`: "This diff introduces no `major` bump." - `check:dual-build-cjs-loads`: "107 published require entry point(s) across 66 package(s) load; 717 emitted CommonJS file(s) parse". - `check:dts-closure`: "72 built package(s) swept - 172/172 declared declaration file(s) present". - `check-system-context-census`: "OK — 118 elevation read sites in 20 packages". - `check:test-source-alias`, `check:cross-package-test-inputs`, `check:published-files` and `check-issue-citations` are green. **The derivation's stale-tree note.** While the gates ran, `origin/main` moved 4 commits, to `117d34de3f`. Two gate-input files changed upstream: `scripts/platform-object-tenancy-census.json` and `scripts/migrate/overlay-views-to-sys-view-definition.md`. None of the 4 commits touches a file this PR changes, so the merge ref is CI's to judge. **Declared narrowings** - objectql's `repo` vitest project (`test:repo`) and the Dogfood Regression Gate are left to CI. The diff moves no export, type or code token (see the projection above), only message text. ## Acceptance notes - **Test titles that use the pre-D5 word, left.** The card's ruling covers error, warning and log strings; these are titles. `plugin-security/src/security-plugin.test.ts:2317, :2571, :2674` and `can-write-object-admission.test.ts:640` say "(gate is before the fall-open)". The gates still run before the D5 refusal, so the DENIES assertions hold. `trigger-schedule/src/schedule-runas-e2e.test.ts:95-96` say "user-less runAs fail-open" and "runs the flow UNSCOPED (user-less)". That test runs a stub data executor on a bare `AutomationEngine`, and what it asserts is the warning. #22345 left the same set. - **Comments with the same phrase as a class name, left (comment-only edits are outside this card).** `service-automation/src/engine.ts:357` and `guard-refusal.ts:17` list "a run would execute unscoped" among guard refusals. `qa/dogfood/test/flow-runas-schedule.dogfood.test.ts:12` already carries #22345's D5 correction. - **Hand-written docs, not runtime strings.** `content/docs/automation/flows.mdx:1593` lists "a run that would execute unscoped" among guard refusals. That names the refusal class the same way `lint-flow-patterns.ts:728` does. `content/docs/automation/hooks.mdx:155` says such operations are "refused (`HOOK_UNSCOPED_DATA_ACCESS`) rather than run unscoped", the same reading as the lint hint. `skills/objectstack-automation/SKILL.md:194` is #22372's surface. Taker: none. - **Possible overlap.** #22343 (`domain:spec` seat 2) edits `engine.ts`'s `validateNodeConfigKeys`, away from the run-setup warning. It had not landed as of the last fetch of `origin/main` (`191543456f`, none of whose commits since base touches a file this PR changes), so no merge of `main` was owed. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3089848 commit fdfdd7e

7 files changed

Lines changed: 25 additions & 10 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
'@objectstack/objectql': patch
4+
---
5+
6+
fix(service-automation, objectql): the `runAs: 'user'` refusals and the run-setup warning say what a principal-less operation meets on both kernels
7+
8+
Clause-②: no
9+
10+
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.
11+
12+
- `@objectstack/service-automation`: the `UnscopedRunDataAccessError` message, and the `[runAs]` warning logged at run setup for a flow whose trigger resolved no user.
13+
- `@objectstack/objectql`: the `HookUnscopedDataAccessError` message.
14+
15+
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.

‎packages/objectql/src/hook-run-as.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ describe('#14010 Hook.runAs — the identity a hook\'s ctx.api presents', () =>
207207
expect(thrown.hook).toBe('stamp_grade');
208208
// The remedy the author can act on, and the reason.
209209
expect(thrown.message).toContain("runAs: 'system'");
210-
expect(thrown.message).toContain('UNSCOPED');
210+
expect(thrown.message).toContain('refused by the security plugin where one is composed, unscoped where none is');
211211
// The decisive half: refusing means the operation did NOT run unscoped.
212212
expect(engine.seen).toEqual([]);
213213
});

‎packages/objectql/src/hook-run-as.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,8 @@ export class HookUnscopedDataAccessError extends Error {
117117
.join(', ');
118118
super(
119119
`[runAs] refusing a data operation (${where}): this hook's runAs is 'user' but no trigger user ` +
120-
`could be resolved, so the operation would execute UNSCOPED (elevated, RLS-bypassing) rather ` +
121-
`than restricted to a user. Declare \`runAs: 'system'\` on the hook to make the elevation ` +
120+
`could be resolved, so the operation cannot be restricted to a user. Without one it would carry no principal: ` +
121+
`refused by the security plugin where one is composed, unscoped where none is. Declare \`runAs: 'system'\` on the hook to make the elevation ` +
122122
`explicit and intended, or arrange for the trigger to supply a user (a write made with a system ` +
123123
`context carries none). Branch on \`code === '${HOOK_UNSCOPED_DATA_ACCESS_CODE}'\` (ADR-0112) ` +
124124
`to detect this. (ADR-0049)`,

‎packages/services/service-automation/src/builtin/crud-runas.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ describe('resolveRunDataContext (#1888 unit)', () => {
235235
expect(err).toBeInstanceOf(UnscopedRunDataAccessError);
236236
expect((err as UnscopedRunDataAccessError & { code: string }).code).toBe('AUTOMATION_UNSCOPED_RUN_DATA_ACCESS');
237237
expect(err!.message).toMatch(/runAs: 'system'/);
238-
expect(err!.message).toMatch(/UNSCOPED/);
238+
expect(err!.message).toMatch(/refused by the security plugin where one is composed, unscoped where none is/);
239239
// Names WHERE, so a refusal in a busy log is traceable to a flow + record.
240240
expect(err!.message).toContain("object 'invoice'");
241241
expect(err!.message).toContain("run 'run_1'");

‎packages/services/service-automation/src/engine.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6132,9 +6132,9 @@ export class AutomationEngine implements IAutomationService {
61326132
if (runIsUnscopedUserMode(runContext) && flowTouchesData(flow)) {
61336133
this.logger.warn(
61346134
`[runAs] flow '${flow.name}' executes with runAs:'user' but its trigger resolved no user ` +
6135-
`— its data operations will be REFUSED. Running them would execute UNSCOPED ` +
6136-
`(elevated, RLS-bypassing) rather than restricted, which is the fail-open ADR-0049 ` +
6137-
`forbids. Declare runAs:'system' to make the elevation explicit and intended, or arrange ` +
6135+
`— its data operations will be REFUSED. Without a user they would carry no principal: ` +
6136+
`refused by the security plugin where one is composed, unscoped where none is (the fail-open ADR-0049 forbids). ` +
6137+
`Declare runAs:'system' to make the elevation explicit and intended, or arrange ` +
61386138
`for the trigger to supply a user. Note a user-less trigger is NOT only a schedule: a ` +
61396139
`record-change flow fired by a system write carries no user either (ADR-0049).`,
61406140
);

‎packages/services/service-automation/src/runtime-identity.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@ export class UnscopedRunDataAccessError extends Error {
8686
.join(', ');
8787
super(
8888
`[runAs] refusing a data operation${where ? ` (${where})` : ''}: this run's effective runAs is ` +
89-
`'user' but no trigger user could be resolved, so the operation would execute UNSCOPED ` +
90-
`(elevated, RLS-bypassing) rather than restricted to a user. Declare \`runAs: 'system'\` on the ` +
89+
`'user' but no trigger user could be resolved, so the operation cannot be restricted to a user. Without one it would carry no principal: ` +
90+
`refused by the security plugin where one is composed, unscoped where none is. Declare \`runAs: 'system'\` on the ` +
9191
`flow to make the elevation explicit and intended, or arrange for the trigger to supply a user ` +
9292
`(a write made with a system context carries none). (ADR-0049)`,
9393
);

‎packages/triggers/trigger-schedule/src/schedule-runas-e2e.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ describe('schedule trigger -> engine: user-less runAs fail-open via the REAL cro
119119
const w = runAsWarns(warns);
120120
expect(w).toHaveLength(1);
121121
expect(w[0]).toContain("flow 'nightly_sweep'");
122-
expect(w[0]).toMatch(/UNSCOPED/);
122+
expect(w[0]).toMatch(/refused by the security plugin where one is composed, unscoped where none is/);
123123
expect(w[0]).toMatch(/runAs:'system'/);
124124
});
125125

0 commit comments

Comments
 (0)