Repository navigation
fix(service-automation)!: retire RunProvenanceContext; pre-D5 principal-less readings outside packages/spec say D5 refuses it - #22357
Conversation
… names only what resolveRunDataContext returns
The provenance-only envelope declared a { flowRunId }-only data context
that no code builds and ADR-0096 D5 refuses. Its docstring and four
sibling comments described the data security middleware skipping a
principal-less context in the present tense.
Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…refused since ADR-0096 D5 Comment and docstring text only: the sentences that described the security middleware handing through, skipping or falling open for a context with no principal now say that it used to, and that D5 refuses it. No executable line moves. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…ur packages ship comment text only Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…e-d5-reading-pass
📓 Docs Drift CheckThis PR changes 7 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 52 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 d6f1acc5b197169f663442cb6cdc02a62083c465 && git checkout d6f1acc5b197169f663442cb6cdc02a62083c465
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b7e01fbbd2c3340ae95e54e2ec220314e9ae728e f3a9675f4b07a27ddc4d50622060450a1036d429 && git checkout -B drift-repro b7e01fbbd2c3340ae95e54e2ec220314e9ae728e && git merge --no-ff f3a9675f4b07a27ddc4d50622060450a1036d429
node scripts/docs-audit/affected-docs.mjs --json b7e01fbbd2c3340ae95e54e2ec220314e9ae728e
|
… behind its cookie (objectstack-ai#22367) Part of objectstack-ai#22258 Clause-②: yes (widening) Release: the domain:services half of this card stays open, carried by domain:services. Nine in-process readers there (plugin-auth, plugin-webhooks, plugin-sharing, service-storage, service-settings, service-datasource; table H5 below) still renew a cookie session without re-issuing its cookie. ## What this changes An in-process `auth.api.getSession` read renews a session past better-auth's `updateAge` and stages the renewed cookie on a response the door never sends. The browser's cookie then dies before its session: a split session, a dead cookie beside a live bearer. One rule now covers every in-process reader in this lane. It lives in one helper, `inProcessSessionReadInput(headers)` in `@objectstack/types`, and is decided by what the request carries: - **A session cookie** (a browser, including the console, which sends its cookie beside its bearer): the read passes `query: { disableRefresh: true }`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie, so cookie and session expire together. - **No session cookie** (a bearer-only client): read exactly as before, renewal included. No cookie exists to fall behind, and the bearer is the session token, which renewal does not change. The rule only ever adds `disableRefresh`. It sets no cookie, forwards none, and never changes which session a request resolves to. **Applied at all ten readers in this lane.** The census on `b7e01fbbd` found six. Four more use the optional-chained spelling `api?.getSession?.(`, which the `.getSession(` regex did not match: | package | reader (line on this branch) | in the PM census | |---|---|---| | rest | `rest-server.ts:3037` `computeExecCtx` getter | yes | | rest | `rest-server.ts:3224` auth-gate re-read | yes | | runtime | `http-dispatcher.ts:1362` `enforceAuthGate` | yes | | runtime | `http-dispatcher.ts:1442` `enforceProjectMembership` | **no** | | runtime | `security/resolve-session-principal.ts:57` (rate limiter, concrete route mounts) | yes | | runtime | `security/resolve-execution-context.ts:165` (dispatcher scope, MCP door) | **no** | | plugin-hono-server | `current-user-endpoints.ts:412` | yes | | cloud-connection | `marketplace-install-local-plugin.ts:2624` `resolveActiveOrgId` | yes | | cloud-connection | `marketplace-install-local-plugin.ts:2794` `resolveInstallPrincipal` getter | **no** | | cloud-connection | `cloud-connection-plugin.ts:209` session bridge | **no** | The four extra readers are fixed in place under the bounded in-place-fix rule: the same defect class, the same one-line call, no other claim on those files, and the same packages and gates. Their doors split measurably: the MCP door and `/i18n/locales` go through `resolve-execution-context` (H1, ablation 2). This adds two files to the claim's file surface: `packages/runtime/src/security/resolve-execution-context.ts` and `packages/cloud-connection/src/cloud-connection-plugin.ts`. **Where the helper lives, and why there.** The claim suggested a helper in `packages/runtime`. That cannot serve all ten readers: `rest` cannot import `runtime` (runtime depends on rest, so it would be a cycle), and `plugin-hono-server` does not depend on runtime. `@objectstack/types` is in this lane and is already a dependency of all four reader packages, which is the same "one home, no new edge" reasoning that file's barrel records for its other shared rules. ## Why `Part of`, and why `Clause-②: yes` - **`Part of`.** Triage's done-when reads "No framework door extends a session without forwarding its cookie". After this PR, every door in this lane holds (H1). The nine `domain:services` readers still split a session through public doors (H5), so the card stays open for them. - **`Clause-②: yes (widening)`, not the claim's `no`.** The claim's gloss on `no` was "No accepted input, export or published shape changes", written before the helper's home was chosen. The one shared helper has to be an export of a published package, so `@objectstack/types` gains three named exports: `inProcessSessionReadInput`, `carriesSessionCookie` and the `InProcessSessionReadInput` type. That is an additive widening of a published package's public surface, which the `Check Changeset` "WHICH LEVEL" ruling grades at least `minor`. The changeset is therefore `minor` for `@objectstack/types` and `patch` for `rest`, `runtime`, `plugin-hono-server` and `cloud-connection`. Raised with the seat in the dev report; no accepted input and no wire shape changes. ## H1: reproduced through public doors, then measured on the fix The probe is a fresh `pnpm dev:crm -- --fresh` stack, run on `b7e01fbbd` and again on this branch, with better-auth 1.7.3 as pinned. `expiresIn` (604800 s) is read from the fresh `sys_session` row; the pin reads both values off the running instance's `sessionConfig`, and `updateAge` is 86400 s. Each row ages the signed-in session in `sys_session` to `now + expiresIn − updateAge − 60 s`, sends one request, and reads `expires_at` back. | door | by | before: Δ `expires_at` · session `Set-Cookie` | after | |---|---|---|---| | `GET /api/v1/auth/get-session` (control) | cookie | +86460 s · `Max-Age=604800` | +86460 s · `Max-Age=604800` | | `GET /api/v1/data/sys_user` | cookie | +86460 s · none | **0 · none** | | `GET /api/v1/data/sys_user` | bearer | +86460 s · none | +86460 s · none | | `GET /api/v1/auth/me/permissions` | cookie | +86460 s · none | **0 · none** | | `GET /api/v1/auth/me/permissions` | bearer | +86460 s · none | +86460 s · none | | `GET /api/v1/meta/object` | cookie | +86460 s · none | **0 · none** | | `GET /api/v1/i18n/locales` | cookie | +86460 s · none | **0 · none** | | `GET /api/v1/packages` | cookie | +86460 s · none | **0 · none** | | `GET /api/v1/marketplace/install-local` | cookie | +86460 s · none | **0 · none** | | `GET /api/v1/mcp` (answers 406) | cookie | +86460 s · none | **0 · none** | Every door's bearer row is unchanged by the fix (+86460 s, no cookie); only the first two doors are shown here. ## H3: who holds only a bearer These clients would lose renewal under a blanket `disableRefresh`. Measured by reading where each one sends its credential and when it reaches `get-session`: - **`@objectstack/client`** sends `Authorization: Bearer` from its stored token on every request (`fetch`). It calls `get-session` only on an explicit `auth.me()` or `refreshToken()`. Outside a browser it holds no cookie jar, so it is bearer-only. - **The CLI** is bearer-only. It reaches `get-session` only at `os login` and `os cloud whoami`. Its data commands (`datasource list-tables`, `validate`, `introspect`, `package publish`, `plugin publish`) carry a bearer. - **MCP:** OAuth access tokens are verified separately and are not better-auth sessions, so they are unaffected. API keys are unaffected too. A session bearer presented there is bearer-only. - **objectui console:** NOT bearer-only. Its fetches send the cookie (`credentials: 'include'`) beside the stored bearer, and it calls `get-session` on mount and on every re-resolution (`AuthProvider.loadSession`). **Before:** every bearer-only data read past `updateAge` renewed, +86460 s on every door above. A blanket `disableRefresh` would end that, and a CLI or SDK session would die `expiresIn` (7 days) after sign-in however active. **After:** bearer-only reads still renew, +86460 s on every door (measured above, and pinned). ## H4: the rule, chosen on that measurement - **Forward the renewed `Set-Cookie` from every door: measured and not taken.** - Only three of the ten readers have a response in hand: the Hono `me/*` endpoints and two cloud-connection routes, all on a Hono `c`. - REST's `computeExecCtx(environmentId, req)` has no response object and is cached per request across many routes. - The dispatcher is transport-neutral and returns `{ status, body }`. A forward there would need a header channel through every dispatcher result and every adapter's `sendResult`. - The rate limiter reads before the route. - A request can pass two or three in-process reads (REST: 2, dispatcher: up to 3), and only the first renews. - A forward would still need this same cookie test, so that no cookie is ever set on a request that sent none. - So forwarding cannot be one rule for all ten. - **Taken: the PM's lean, unchanged.** A cookie request reads with `disableRefresh`; a bearer-only request reads as before. better-auth applies the same rule to its own reads that cannot write a cookie (React Server Components, `dist/integrations/next-js.mjs:62-69`). ## H5: the `domain:services` readers, measured and not edited Measured on this branch's build, so a remaining renewal belongs to the services reader and not to a lane reader that ran on the same request. Line numbers are at `b7e01fbbd`. | reader | door | by cookie | by bearer | |---|---|---|---| | plugin-auth `auth-plugin.ts:2464` | `POST /api/v1/auth/admin/oauth2/toggle-disabled` | +86460 s · no cookie (**split**) | +86460 s | | plugin-auth `auth-plugin.ts:2527` (`gateAdmin`, every admin route behind it) | `POST /api/v1/auth/admin/sso/register` | +86460 s · no cookie (**split**) | +86460 s | | plugin-auth `auth-plugin.ts:2594` | `POST /api/v1/auth/admin/unlock-user` | +86460 s · no cookie (**split**) | +86460 s | | plugin-auth `auth-plugin.ts:2912` | `POST /api/v1/auth/admin/has-permission` | +86460 s · no cookie (**split**) | +86460 s | | plugin-webhooks `webhook-outbox-plugin.ts:482` | `POST /api/v1/webhooks/redeliver` (showcase stack) | +86460 s · no cookie (**split**) | +86460 s | | service-storage `storage-service-plugin.ts:843` | `GET /api/v1/storage/upload/chunked/:id/progress` | +86460 s · no cookie (**split**) | +86460 s | | plugin-sharing `sharing-plugin.ts:940` (not in the PM census) | `DELETE /api/v1/share-links/:id` | +86460 s · no cookie (**split**) | +86460 s | | service-settings `settings-service-plugin.ts:299` (not in the PM census) | `GET /api/settings` | +86460 s · no cookie (**split**) | +86460 s | | service-datasource `admin-routes.ts:212` (not in the PM census) | `GET /api/v1/datasources/drivers` | +86460 s · no cookie (**split**) | +86460 s | **The fix there is the same rule:** `api.getSession(inProcessSessionReadInput(headers))`. Five of the six packages already depend on `@objectstack/types`; `plugin-webhooks` would gain that one dependency. ## Pins, and the tier each runs in All pins run in each package's `local` vitest project (CI: `Test Core`). The runtime pin boots an in-process `ObjectKernel`, with no spawned process and no driver socket. - `packages/types/src/in-process-session-read.test.ts`: the cookie test across every spelling better-auth writes (default and custom `cookiePrefix`, `__Secure-`). Also: other cookies, empty values, plain header records, and bearer-only. The input builder passes the same headers object through and adds `query` only for a cookie. - `packages/runtime/src/in-process-session-renewal.pin.test.ts`, against real better-auth: - It reads `expiresIn` and `updateAge` off the running instance. A precondition proves the fixture renews: a bare in-process read without the rule moves `expires_at`. - Three doors, each by cookie and by bearer: `GET /data/:object` (rest), `GET /auth/me/permissions` (hono) and `GET /i18n/locales` (dispatcher `resolveExecutionContext`). - Pinned for each: a cookie request leaves cookie and session expiry aligned (no renewal, no cookie). A bearer-only request still renews to `now + expiresIn`, and no cookie is set on its response. - The rate limiter's reader (`resolveSessionPrincipalId`) by cookie and by bearer. After the limiter's read, `get-session` still renews AND re-issues the cookie. - Control: `get-session` renews and re-issues with `Max-Age = expiresIn`. - `packages/rest/src/in-process-session-read.pin.test.ts`, `packages/plugins/plugin-hono-server/src/in-process-session-read.pin.test.ts`, `packages/cloud-connection/src/in-process-session-read.pin.test.ts`: every remaining reader hands better-auth the rule's input. The cases are cookie, cookie plus bearer, and bearer-only. Covered: REST's getter and gate re-read; the Hono resolver; the cloud-connection session bridge, `resolveActiveOrgId` and `resolveInstallPrincipal`. - `packages/rest/src/execctx-authz-input-seam-reachability.test.ts`: its source-text pin on the auth-gate re-read now reads the new argument. Its intent is unchanged: still the raw, throwing api call. ## Ablation, run twice: drop the rule from one reader Both runs used `scripts/ablation-replace.mjs` in wrap mode, inside a script with an absolute-path restore trap. In both suites the mutated reader resolves from `src` (rest: a relative import; runtime: the `@objectstack/rest` alias and a relative import), so no `dist` leg applies. 1. **rest `computeExecCtx` getter** reverted to `api.getSession({ headers: h })`. The anchor went 1 → 0 and the blob `89fae0b5e5f5` → `677bb44cb288`. - Runtime pin: **1 failed | 10 passed**. Exactly `GET /data/:object — by cookie` went red: "the session renewed (+86460 s) but its cookie was not re-issued". - rest pin: **2 failed | 1 passed** (both cookie cases red; bearer-only green). - Restored: blob == HEAD `89fae0b5e5f5`, and `git diff HEAD` is empty. 2. **runtime `resolve-execution-context` getter** reverted the same way. The blob went `c570e6e4cd84` → `2e86b8e71527`. - Runtime pin: **1 failed | 10 passed**. Exactly `GET /i18n/locales — by cookie` went red. - Restored: blob == HEAD `c570e6e4cd84`, and the diff is empty. ## Verification All at HEAD `8200f5778`, the last commit on this branch: - **Unit tests** (`pnpm --filter PKG test`, each package's `local` project): - `types`: 25 files, 749 passed. - `rest`: 264 files, 4954 passed, 326 skipped. - `runtime`: 341 files, 4787 passed, 19 skipped. - `plugin-hono-server`: 28 files, 329 passed. - `cloud-connection`: 42 files, 514 passed. - `test:repo`: `types` 11, `rest` 191 (1 skipped), `runtime` 751, all passed. - **Typecheck** for the five packages: 41 of 41 turbo tasks green. Each test layer compiles; runtime is at its existing ledger (27 files, 190 errors), unchanged. - **Gates**: the 67 commands that `node scripts/pm/dispatch-gates.mjs --commands` derives for this diff. That is the order's 61 plus `check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. All exit 0. The `--ran` reconciliation reads 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN. - **Lint**: the full `pnpm lint` (`eslint . --no-inline-config`) exits 0. - **Not merged with `origin/main`**, which is two commits ahead (objectstack-ai#22351 cli, objectstack-ai#22352 plugin-security and plugin-auth). Neither touches a file here, and CI tests the merge ref. ## Acceptance notes - **Residue the server cannot see.** Some browser requests carry the bearer without the cookie (a cross-origin fetch without credentials, or a blocked cookie). Those read as bearer-only and still renew in-process. If the same browser sends that session's cookie on other requests, that cookie can still fall behind. The rule decides from what each request carries. - **A behaviour change for a tab that never calls `get-session`.** Such a tab now signs out at the session's real expiry, instead of keeping a live bearer beside a dead cookie. The console calls `get-session` on mount and on each re-resolution. - **Declaration drift, for `domain:spec` (not edited here).** `AuthSessionApi` in `packages/spec/src/contracts/auth-service.ts` declares `getSession`'s input as `{ headers }`. Its doc says every reader "calls exactly `getSession({ headers })`", which is no longer true. The helper declares the wider slice it passes (`query.disableRefresh`) itself; that type-checks because the extra key reaches a non-fresh object. Carrier: the `domain:spec` seat. - **Vendor behaviour, unchanged here.** better-auth's own `get-session` re-issues the session cookie on a bearer-only request too (measured: `Max-Age=604800` with a bearer). That route is better-auth's, and this PR does not touch it. - **Overlap.** Open PR objectstack-ai#22357 edits `packages/runtime/src/http-dispatcher.ts` in two comment hunks (around lines 1241 and 1508), disjoint from this PR's hunks (around 1344-1362 and 1420-1442). - **Unread.** The cloud measurement the card cites (`objectstack-ai/cloud` issue 2699's report and PR 2708) was not readable from this session. H1 re-measured the defect independently on this repo's doors. --- _Generated by [Claude Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ince ADR-0096 D5 (objectstack-ai#22382) Fixes objectstack-ai#22372 Clause-②: no The published `objectstack-query` skill taught `{ flowRunId }` "for provenance alone" as a valid execution context. Since ADR-0096 D5 strict mode (PR objectstack-ai#22297) the security plugin refuses a non-system context that carries no principal with `403 PERMISSION_DENIED`, so an AI author following that line wrote a context the engine refuses. This PR rewrites that one paragraph in place so it names the two shapes the engine admits and the refusal otherwise, and records the `skills/**` enumeration the card asked for. ## The paragraph Before (`skills/objectstack-query/SKILL.md:65-69` on `main` at `16096e8d7`): ``` Pass any SUBSET of the execution envelope (identity, tenant, transaction): `{ isSystem: true }` for a system read, `{ flowRunId }` for provenance alone. On the READ methods it may sit in the query bag (above) OR in the trailing options argument, `engine.find(obj, query, { context })`; the trailing one wins when both are given. Writes take only the trailing argument. ``` After (`:65-70`): ``` Pass any SUBSET of the execution envelope (identity, tenant, transaction): `{ isSystem: true }` for a system read, otherwise the caller's own context (user, position or permission set), or `403 PERMISSION_DENIED` (ADR-0096 D5). On the READ methods it may sit in the query bag (above) OR in the trailing options argument, `engine.find(obj, query, { context })`; the trailing one wins when both are given. Writes take only the trailing argument. ``` Lines 68-70 are the original 67-69 re-wrapped, text unchanged. ## The contract the sentence follows — `main` at `16096e8d7`, verbatim at each site - `packages/plugins/plugin-security/src/security-plugin.ts:383-387`, `isPrincipalLessContext`: `positions.length === 0 && explicitPermissionSets.length === 0 && !context?.userId`. That is the "(user, position or permission set)" gloss. - `:398-404`, `principalLessDenial`, the refusal text: "was called with a context that carries no principal (no user, no position, no permission set) and is not a system context. Pass the caller's execution context, or, for platform plumbing whose own door already authorized the caller, the explicit system opt-in (isSystem: true)." — `PermissionDeniedError`, `403 PERMISSION_DENIED`. - `:349-367`, the predicate's docblock: "A non-system context of this class is REFUSED, at every layer that used to hand it through: the engine middleware throws principalLessDenial before it resolves anything, the object-admission probes (`canReadObject` / `canWriteObject` / `canExport`) answer `false`, and its row scope is the deny sentinel (`getReadFilter`)." and "The two ways to reach the engine are explicit, never a missing field: carry the caller's principal, or … the explicit system opt-in (`isSystem: true`)." - `docs/adr/0096-execution-surface-identity-admission.md`, the D5 note dated 2026-10-08: "An engine context that carries no principal (no user, no position, no permission set) and is not a system context is refused with `PermissionDeniedError` (`403 PERMISSION_DENIED`) wherever the security plugin used to hand it through". **Wording and PR objectstack-ai#22327.** PR objectstack-ai#22327 (card objectstack-ai#22302) is still an open draft as of this PR (read via REST: `state: open`, `draft: true`; it edits `packages/spec/src/contracts/security-service.ts`, `packages/spec/src/kernel/execution-context.zod.ts`, `packages/spec/src/data/data-engine.zod.ts`, `content/docs/kernel/contracts/data-engine.mdx` and `content/docs/permissions/access-recipes.mdx`). So the sentence here follows the D5 contract as it stands on `main` — the plugin's refusal text and predicate above — not that PR's draft text; the two agree on substance (principal or `isSystem: true`, else `403 PERMISSION_DENIED`, ADR-0096 D5). On `main` the old sentence still stands at `execution-context.zod.ts:335-336` and `:501`, `data-engine.zod.ts:67-70` and `data-engine.mdx:127-128`; those are objectstack-ai#22327's files and are not touched here. ## Enumeration pin — `git grep -n -i` over `skills/**` on `main` at `16096e8d7` **Class 1, a `{ flowRunId }`-only context.** `flowRunId`: 1 hit, `objectstack-query/SKILL.md:66`, fixed here. `provenance`: 1 hit, the same line. `runId` / `run id`: 2 hits, the same line plus `objectstack-automation/references/state-machines-and-approvals.md:208`, a `:runId` URL path parameter, not a context. **Class 2, a principal-less context that is admitted, keeps its scope or falls open.** Zero hits for each of: `no principal`, `without a principal`, `principal-less`, `principalless`, `anonymous context`, `context: {}` (fixed-string, and the regex `context:\s*\{\s*\}`), `empty context`, `fall open`, `falls open`, `fall-open`, `fail open`, `fail-open`, `hand(ed|s)? (it )?through`, `keeps its scope`, `skips? (the )?(permission |security )?checks`, `no identity`, `without identity`, `resolves no identity`, `without (a )?context`, `contextless`, `no context`, `RLS-on`, `sees-nothing`, `SYSTEM_CTX`, `passes only`. Non-zero query words, each hit read and dispositioned: - `unauthenticated` (4): `objectstack-api/SKILL.md:162` — `authRequired: false` opens an anonymous HTTP entry point (ADR-0121 D6 pairing); that is the door's authentication, not an engine context. The public-form endpoints at `:87-88` run under a synthetic `{ permissions: ['guest_portal'], anonymous: true }` context, which carries a named permission set and so is not principal-less under the predicate. `objectstack-data/references/data-hooks.md:361`, `:602`, `:629` — `ctx.user` is `undefined` for system / unauthenticated writes: the ctx shape, no admission claim. - `no user` (2): `objectstack-automation/SKILL.md:192-194` — a `'user'` hook whose trigger resolved no user has its `ctx.api` refused (`HOOK_UNSCOPED_DATA_ACCESS`, 403) "rather than run unscoped": fail-closed, consistent with D5. `data-hooks.md:930` — "system operations carry no user", a system context. - `context-less` (1): `objectstack-ui/rules/actions.md:127` — `ctx.user` is `undefined` for a context-less / self-invoked call (the `ScopedRepo.execute()` path, `packages/runtime/src/sandbox/body-runner.ts:1188-1198` says that path carries no caller identity). It describes `ctx.user`; it does not say the engine admits such a context. Left alone. - `carries no` (5), `sees nothing` (1), `anonymous` (11), `bypass` (7), `elevat` (12), `runAs` (20), `system context` (3), `resolve[sd] no` (3), `no resolvable` (1), `unscoped` (4): every hit is either an explicit elevation the engine admits (`isSystem`, `runAs: 'system'`: `objectstack-automation/SKILL.md:183`, `references/examples-flows.md:23`, `:88` teach `runAs: 'system'` for a run with no trigger user, which is the D5-correct prescription) or a different subject (anonymous records, sharing `bypass`, public forms, a search axis, a time dimension). Control: `isSystem` hits 3 files (`data-hooks.md`, `objectstack-query/SKILL.md`, `objectstack-query/evals/filters-pagination-search.json`), matching the seat's reading. No hit lands in `skills/objectstack-formula/SKILL.md` (PR objectstack-ai#22347) or `skills/objectstack-ui/references/react-blocks.md` (PR objectstack-ai#22322); neither file is touched. ## Evals `skills/objectstack-query/evals/filters-pagination-search.json`: the 2 `isSystem` hits are both in case `id: 5`, whose `expected_output` prescribes `context: { isSystem: true }` and whose `must_contain` is `["context", "isSystem: true", "limit: 1"]`. `flowRunId` / `provenance` / `envelope`: 0 hits across `evals/`. The old line is not asserted; the eval is unchanged and stays true. ## Budget — net-line budget 0, cap +1: spent +1 | reading | before (`16096e8d7`) | after (`f4e5170b`) | |---|---|---| | `skills/objectstack-query/SKILL.md`, lines | 400 | 401 (+1) | | whole package, all `skills/*/SKILL.md`, lines | 4409 | 4410 (+1) | | `SKILL.md` tokens, `ceil(bytes/4)` (ceiling 5552) | 4109 | 4128 (headroom 1424) | Why not 0: the replaced clause (`{ flowRunId }` for provenance alone) was 37 characters; the replacement that states the admitted shape, the refusal code and the ADR is 112. A 0-net fit required deleting content — the envelope's "(identity, tenant, transaction)" or the principal gloss — and re-wrap is not a currency, so the one line the cap allows was spent instead. `scripts/pm/check-skill-line-ratchet.mjs` does not cover the published root (its header says so); the token ratchet is the binding one and it is green with headroom. ## Changeset `skills/**` is in no released package's `files[]`: no `package.json` under `packages/` names a `skills` path and none carries an entry that escapes its own directory (both measured over every `packages/**/package.json`); the catalog reaches customers through `npx skills add objectstack-ai/objectstack/skills` from this repository, which `packages/create-objectstack` invokes at scaffold time rather than bundling (`src/created-summary.ts:31`). Positive control: the same old sentence in `packages/spec/src/kernel/execution-context.zod.ts:501` IS inside spec's `files[]` (`src/**/*.zod.ts`), which is why objectstack-ai#22327 carries a changeset and this PR does not. No `.changeset/*.md`; `skip-changeset` is the repo's skip form. ## Gates — merge base `16096e8d7`, final commit `f4e5170b` `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 24 families from the worktree change set (1 path); each ran with its exit code captured before any pipe; `--ran` reconciliation: "24 derived, 24 run, 0 NOT-MEASURED, 0 UNRUN", with every line carrying its exit code ("a DERIVED zero — all 24 recorded an exit code and none of them is 3"). All 24 exit 0: - `node scripts/check-skills-token-ratchet.mjs` (+ `--self-test`): "skills/objectstack-query/SKILL.md is 4128 tokens (ceiling 5552; headroom 1424)". - `pnpm --filter @objectstack/lint run check:doc-formula-expressions`, after building its prerequisite under the verify lock (`pnpm exec turbo run build --filter=@objectstack/formula --filter=@objectstack/lint`, `VERDICT command-exit 0`, held 105s, waited 0s). - `pnpm --filter @objectstack/spec run check:skill-docs` (reads frontmatter only; unchanged), `pnpm check:doc-authoring`, `pnpm check:skill-identifier-liveness`, `pnpm check:skill-frame-sync`, `pnpm check:skill-compatibility`, `pnpm check:corpus-claim-drift`, `pnpm check:cross-package-test-inputs`, `pnpm check:agent-test-spelling`, `pnpm check:role-word`, `pnpm check:gitlink-declared`, `pnpm check:driver-memory-census`, `pnpm check:refd-timer-probe`, `pnpm check:watch-hint-literal`, `pnpm check:pm-governed-merges`, `pnpm check:nul-bytes`, `node scripts/check-ci-filter-parity.mjs`, `node scripts/check-closing-keyword-parity.mjs` (+ `--self-test`), `node scripts/check-comment-mask-corpus.mjs`, `node scripts/check-doc-route-spelling.mjs --advisory` (+ `--self-test`). Beyond the derivation: `pnpm check:pm-skill-ratchet` exit 0 (the published root is outside its map, as its header states); `pnpm --filter @objectstack/spec run check:skill-refs` exit 0 ("9 generated files in sync", nothing to regenerate); a control-byte scan over the edited file finds none. The 52 artifact-roster families, the 11 declared wide-population families and the type-check lanes the derivation lists outside the derived total are CI's runs on this PR; `pnpm lint` (repo-level eslint) was not run locally — the diff is one Markdown file. ## Acceptance notes - Governed surface, Tier H (`skills/**`): this PR stays draft; landing waits for an authorized approval, and the dispatch names the contract-review tier as mandatory on this path. - Commit identity: this cloud container cannot mint the fleet identity (`OS_FLEET_APP_ID` / `OS_FLEET_PRIVATE_KEY` are unset and the relay hands out no token for `git`), so the one commit carries the worktree's harness identity; every later commit on this branch keeps that same identity. - Observed, not filed (code comments are objectstack-ai#22345's lane, PR objectstack-ai#22357): `packages/runtime/src/sandbox/body-runner.ts:979-984` still says "A caller that has no context to give gets the same identity-less behavior as before", a pre-D5 reading in a code comment. - Historical `CHANGELOG.md` entries ("A run with no principal now passes provenance alone.") are release-owned records of what shipped and are not edited. ## 维护者速读(草稿) - **改了什么:** 已发布的 `objectstack-query` 技能里,"Execution Context" 一节的一段话。原来教"只传 `{ flowRunId }` 做溯源"也是合法的执行上下文;现在改为:系统读传 `{ isSystem: true }`,否则传调用者自己的上下文(带用户、岗位或权限集),两者都没有则引擎拒绝(`403 PERMISSION_DENIED`,ADR-0096 D5)。只改这一段,净增 1 行(预算 0、上限 +1)。 - **为什么改:** ADR-0096 D5 严格模式(PR objectstack-ai#22297)落地后,`plugin-security` 在全部站点拒收"无 principal 且非 system"的上下文。按旧句写出来的上下文会被引擎直接拒绝,而这份技能是通过 `npx skills add` 装进客户项目的,AI 作者先读到它、再撞上 403。同时按卡片要求对全部 `skills/**` 做了枚举:只有这一行教旧读法,其余命中都是 `isSystem`/`runAs:'system'` 这类显式提权或别的主题;评测文件没有断言旧句。 - **风险与代价(含回滚):** 纯文本改动,不碰代码、不发包、无 changeset(`skills/**` 不在任何已发布包的 `files[]` 内)。措辞按 `main` 上的 D5 契约原文写;PR objectstack-ai#22327 仍是 draft,它落地后两边说法一致。回滚即还原这一个文件的一次提交。 - **席位意见:** - **你要做的:** 看一眼第 65-70 行这一段表述是否认可,认可就给一个批准。 --- _Generated by [Claude Code](https://claude.ai/code/session_01JmWtcHfGbC4ncw4GFKWuRA)_ Co-authored-by: Claude <noreply@anthropic.com>
…g texts hold on both kernels (objectstack-ai#22390) Fixes objectstack-ai#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 objectstack-ai#22302, comments and docstrings were objectstack-ai#22345 (PR objectstack-ai#22357), and the skills surface is objectstack-ai#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 objectstack-ai#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. objectstack-ai#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 objectstack-ai#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 objectstack-ai#22372's surface. Taker: none. - **Possible overlap.** objectstack-ai#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>
Fixes #22345
Clause-②: no (narrowing)
domain:servicesseat 1, branchclaude/issue-22345-pre-d5-reading-pass, dispatched under the claim6068302217, executing triage6068146867. The pre-D5 family'spackages/spechalf is #22302 / PR #22327 and is not touched here.What this does
RunProvenanceContextfrom@objectstack/service-automation: the interface, its arm ofRunDataContext, the type export insrc/index.tsand the README export list.RunDataContextis nowinterface RunDataContext extends RunIdentityContext {}, which is exactly the set of shapesresolveRunDataContextreturns. Only type declarations change.packages/spec. 24 comment or docstring sites in 11 packages said, in the present tense, that a principal-less or{ flowRunId }-only context falls open, is handed through, or is skipped by the data security middleware. Each now says that the middleware used to do this, and that ADR-0096 D5 refuses such a context. These edits are comment text only.scripts/js-comment-mask.mjsstripCommentsand whitespace was collapsed. After that, 18 of the 20 changed.tsfiles are byte-identical to base35afb15878. The other two areruntime-identity.tsandindex.ts. They differ only by the removedinterface RunProvenanceContext, theRunDataContextdeclaration, and the dropped name in the type export. The class body between those two declarations is byte-identical.H1: producers and readers (base
35afb15878)git grep -n RunProvenanceContextfinds 4 hits:runtime-identity.ts:75(the declaration),:124(the union),index.ts:195(the type export) andREADME.md:451(the export list). There is no other reference inpackages/**,apps/**,examples/**orpackages/qa/**. The pinned siblingobjectuiata58626c88dhas none either:git grep -E 'RunProvenanceContext|RunDataContext'exits 1, and in the same shallow fetchservice-automationmatches 14 files as the control.RunDataContextby name:runtime-identity.ts:124(the declaration),:178(the return type ofresolveRunDataContext),:275(a parameter ofstampSystemInsertOwner),index.ts:195andREADME.md:450. It has one reader outside the package:plugin-approvals/test-typecheck-debt.json:43. That is a ledgered TS2352 onrecord-lock-schedule-run.integration.test.ts:150, which castsresolveRunDataContext(...)to a record because the union's provenance arm had noisSystem.No production code builds an engine context that carries
flowRunIdand no principal. Every data node resolves its context throughresolveRunDataContext(crud-nodes.ts:484/572/720/812,plugin.ts:1248).engine.ts:6079stampsflowRunIdon the run'sAutomationContext, which is not an engine context. Only tests build such a context:objectql/src/engine.test.ts:601(a bare engine; it pins hook provenance);plugin-security/src/delegated-admin-gate.test.ts:137andsystem-write-guard.test.ts:94(gate units);principal-less-strict-mode.test.ts:135(refused by D5).The
provenance: { flowRunId }fixtures with no session in the plugin-approvals, plugin-audit and service-storage tests areHookContextshapes. None of these names the type.The docstring said the type "survives for the non-data provenance uses that motivated Approval: a schedule-triggered run still can't write its own locked record — it carries no ObjectQL context to hold
flowRunId(#3456 residual) #3712". None was found. The Approval: a schedule-triggered run still can't write its own locked record — it carries no ObjectQL context to holdflowRunId(#3456 residual) #3712 use is the approvals record lock. It readsHookContext.provenance.flowRunId(plugin-approvals/src/lifecycle-hooks.ts:489), which objectql'sbuildProvenancebuilds from anyExecutionContext, andRunIdentityContextalready carriesflowRunId.Result: the retirement condition holds, and the type is retired.
H2: what D5 does today (on
main)plugin-security/src/security-plugin.ts:2451:if (opCtx.context?.isSystem) return next(). This system short-circuit runs first.:2654-:2656:if (isPrincipalLessContext(opCtx.context)) throw principalLessDenial(...)throwsPermissionDeniedError,403 PERMISSION_DENIED, for every verb. It runs after the package-managed, system-row, curated-capability, audience-anchor, engine-owned-write and delegated-admin gates. The predicate is at:383-:387. The probes agree with it:getReadFilterreturns the deny filter (:5947), andcanReadObject(:6384) andcanExport(:6778) answerfalse. Landed ina3bcbcf3ca(PR feat(plugin-security)!: refuse a principal-less, non-system data-engine context (ADR-0096 D5 strict mode) #22297), andgit merge-base --is-ancestor a3bcbcf3ca origin/mainexits 0.resolveRunDataContext(service-automation/src/runtime-identity.ts) returns, byrunAs:runAs: 'system':{ isSystem: true, actor: 'svc:flow:NAME', userId?, tenantId?, positions: [], permissions: [], flowRunId? };runAs: 'user'with a user:{ isSystem: false, userId, positions, permissions, tenantId?, flowRunId? };UnscopedRunDataAccessError(AUTOMATION_UNSCOPED_RUN_DATA_ACCESS).H3: the enumeration (the pin)
Why there is no test file. The repo's closest precedents pin a relation or a structure, never wording.
rest-server-docblock-position.test.tssays so: "Wording is not pinned here on purpose: nothing parses these sentences". Nothing parses these comments either. So the pin is this command and its output, re-runnable on any tree:It gives 82 lines at base
35afb15878and 76 at headf3a9675f4b. The grep is line-based, so the sweep also paired subject and claim terms across 8-line windows to catch sentences split over lines. That window sweep found the fixed sites below that the grep alone misses.Fixed in this PR: current tense and false now (base positions)
service-automation/src/runtime-identity.ts:56-74RunProvenanceContext: "the empty-principal fall-open … indistinguishable from passing no context at all" (retired with the type)service-automation/src/runtime-identity.ts:85-86UnscopedRunDataAccessError: "the data security middleware skips when there is no principal"service-automation/src/runtime-identity.ts:220-223service-automation/src/runtime-identity.ts:333-336runIsUnscopedUserMode: "a principal-less context that the security middleware would wave straight through"service-automation/src/engine.ts:6057-6058resolveRunContext: "the data security middleware skips when there is no identity"service-automation/src/builtin/crud-runas.test.ts:219-220service-automation/src/builtin/crud-runas.test.ts:258-259service-analytics/src/strategies/objectql-strategy.ts:410-411plugin-security/src/security-plugin.ts:6061-6065getMetadataReadableFields: "mirrors the engine middleware, which skips its grant-based gates for a caller with no permission sets"plugin-security/src/get-metadata-readable-fields.test.ts:8-10, :84platform-objects/src/system/sys-secret.object.ts:50-51metadata-protocol/src/protocol.ts:11233-11237return next()"metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts:7-9next()"plugin-auth/src/auth-plugin.ts:2480-2482plugin-auth/src/auth-manager.ts:3384-3386plugin-auth/src/scim-connection-service.ts:121-122plugin-auth/src/principal-less-producers-system-context.test.ts:13-15runtime/src/http-dispatcher.ts:1244-1246{ ...caller, isSystem: true }since #3914)runtime/src/http-dispatcher.ts:1510-1511runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts:551-556trigger-record-change/src/record-change-integration.test.ts:395-396qa/dogfood/test/flow-runas-schedule.dogfood.test.ts:10-12qa/dogfood/test/declarative-endpoint-anonymous-guest.dogfood.test.ts:15-17examples/app-showcase/src/automation/flows/index.ts:1584-1586The pin's 76 lines at head
f3a9675f4b, each with its classexamples/…/flows/index.ts:1586,protocol.platform-store-system-opt-in.test.ts:8, :9,protocol.ts:11237,auth-plugin.ts:2481,principal-less-producers-system-context.test.ts:14,scim-connection-service.ts:122,get-metadata-readable-fields.test.ts:9,security-plugin.ts:6062,declarative-endpoint-anonymous-guest.dogfood.test.ts:17,dispatcher-plugin.endpoint-fallback.integration.test.ts:552,http-dispatcher.ts:1514,crud-runas.test.ts:220,engine.ts:6058,runtime-identity.ts:206..changeset/21908-by-id-producers-opt-in.md:6,.changeset/21908-principal-less-producers-final.md:8,.changeset/21908-principal-less-strict-mode.md:20andcontent/docs/releases/v17/17-0.mdx:302.docs/adr/0096-…:11, :38, :156anddocs/adr/0138-…:111, :635.auth-manager.org-slug-guard-system-context.test.ts:10,http-dispatcher.membership-system-context.test.ts:10,principal-less-strict-mode.test.ts:9,security-plugin.ts:359, :2650, :6212,zero-set-capability-fold.test.ts:40,zero-set-masking.test.ts:37,webhook-system-context.pin.test.ts:11,datasource-system-context.pin.test.ts:16,inbox-system-context.ts:16,sql-http-outbox.ts:473,system-context.pin.test.ts:18(service-messaging),settings-system-context.pin.test.ts:11,metadata-store.ts:143, :174, :196, :197, :198andowner-of-private-object-under-strict-mode.dogfood.test.ts:8.analytics-rls.dogfood.test.ts:9, :163,flow-runas-fixture.ts:27,flow-runas.dogfood.test.ts:30,execution-context-bridge.test.ts:16,service-analytics/src/plugin.ts:427andobjectql-strategy.ts:852.auto-enqueuer.ts:30,redeliver-guard.ts:70,datasource-admin-plugin.ts:104,datasource-secret-binder.ts:40,fan-out-system-context.ts:25,outbox-dispatcher-scope.ts:94, :118, :135,settings-service-plugin.ts:414, :538andsettings-service.ts:104.public-form-grant-masking.test.ts:33, :233("never the principal-less hand-off").tenant-audit-update-delete-half-repairs.test.ts:798..changeset/22345-run-provenance-context-retired.md:17.dispatcher-plugin.endpoint-fallback.integration.test.ts:571andrunas-grant-resolution.integration.test.ts:102.export-permission-axis.test.ts:143(a test title),rest/src/rest-server.ts:2640andruntime/src/domains/automation.ts:295. These describe an AUTHENTICATED caller with zero permission sets, not a principal-less one. See the Acceptance notes.docs/adr/0111-…:126(the sharing middleware skippingsys_record_share),better-auth-schema-parity.test.ts:13,can-write-object-admission.test.ts:576andsecurity-plugin.ts:6521(step 2.5 with no payload), andtext-match-sql.ts:237.H4:
objectql/src/engine.ts(domain:engine), not changedThe sentence is now at
:5466-:5469, after PR #22337 landed: "A context carrying only write PROVENANCE ({ flowRunId }, all an identity-less flow run has — #3712) is such a case: it says what produced the write, not who is calling, and surfaces through {@link buildProvenance} instead."Reading: the sentence does not assert the pre-D5 behaviour. It says nothing about the security middleware admitting or skipping that context. It describes
buildSessionreturning no session for it, and that is still what the code does. So it is not changed, and this diff contains no objectql file.Its parenthetical producer claim, "all an identity-less flow run has", is a different staleness. It has been false since #3760: a user-less
runAs: 'user'run never reaches the engine, and arunAs: 'system'one carriesisSystemandactor. It is listed in the Acceptance notes with its family.H5: retirement, dependents and reverse verification
Why an interface rather than a type alias. This was probed with tsc 6.0.3. The alias
type RunDataContext = RunIdentityContextprints asRunIdentityContext | undefinedin diagnostics. That re-spells plugin-approvals' ledgered TS2352 signature ('RunDataContext | undefined') and turns itscheck:test-typecheckred (one ARRIVED, one VANISHED). The interface keeps the name, so no consumer ledger moves.Dependents typecheck.
turbo run typecheck --filter="...^@objectstack/service-automation"covers all 18 dependents. The six other published packages this diff touches were added with explicit--filters. That is 24 packages, and all 24 declare atypecheckscript. Result:Tasks: 89 successful, 89 total, 36 cached, atc19dde3b5c, before the merge ofmain.Reverse verification. A probe file went into
plugin-approvals/src. That package resolves@objectstack/service-automationthroughexportstodist/index.d.ts, rebuilt from this branch. tsc reported:TS2305: Module '"@objectstack/service-automation"' has no exported member 'RunProvenanceContext';TS2739: Type '{ flowRunId: string; }' is missing the following properties from type 'RunDataContext': isSystem, positions, permissions.The probe was removed by a trap, and
git status --porcelainprinted nothing afterwards.Changeset
.changeset/22345-run-provenance-context-retired.mdgrades@objectstack/service-automationasminor: BREAKING, an accept-set narrowing of one type and one removed type export, with the!banner. It also grades four packages aspatchfor comment text only. Their edited comment text ships, as measured in the builtdist:plugin-securitydist/index.jsanddist/index.d.tsruntimedist/index.jsanddist/index.d.tsservice-analyticsdist/index.jsplatform-objectsdist/index.jsThe edited comments of
plugin-authandmetadata-protocoldo not ship. As a control, the code tokens next to them are present in the bundle (withSystemContext(rawEngine)2,CREDENTIAL_PROBE_CONTEXT3,sys_metadata_audit8).ADR-0087 disposition:
not-required (runtime-interface-only packages/services/service-automation/src/runtime-identity.ts#RunDataContext). The gate verified it: "verified: …#RunDataContext (interface)".Cross-lane paths (comment-only, declared)
plugin-security,plugin-auth,runtime,metadata-protocol,platform-objects,service-analyticsandtrigger-record-change: rows 8-21 of the table.packages/qa/dogfoodandexamples/app-showcase, both private: rows 22-24.objectql: read under H4 and not changed.Verification (head
f3a9675f4b= this branch merged withmainat54c3ce10ce; PR #22337 landed meanwhile; no conflict)pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2:Test Files 177 passed (177),Tests 2171 passed (2171). Before the merge, atc19dde3b5c, it was 176 / 2163.pnpm --filter @objectstack/service-automation typecheck:check:test-typecheck: OK … 0 file(s) / 0 error(s).The edited test files, one run each, all passed:
get-metadata-readable-fields.test.tsprotocol.platform-store-system-opt-in.test.tsprincipal-less-producers-system-context.test.tsdispatcher-plugin.endpoint-fallback.integration.test.tsrecord-change-integration.test.tsAfter the merge,
turbo run build --filter=!@objectstack/docsgave72 successful, 72 total.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 79 commands from 22 paths atf3a9675f4b, and all 79 exited 0.--ranwith recorded exit codes: "79 derived famil(ies) accounted for — 79 run, 0 NOT-MEASURED (a DERIVED zero …)". Some of the gates' own verdict lines:check-adr-0087-registration: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition";check-system-context-census: "OK — 118 elevation read sites in 20 packages";check:nul-bytes: "OK (scanned 10334 text file(s) … no raw ASCII control bytes)";check:dual-build-cjs-loads: "106 published require entry point(s) across 66 package(s) load";check:published-files,check:dts-closure,check:test-source-alias,check:cross-package-test-inputsandcheck-issue-citations: green.ESLint on the 20 changed
.tsfiles:errors=0 warnings=0, with 20 files counted from--format json. That run was narrowed to the changed files, which holds only because no file's lint result depends on another file: the population is the config's**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}block, andeslint.config.mjs:327-328states that the config "never enables type-aware linting (noparserOptions.project…)". The full lint is CI's.Declared narrowings:
service-automationexport, and CI's TypeScript Type Check runs them all.Acceptance notes
rest/src/rest-server.ts:2639-2642,runtime/src/domains/automation.ts:295-303("this surface refuses where/datafalls open"),plugin-security/src/export-permission-axis.test.ts:143-146(a title and a comment) andplugin-security/src/baseline-composition.test.ts:157-159. Not a principal-less reading, so left as they are. Taker: none.flowRunId(#3456 residual) #3712 producer premise, false since The #1888 user-less fail-open is wider than the lint that guards it — record-change flows fired by a system write run UNSCOPED, unlinted #3760, with no admission claim. These sites say that a schedule-triggered run reaches the data layer as{ flowRunId }with no session:objectql/src/engine.ts:5467and:5524,plugin-security/src/delegated-admin-gate.test.ts:133-135,system-write-guard.test.ts:89-91,plugin-audit/src/comment-access-hooks.test.ts:186,service-storage/src/attachment-access-hooks.test.ts:147-151,plugin-approvals/src/approval-service.test.ts:2239-2242andlifecycle-hooks.ts:484-488. The units they introduce still test real hook and gate behaviour for that shape. Taker: none.UnscopedRunDataAccessErrormessage (runtime-identity.ts:109-113) and the run-setup warning (engine.ts:6133-6135) say a principal-less run "would execute UNSCOPED (elevated, RLS-bypassing)". On a kernel with plugin-security, that run is now a 403. Both are pinned:crud-runas.test.ts:238andschedule-runas-e2e.test.ts:122assert/UNSCOPED/. Not changed, because no runtime behaviour moves in this PR. The prescription they give (declarerunAs: 'system') is still right.plugin-security/src/security-plugin.test.ts:2317, :2571, :2674read "(gate is before the fall-open)", andcan-write-object-admission.test.ts:640reads "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-96reads "runs the flow UNSCOPED".service-automation/src/engine.ts:6292, "Surfaces the user-less fail-open (see helper)", names the [P0][security] Flow runAs never switches execution identity #1888 case. It is not a claim about the middleware.objectql-strategy.ts:852andanalytics-rls.dogfood.test.ts:8say "the bridge passes no ExecutionContext". That is a tense slip inside a past-tense paragraph; the bridge has passed the caller's context since analytics: 让 executeAggregate 桥携带 ExecutionContext(#3597 的纵深防御第二层)+ 两处残留无 scope 调用 #3602.runtime/src/http-dispatcher.ts:1241-1254is bound to no declaration, the shaperest-server-docblock-position.test.tsrecords for this file. Its text is corrected here; its position is not moved.getReadableFieldsstill answers the full field set (minus posture fields) for a caller with no permission sets, a caller the middleware now refuses. The docstring now says that, instead of calling it "mirroring". No consumer was measured reaching it with such a context.resolveRunDataContextkeeps its| undefinedreturn type although no path returnsundefined.skills/objectstack-query/SKILL.md:66("{ flowRunId }for provenance alone") is Tier H and stays with the card the PR docs(spec): ISecurityService and the data-engine contract page state the ADR-0096 D5 refusal of a principal-less context #22327 review says is owed.domain:specseat 2) editsservice-automation/src/engine.tsandREADME.mdin other regions.Generated by Claude Code