Repository navigation
fix(service-automation, objectql): runAs refusal and run-setup warning texts hold on both kernels - #22390
Conversation
…g 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
📓 Docs Drift Check1 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 19 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin db4bad7f7d712257e5a3d601d9335b9bbbe709fb && git checkout db4bad7f7d712257e5a3d601d9335b9bbbe709fb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 191543456ff273abd675d2fc42df39aa5976d9fd 7b2cdf7255afc265405b5939a54541eae67eba12 && git checkout -B drift-repro 191543456ff273abd675d2fc42df39aa5976d9fd && git merge --no-ff 7b2cdf7255afc265405b5939a54541eae67eba12
node scripts/docs-audit/affected-docs.mjs --json 191543456ff273abd675d2fc42df39aa5976d9fd |
Fixes #22362
Clause-②: no
domain:servicesseat 1, branchclaude/issue-22362-unscoped-run-strings, dispatched under the claim6071780429, executing triage6070703882(unlocked at6071449995). This is the runtime-strings pass of the pre-D5 family. The other members are not touched here:packages/specis #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 withplugin-securityrefuses an operation that carries no principal:security-plugin.ts:2655throws403 PERMISSION_DENIEDfor every verb whenisPrincipalLessContext(:383) holds. D5 landed ina3bcbcf3ca;git merge-base --is-ancestor a3bcbcf3ca 16096e8d7bexits 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.
service-automation/src/runtime-identity.ts:87-92, theUnscopedRunDataAccessErrormessagedomain:servicesservice-automation/src/engine.ts:6133-6140, the[runAs]warningresolveRunContextlogs at run setupdomain:servicesobjectql/src/hook-run-as.ts:118-125, theHookUnscopedDataAccessErrormessagedomain: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 nouserIdanUnscopedHookApiinstead of{ ...triggering context, isSystem: false }, and that context would carry no principal.Before and after (as the runtime prints them;
WHEREandFLOWare 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. DeclarerunAs: '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. DeclarerunAs: '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. DeclarerunAs: '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 oncode === '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. DeclarerunAs: '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 oncode === 'HOOK_UNSCOPED_DATA_ACCESS'(ADR-0112) to detect this. (ADR-0049)A and C after are read back from the rebuilt
dist(new UnscopedRunDataAccessError(...)andnew HookUnscopedDataAccessError(...)through each package'sexports). B is the source template. Codes, class names, the403status on C and the order of every refusal are unchanged.No non-string token moved
Each changed
.tsfile was projected throughscripts/js-comment-mask.mjsscanSource: comment bytes and literal CONTENT bytes blanked, literal delimiters and${...}interpolation code kept, whitespace runs collapsed. All 6 projections are byte-identical to base16096e8d7b, and so are the line counts:objectql/src/hook-run-as.ts5df1af062d3f07f7objectql/src/hook-run-as.test.tsf42dd65d4b606a02service-automation/src/runtime-identity.ts7b34e026b883b71eservice-automation/src/engine.tsdd43f6d3d3fd367cservice-automation/src/builtin/crud-runas.test.ts22f4d9aa2de782a3trigger-schedule/src/schedule-runas-e2e.test.ts446edc7895f0eb06To 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(becomesthis.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 grepcannot 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/.cjsfile outsidepackages/spec(6,420 files) is projected throughscanSourcewith 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:It gives 92 lines at base
16096e8d7band 94 at head7b2cdf7255. 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-6139are 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: theflow-runas-unscopedfinding 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 absentexecutionContext; 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) andmetadata-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:34597andscripts/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 (
filesisdist,README.md,CHANGELOG.mdin every package touched). By what they say: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.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.plugin-security/src/security-plugin.test.ts:2317, :2571, :2674andcan-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").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.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:351anddriver-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(explicitrunAs:'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 (
unscoped330 lines,bypass408, fall/fail-open 236,admit1,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: noadmitObjectReadand no security service, so analytics queries are "admitted the same way". True: it fires only where no security service is composed.:819-821is the fail-closed branch.runtime/src/domains/actions.ts:805andmetadata-core/src/object-schema-fls-contract.ts:348: an explicitisSystemelevation. 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
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
service-automation/src/builtin/crud-runas.test.ts:238toMatch(/UNSCOPED/)toMatch(/refused by the security plugin where one is composed, unscoped where none is/)trigger-schedule/src/schedule-runas-e2e.test.ts:122toMatch(/UNSCOPED/)objectql/src/hook-run-as.test.ts:210toContain('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
REFUSEDandrunAs:'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). Thennode scripts/ablation-replace.mjsran in WRAP mode: it puts the old text back on disk, runs the test, and restores fromHEADwith proof. Both legs ran underscripts/pm/os-verify-lock.sh. The expected direction was red, and red is what was observed.objectql/src/hook-run-as.ts. The anchorrefused by the security plugin where one is composed, unscoped where none is.(1 hit, 1 → 0) was replaced bywould execute UNSCOPED (elevated, RLS-bypassing) rather than restricted to a user.(0 → 1). Blob26d796cde2d7→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 …'.26d796cde2d7), andgit diff HEADis empty.service-automation/src/runtime-identity.ts. The same anchor and replacement (1 → 0, 0 → 1). Blob613932cfc20f→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 …/.613932cfc20f), andgit diff HEADis empty.git status --porcelainprinted 0 lines andgit diff HEAD0 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-automationthroughdist, andKNOWN_UNALIASED_TEST_IMPORTSlists that pair. It was not ablated. Its green run reads the rebuiltdist, which carries the new clause 2 times and the old phrase 0 times.Changeset
.changeset/22362-unscoped-run-strings.md:@objectstack/service-automationpatchand@objectstack/objectqlpatch,Clause-②: no. Both packages ship the moved text: afterturbo run build,service-automation/dist/index.jsanddist/index.cjseach hold the new clause 2 times andRLS-bypassing) rather0 times.objectql/dist/index.jsanddist/index.mjshold it 1 time and the old phrase 0 times. As a control, the unchangeda write made with a systemis present 1 time in each.@objectstack/trigger-schedulechanges a test only, and itsfilesisdist,README.mdandCHANGELOG.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) andpackages/objectql/src/hook-run-as.test.ts:210(its pin).packages/triggers/trigger-scheduleis this lane's.Verification (head
7b2cdf7255)All builds and tests ran under
scripts/pm/os-verify-lock.sh, and each printedVERDICT command-exit 0.Builds
turbo run build --filter=@objectstack/trigger-schedule... --filter=@objectstack/service-automation... --filter=@objectstack/objectql... --concurrency=1gaveTasks: 31 successful, 31 total(19 cached).turbo run build --filter=!@objectstack/docs --concurrency=1gaveTasks: 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, thetestscript's project (vitest run --project local --maxWorkers=2):Test Files 388 passed (388),Tests 7633 passed (7633).@objectstack/runtimesrc/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 --noEmitexits 0, and its program includesschedule-runas-e2e.test.ts(--listFilesOnlycount 1).ESLint, narrowed to the 6 changed
.tsfiles (--no-inline-config --format json): 6 files, errors=0, warnings=0. The narrowing is a measurement, on three pieces of evidence:**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}block (eslint.config.mjs:971).eslint.config.mjs:327-328states the config "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file". So this diff cannot move any untouched file's verdict.The full
pnpm lintis CI's.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 68 commands from the 7 changed paths at7b2cdf7255. All 68 exit 0.--ranwith 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)".check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET, 36 packages unbuilt) andcheck-engine-split-ratio --days 90refused on the shallow clone (exit 2).check:dts-closureandcheck:sourcemap-no-sources-contenthad 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 nomajorbump."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-filesandcheck-issue-citationsare green.The derivation's stale-tree note. While the gates ran,
origin/mainmoved 4 commits, to117d34de3f. Two gate-input files changed upstream:scripts/platform-object-tenancy-census.jsonandscripts/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
repovitest 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
plugin-security/src/security-plugin.test.ts:2317, :2571, :2674andcan-write-object-admission.test.ts:640say "(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-96say "user-less runAs fail-open" and "runs the flow UNSCOPED (user-less)". That test runs a stub data executor on a bareAutomationEngine, and what it asserts is the warning. service-automation: the exportedRunProvenanceContextdocstring still says a{ flowRunId }-only context falls open ("indistinguishable from passing no context at all"); ADR-0096 D5 now refuses it, and the type is produced nowhere #22345 left the same set.service-automation/src/engine.ts:357andguard-refusal.ts:17list "a run would execute unscoped" among guard refusals.qa/dogfood/test/flow-runas-schedule.dogfood.test.ts:12already carries service-automation: the exportedRunProvenanceContextdocstring still says a{ flowRunId }-only context falls open ("indistinguishable from passing no context at all"); ADR-0096 D5 now refuses it, and the type is produced nowhere #22345's D5 correction.content/docs/automation/flows.mdx:1593lists "a run that would execute unscoped" among guard refusals. That names the refusal class the same waylint-flow-patterns.ts:728does.content/docs/automation/hooks.mdx:155says such operations are "refused (HOOK_UNSCOPED_DATA_ACCESS) rather than run unscoped", the same reading as the lint hint.skills/objectstack-automation/SKILL.md:194is skills(objectstack-query): SKILL.md:66 teaches{ flowRunId }as a context "for provenance alone", which the engine refuses since ADR-0096 D5 #22372's surface. Taker: none.try_catch'sretryis the shared non-strictRetryPolicySchema, soobjectstack validatepasses an undeclaredretrykey thatregisterFlowrefuses — the one builtin left on the descriptor walk after #21982 #22343 (domain:specseat 2) editsengine.ts'svalidateNodeConfigKeys, away from the run-setup warning. It had not landed as of the last fetch oforigin/main(191543456f, none of whose commits since base touches a file this PR changes), so no merge ofmainwas owed.Generated by Claude Code