Repository navigation
docs(spec): ISecurityService and the data-engine contract page state the ADR-0096 D5 refusal of a principal-less context - #22327
Conversation
…the ADR-0096 D5 refusal of a principal-less context
The TSDoc on getReadFilter, canExport and canReadObject said a context that
carries no principal at all keeps its composed scope or is admitted, as the
security middleware hands it through. Since ADR-0096 D5 strict mode that
context gets the deny filter, false, and 403 PERMISSION_DENIED. The
data-engine contract page taught `{ flowRunId }` alone as an admitted
context; it now names the two admitted contexts and the refusal.
Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 139 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 99463d3e807b8a0fdbd016ab3c88cb6f10a34af6 && git checkout 99463d3e807b8a0fdbd016ab3c88cb6f10a34af6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3054516ef1f16f0e5839cb33d13c0c9d5f8ab84f 7c79aade4b1bb4ae31ab3b88f867b7b5039aad93 && git checkout -B drift-repro 3054516ef1f16f0e5839cb33d13c0c9d5f8ab84f && git merge --no-ff 7c79aade4b1bb4ae31ab3b88f867b7b5039aad93
node scripts/docs-audit/affected-docs.mjs --json 3054516ef1f16f0e5839cb33d13c0c9d5f8ab84f
|
|
CI on
Generated by Claude Code |
…curity-service-principal-less-tsdoc
…oc no longer teach a flowRunId-only context as admitted
The three passages said a flow run that resolves no principal passes
exactly { flowRunId }, a context that authorizes like no context and keeps
the unscoped posture. Since ADR-0096 D5 that context is refused with 403
PERMISSION_DENIED, and the automation engine never builds it: a data node
of a user-mode run with no trigger user is refused at resolveRunDataContext,
and a system-mode run passes isSystem: true. access-recipes.mdx said the same
run "runs unscoped"; it is refused.
Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs read: card #22302 (body and all 7 comments, triage ① Derived judgmentsNo accept-set change and no public-surface change. Every hunk in the three Each rewritten sentence, judged against the code on
Sentences the diff leaves in the touched files, re-read: One imprecision, not a defect: the changeset's "The corrected text ships in the published ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…curity-service-principal-less-tsdoc
…al-less readings outside packages/spec say D5 refuses it (objectstack-ai#22357) Fixes objectstack-ai#22345 Clause-②: no (narrowing) `domain:services` seat 1, branch `claude/issue-22345-pre-d5-reading-pass`, dispatched under the claim `6068302217`, executing triage `6068146867`. The pre-D5 family's `packages/spec` half is objectstack-ai#22302 / PR objectstack-ai#22327 and is not touched here. ## What this does - **Retires `RunProvenanceContext`** from `@objectstack/service-automation`: the interface, its arm of `RunDataContext`, the type export in `src/index.ts` and the README export list. `RunDataContext` is now `interface RunDataContext extends RunIdentityContext {}`, which is exactly the set of shapes `resolveRunDataContext` returns. Only type declarations change. - **Closes the pre-D5 reading outside `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. - **No runtime behaviour changes.** Comments were stripped with `scripts/js-comment-mask.mjs` `stripComments` and whitespace was collapsed. After that, 18 of the 20 changed `.ts` files are byte-identical to base `35afb15878`. The other two are `runtime-identity.ts` and `index.ts`. They differ only by the removed `interface RunProvenanceContext`, the `RunDataContext` declaration, 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 RunProvenanceContext` finds 4 hits: `runtime-identity.ts:75` (the declaration), `:124` (the union), `index.ts:195` (the type export) and `README.md:451` (the export list). There is no other reference in `packages/**`, `apps/**`, `examples/**` or `packages/qa/**`. The pinned sibling `objectui` at `a58626c88d` has none either: `git grep -E 'RunProvenanceContext|RunDataContext'` exits 1, and in the same shallow fetch `service-automation` matches 14 files as the control. - `RunDataContext` by name: `runtime-identity.ts:124` (the declaration), `:178` (the return type of `resolveRunDataContext`), `:275` (a parameter of `stampSystemInsertOwner`), `index.ts:195` and `README.md:450`. It has one reader outside the package: `plugin-approvals/test-typecheck-debt.json:43`. That is a ledgered TS2352 on `record-lock-schedule-run.integration.test.ts:150`, which casts `resolveRunDataContext(...)` to a record because the union's provenance arm had no `isSystem`. - **No production code builds an engine context that carries `flowRunId` and no principal.** Every data node resolves its context through `resolveRunDataContext` (`crud-nodes.ts:484/572/720/812`, `plugin.ts:1248`). `engine.ts:6079` stamps `flowRunId` on the run's `AutomationContext`, 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:137` and `system-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 are `HookContext` shapes. None of these names the type. - **The docstring said the type "survives for the non-data provenance uses that motivated objectstack-ai#3712". None was found.** The objectstack-ai#3712 use is the approvals record lock. It reads `HookContext.provenance.flowRunId` (`plugin-approvals/src/lifecycle-hooks.ts:489`), which objectql's `buildProvenance` builds from any `ExecutionContext`, and `RunIdentityContext` already carries `flowRunId`. - 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(...)` throws `PermissionDeniedError`, `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: `getReadFilter` returns the deny filter (`:5947`), and `canReadObject` (`:6384`) and `canExport` (`:6778`) answer `false`. Landed in `a3bcbcf3ca` (PR objectstack-ai#22297), and `git merge-base --is-ancestor a3bcbcf origin/main` exits 0. - `resolveRunDataContext` (`service-automation/src/runtime-identity.ts`) returns, by `runAs`: - `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? }`; - no user: it throws `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.ts` says 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: ``` git grep -n -i -E "middleware('s)? (skips (when|every|its)|skipped (a|when|every|its)|would skip|waves|waved|takes its principal-less|SKIPS)|(skips|skipped) when there is no (principal|identity)|wave[sd]? (it |them )?straight through|principal-less (fall-open|hand-off|.return next)|empty-principal (fall-open|skip)|(falls|fell|fall|falling) open for principal-less|plugin-security('s)? (principal-less )?(falls|fell) open|indistinguishable from passing no context|security[- ]skipped|ADR-0096 E1|straight to .next\(\)|(be|been) handed straight through|middleware handed it" -- . ':!packages/spec/**' ':!**/CHANGELOG.md' ``` It gives 82 lines at base `35afb15878` and 76 at head `f3a9675f4b`. 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) | # | Site | The false sentence | |:-|:-|:-| | 1 | `service-automation/src/runtime-identity.ts:56-74` | `RunProvenanceContext`: "the empty-principal fall-open … indistinguishable from passing no context at all" (retired with the type) | | 2 | `service-automation/src/runtime-identity.ts:85-86` | `UnscopedRunDataAccessError`: "the data security middleware skips when there is no principal" | | 3 | `service-automation/src/runtime-identity.ts:220-223` | "presenting none means the data security middleware skips every principal gate" | | 4 | `service-automation/src/runtime-identity.ts:333-336` | `runIsUnscopedUserMode`: "a principal-less context that the security middleware would wave straight through" | | 5 | `service-automation/src/engine.ts:6057-6058` | `resolveRunContext`: "the data security middleware skips when there is no identity" | | 6 | `service-automation/src/builtin/crud-runas.test.ts:219-220` | "which the data security middleware waves straight through" | | 7 | `service-automation/src/builtin/crud-runas.test.ts:258-259` | "the data security middleware skips" | | 8 | `service-analytics/src/strategies/objectql-strategy.ts:410-411` | "reaches the engine principal-less and plugin-security falls open" | | 9 | `plugin-security/src/security-plugin.ts:6061-6065` | `getMetadataReadableFields`: "mirrors the engine middleware, which skips its grant-based gates for a caller with no permission sets" | | 10 | `plugin-security/src/get-metadata-readable-fields.test.ts:8-10, :84` | "the engine middleware skips its whole gate for such a caller"; "middleware-mirroring fall-open" | | 11 | `platform-objects/src/system/sys-secret.object.ts:50-51` | "read with no principal (middleware falls open for principal-less internal calls)" | | 12 | `metadata-protocol/src/protocol.ts:11233-11237` | "this read passes no context"; "the middleware takes its principal-less `return next()`" | | 13 | `metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts:7-9` | "plugin-security hands … straight to `next()`" | | 14 | `plugin-auth/src/auth-plugin.ts:2480-2482` | "— the principal-less hand-off (ADR-0096)" | | 15 | `plugin-auth/src/auth-manager.ts:3384-3386` | "(the security middleware's principal-less hand-off, ADR-0096)" | | 16 | `plugin-auth/src/scim-connection-service.ts:121-122` | same | | 17 | `plugin-auth/src/principal-less-producers-system-context.test.ts:13-15` | "A context with neither … is the security middleware's principal-less hand-off" | | 18 | `runtime/src/http-dispatcher.ts:1244-1246` | "these calls carry NO ExecutionContext, so the data engine's security middleware skips RLS / FLS / CRUD / tenant scoping entirely" (the facade has carried `{ ...caller, isSystem: true }` since objectstack-ai#3914) | | 19 | `runtime/src/http-dispatcher.ts:1510-1511` | "(the security middleware's principal-less hand-off, ADR-0096)" | | 20 | `runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts:551-556` | "which the security middleware hands straight through"; "once a principal-less context is denied too" | | 21 | `trigger-record-change/src/record-change-integration.test.ts:395-396` | "the data security middleware skips when there is no principal" | | 22 | `qa/dogfood/test/flow-runas-schedule.dogfood.test.ts:10-12` | "the security middleware SKIPS (it delegates auth to the auth layer)" | | 23 | `qa/dogfood/test/declarative-endpoint-anonymous-guest.dogfood.test.ts:15-17` | "once the engine-level deny for the principal-less hand-off lands" | | 24 | `examples/app-showcase/src/automation/flows/index.ts:1584-1586` | "Without this it relies on the 'no identity → security-skipped' fall-through" | ### The pin's 76 lines at head `f3a9675f4b`, each with its class - **Fixed here (15):** the lines of rows 1-24 that still match, now in their corrected form: `examples/…/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`. - **Historical, left (36):** - Release text, not edited in a code PR: `.changeset/21908-by-id-producers-opt-in.md:6`, `.changeset/21908-principal-less-producers-final.md:8`, `.changeset/21908-principal-less-strict-mode.md:20` and `content/docs/releases/v17/17-0.mdx:302`. - Decision records (Tier H): `docs/adr/0096-…:11, :38, :156` and `docs/adr/0138-…:111, :635`. - "Before …" / "used to …" / "which D5 closes": `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, :198` and `owner-of-private-object-under-strict-mode.dogfood.test.ts:8`. - objectstack-ai#3597 and objectstack-ai#1888 history: `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:427` and `objectql-strategy.ts:852`. - **True, left (15):** - Names the hand-off as what ADR-0096 D5 closes: `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, :538` and `settings-service.ts:104`. - Negation: `public-form-grant-masking.test.ts:33, :233` ("never the principal-less hand-off"). - Describes the replacement: `tenant-audit-update-delete-half-repairs.test.ts:798`. - This PR's changeset quoting the removed sentence: `.changeset/22345-run-provenance-context-retired.md:17`. - **A string literal, not a comment, left (2):** both are test assertion messages that name the failure shape: `dispatcher-plugin.endpoint-fallback.integration.test.ts:571` and `runas-grant-resolution.integration.test.ts:102`. - **The sibling ADR-0056 D2 family, left (3):** `export-permission-axis.test.ts:143` (a test title), `rest/src/rest-server.ts:2640` and `runtime/src/domains/automation.ts:295`. These describe an AUTHENTICATED caller with zero permission sets, not a principal-less one. See the Acceptance notes. - **Another subject (5):** `docs/adr/0111-…:126` (the sharing middleware skipping `sys_record_share`), `better-auth-schema-parity.test.ts:13`, `can-write-object-admission.test.ts:576` and `security-plugin.ts:6521` (step 2.5 with no payload), and `text-match-sql.ts:237`. ## H4: `objectql/src/engine.ts` (`domain:engine`), not changed The sentence is now at `:5466`-`:5469`, after PR objectstack-ai#22337 landed: "A context carrying only write PROVENANCE (`{ flowRunId }`, all an identity-less flow run has — objectstack-ai#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 `buildSession` returning 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 objectstack-ai#3760: a user-less `runAs: 'user'` run never reaches the engine, and a `runAs: 'system'` one carries `isSystem` and `actor`. 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 = RunIdentityContext` prints as `RunIdentityContext | undefined` in diagnostics. That re-spells plugin-approvals' ledgered TS2352 signature (`'RunDataContext | undefined'`) and turns its `check:test-typecheck` red (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 `--filter`s. That is 24 packages, and all 24 declare a `typecheck` script. Result: `Tasks: 89 successful, 89 total`, 36 cached, at `c19dde3b5c`, before the merge of `main`. - **Reverse verification.** A probe file went into `plugin-approvals/src`. That package resolves `@objectstack/service-automation` through `exports` to `dist/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 --porcelain` printed nothing afterwards. ## Changeset `.changeset/22345-run-provenance-context-retired.md` grades `@objectstack/service-automation` as `minor`: BREAKING, an accept-set narrowing of one type and one removed type export, with the `!` banner. It also grades four packages as `patch` for comment text only. Their edited comment text ships, as measured in the built `dist`: | Package | Edited text found in | |:-|:-| | `plugin-security` | `dist/index.js` and `dist/index.d.ts` | | `runtime` | `dist/index.js` and `dist/index.d.ts` | | `service-analytics` | `dist/index.js` | | `platform-objects` | `dist/index.js` | The edited comments of `plugin-auth` and `metadata-protocol` do not ship. As a control, the code tokens next to them are present in the bundle (`withSystemContext(rawEngine)` 2, `CREDENTIAL_PROBE_CONTEXT` 3, `sys_metadata_audit` 8). 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-analytics` and `trigger-record-change`: rows 8-21 of the table. - `packages/qa/dogfood` and `examples/app-showcase`, both private: rows 22-24. - `objectql`: read under H4 and not changed. ## Verification (head `f3a9675f4b` = this branch merged with `main` at `54c3ce10ce`; PR objectstack-ai#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, at `c19dde3b5c`, 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: | Test file | Tests passed | |:-|-:| | plugin-security `get-metadata-readable-fields.test.ts` | 7 | | metadata-protocol `protocol.platform-store-system-opt-in.test.ts` | 7 | | plugin-auth `principal-less-producers-system-context.test.ts` | 4 | | runtime `dispatcher-plugin.endpoint-fallback.integration.test.ts` | 21 | | trigger-record-change `record-change-integration.test.ts` | 9 | - After the merge, `turbo run build --filter=!@objectstack/docs` gave `72 successful, 72 total`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 79 commands from 22 paths at `f3a9675f4b`, and all 79 exited 0. `--ran` with 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-inputs` and `check-issue-citations`: green. - ESLint on the 20 changed `.ts` files: `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, and `eslint.config.mjs:327-328` states that the config "never enables type-aware linting (no `parserOptions.project` …)". The full lint is CI's. - Declared narrowings: - The dependents typecheck ran before the merge. The commits the merge brought in move no `service-automation` export, and CI's TypeScript Type Check runs them all. - The full test suites of the comment-only packages, and the Dogfood Regression Gate, are left to CI. Each of their changed files is comment-identical to base, as measured above. ## Acceptance notes - **Sibling family, outside this card's definition (ADR-0056 D2, the deny baseline).** These sites say that the middleware skips its CRUD gate for an AUTHENTICATED caller with zero permission sets. The step-2 CRUD gate has not been guarded on a resolved set since that change. The sites are `rest/src/rest-server.ts:2639-2642`, `runtime/src/domains/automation.ts:295-303` ("this surface refuses where `/data` falls open"), `plugin-security/src/export-permission-axis.test.ts:143-146` (a title and a comment) and `plugin-security/src/baseline-composition.test.ts:157-159`. Not a principal-less reading, so left as they are. Taker: none. - **The objectstack-ai#3712 producer premise, false since objectstack-ai#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:5467` and `: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-2242` and `lifecycle-hooks.ts:484-488`. The units they introduce still test real hook and gate behaviour for that shape. Taker: none. - **Runtime strings, outside "comments only".** The `UnscopedRunDataAccessError` message (`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:238` and `schedule-runas-e2e.test.ts:122` assert `/UNSCOPED/`. Not changed, because no runtime behaviour moves in this PR. The prescription they give (declare `runAs: 'system'`) is still right. - **Test titles are strings, so they are left.** `plugin-security/src/security-plugin.test.ts:2317, :2571, :2674` read "(gate is before the fall-open)", and `can-write-object-admission.test.ts:640` reads "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` reads "runs the flow UNSCOPED". - **Left on purpose:** - `service-automation/src/engine.ts:6292`, "Surfaces the user-less fail-open (see helper)", names the objectstack-ai#1888 case. It is not a claim about the middleware. - `objectql-strategy.ts:852` and `analytics-rls.dogfood.test.ts:8` say "the bridge passes no ExecutionContext". That is a tense slip inside a past-tense paragraph; the bridge has passed the caller's context since objectstack-ai#3602. - **An orphaned docblock.** `runtime/src/http-dispatcher.ts:1241-1254` is bound to no declaration, the shape `rest-server-docblock-position.test.ts` records for this file. Its text is corrected here; its position is not moved. - **A plugin-security design question, not answered here.** `getReadableFields` still 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. - **Not changed:** - `resolveRunDataContext` keeps its `| undefined` return type although no path returns `undefined`. - `skills/objectstack-query/SKILL.md:66` ("`{ flowRunId }` for provenance alone") is Tier H and stays with the card the PR objectstack-ai#22327 review says is owed. - **A possible overlap.** PR objectstack-ai#22315 (`domain:spec` seat 2) edits `service-automation/src/engine.ts` and `README.md` in other regions. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- 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>
Contract reviewServed-tier: Inputs read: card #22302 (body and all 9 comments, triage ① Derived judgmentsNo accept-set change and no public-surface change. The head is a merge of Each sentence the PR states, judged against the merged
The two The Docs Drift Check pages, re-read on the merged tree. Sweep of the merged tree for leftover hand-off wording, outside One imprecision, carried over, not a defect: the changeset's "The corrected text ships in the published ② Semver level
③ Boundary flags
Check-runs on the head: 33 Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #22302
Clause-②: no
Summary
ADR-0096 D5 strict mode (merged as a3bcbcf) made
plugin-securityrefuse a data-engine context that carries no principal and is not a system context. Two published texts still taught the old hand-off, and this PR corrects them. Text only: no shape, runtime or export moves.packages/spec/src/contracts/security-service.ts: the four sentences the card names, at three methods. Each now states what that context gets, in the words of the method's return, and cites ADR-0096 D5.content/docs/kernel/contracts/data-engine.mdx: theExecutionContextpassage no longer teaches{ flowRunId }alone as an admitted context. It names the two admitted contexts and the refusal..changeset/22302-security-service-principal-less-text.md:@objectstack/specpatch (the TSDoc ships in the published.d.ts).What each sentence says now
A "principal" is what the existing text already defines: a position, a named permission set or a user id. The context in question has none of the three and is not
isSystem.getReadFilter, deny baseline paragraph403 PERMISSION_DENIED) answers the same contextcanExport, fails-closed paragraphfalsebefore anything resolves, the answer the middleware's principal-less refusal (403 PERMISSION_DENIED) gives itcanReadObject, arm listisSystem: the ADR-0096 D5 refusal of a context with no principal at all (false, before anything resolves); the ADR-0056 D2 arm keeps its wordingcanReadObject, fails-closed paragraphcanExportdata-engine.mdx,ExecutionContextpassage{ flowRunId }, a context that deliberately carries no principal{ isSystem: true }. A context with neither is refused by the security plugin with403 PERMISSION_DENIEDbefore the operation runs, for every verb.{ flowRunId }alone is such a context: a run id is provenance, not identityUnchanged on purpose: the system bypass sentences, the ADR-0056 D2 deny baseline for a caller that carries a principal and resolves no set, and the
getReadableFieldsparagraph (it already says "a non-system caller who resolves no permission set", which a principal-less context now is). No PR number or tracker id is cited in the shipped text.Hypotheses measured before writing
Measured against origin/main at fe98cc6 (the tree moved after the base 59d993c; the moved files do not touch principal-less handling, checked by diff of
security-plugin.ts, which changed only in the organization-wall region).isPrincipalLessContext(context)is read at five call sites insecurity-plugin.ts: the engine middleware (throwsprincipalLessDenial, aPermissionDeniedError,403 PERMISSION_DENIED, for every verb, after theisSystembypass, the public-form-grant branch and the pre-resolution gates, and before any permission-set resolution),getReadFilter(returns the deny filter),canReadObject,canWriteObjectandcanExport(each returnsfalse). The sixth site,resolveProjectionFieldMask, no longer settles a principal-less context early: it falls through to the zero-set mask like any caller that resolves no set.SecurityPluginOptionshas two keys (defaultPermissionSets,fallbackPermissionSet) and the file reads no environment variable on this path.principal-less-strict-mode.test.ts,can-read-object-admission.test.tsandcan-write-object-admission.test.tspass (3 files, 103 tests).canWriteObjectpassage.canWriteObjectis not a member ofISecurityService(it is a method of the plugin only), so the contract file never describes it. The otherprincipalhits in the file were each read:hasWriteBypass(:698),resolveWriteScope(:718),describeDelegationNarrowing(:754) andcheckAuthoredRowWrite(:798) say a principal-less context fails closed, answersnarrowed: falseor abstains, all still true.getMetadataReadableFields(:389) sayscanReadObjectis "the deny baseline for a caller that carries a principal", which is true and incomplete, not a hand-off statement, and is left alone.content/docs.data-engine.mdxis the only page that teaches{ flowRunId }as an admitted context. The otherflowRunIdand "no principal" hits are auto-generated reference tables or are about other subjects (record-view auditing, the time-triggered flow acting organization, realtime subscriptions, rate-limit keys). One neighbouring sentence is named under Acceptance notes.Verification
All on commit 4363c58 (the head of this branch).
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 101 commands (95 at dispatch, plus 6 that apply once the changeset exists). 100 ran with exit 0. 1 is NOT MEASURED:pnpm check:dual-build-cjs-loadsexited 3, PREREQUISITE NOT MET (it readsdist/of 32 packages and needs a repo-widepnpm build; nothing was measured). Basis for leaving it to CI (Build Core): this diff emits no JavaScript. The new sentences appear only inpackages/spec/dist/contracts/index.d.tsandindex.d.mts; a recursive grep ofdist/finds them in no.jsor.cjs, and finds none of the old sentences anywhere. Three gates first exited 3 for unbuilt prerequisites (@objectstack/formula,@objectstack/lint,@objectstack/client-react); after building those they ran and exited 0.--ranverdict line:dispatch-gates --ran: 101 derived famil(ies) accounted for — 100 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3).Exit 0.pnpm --filter @objectstack/spec buildexit 0.pnpm --filter @objectstack/spec typecheckexit 0.pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/contracts/security-service.test.ts src/contracts/core-service-contracts.test.ts: 2 files, 33 tests passed.Test Core,Dogfood Regression Gate,Build Docsand the type-check lanes outside@objectstack/spec.Line budget
59 changed lines against the merge base (42 added, 17 removed) in 3 files. No governed path is touched (
docs/adr/**,skills/**,.claude/**,AGENTS.md,CLAUDE.md: 0).Acceptance notes
Noted, not edited, because they sit outside the two files the claim names. They state the same hand-off in published or shipped text and are worth one follow-up edit together:
packages/spec/src/kernel/execution-context.zod.ts, theflowRunIdfield TSDoc (about :336-341): "A run that resolves no principal ... passes exactly{ flowRunId }: ... such a context authorizes identically to no context at all, and the run keeps the unscoped posture it already had". Stale: that context is refused now.packages/spec/src/kernel/execution-context.zod.tsabout :499-501, theExecutionContexttype TSDoc: "a flow run with no resolvable identity passes provenance alone ({ flowRunId })". Teaches the refused context as a valid one.packages/spec/src/data/data-engine.zod.tsabout :66-71, theBaseEngineOptionsSchema.contextTSDoc: "a flow run that resolves no identity passes provenance alone ({ flowRunId }), a context deliberately carrying no principal at all". Same.packages/spec/src/contracts/analytics-service.tsabout :441-443: the rationale for forwarding the request context says a context-less bridge ran "completely unscoped" because "the security middleware's fall-open skipped its own RLS injection". Historical, but withplugin-securitycomposed the context-less call is refused rather than unscoped.content/docs/permissions/access-recipes.mdx:52: "A schedule-triggered run has no user, so underuserit runs unscoped". TherunAstext incontent/docs/automation/approvals.mdx,hooks.mdxand the generatedautomation-api.mdxsays such a run's data operations are refused. I did not run a schedule-triggered flow, so this is a read-only inference.hook.zod.tsabout :1126 ("a schedule-triggered flow run resolves no principal") reads true as written and is left alone.One behaviour edge, measured by reading and by
principal-less-strict-mode.test.ts, not a text defect: the middleware's public-form-grant branch (ADR-0056) sits ahead of the principal-less refusal, so a hand-built{ publicFormGrant: { object } }with no named set would be admitted for its create and read-back on that object, whilecanReadObject,canExportandgetReadFilteranswer deny for it. The only producer in the tree, the REST form-submit route, builds its context withpermissions: ['guest_portal'], so it carries a principal and is not in the class this text describes. The new text says "the middleware's principal-less refusal" rather than claiming the middleware refuses every such context for that reason.Patch round 1 (the seat amended the claim surface)
Adds the TSDoc of two more published sources and one docs line. Comment and docs text only: 0 of 71 changed lines in the three .ts files is outside a comment block.
Measured before written, on origin/main at 28bff18 (the merge base of this round): packages/services/service-automation/src/runtime-identity.ts, resolveRunDataContext (line 178). With runAs system it returns isSystem true plus actor svc:flow:NAME, empty positions and permissions, the run id, and the trigger userId and tenantId only when the trigger resolved them (lines 180-218). With runAs user and no trigger user, which is what a schedule run has, it throws UnscopedRunDataAccessError (code AUTOMATION_UNSCOPED_RUN_DATA_ACCESS) at line 231, before any call reaches the data engine. With a trigger user it returns that user identity (line 233 on). engine.ts resolveRunContext (lines 6057 and 6062) stamps runAs (default user) and flowRunId on the run context. Nothing in service-automation builds a flowRunId-only context: the RunProvenanceContext type is exported but produced nowhere. Pinned by src/builtin/crud-runas.test.ts (24 tests pass; lines 221, 247, 288 and 316: a user-mode run with no user throws, a schedule run reaches the data layer with zero operations and fails, a record-change run fired by a user-less write is refused the same way).
Verification on head b7cc3e2: spec build exit 0; the old sentences are in no file of packages/spec/dist (.d.ts, .d.mts, .js, .mjs: 0 files); the type TSDoc lands in manifest.zod-*.d.ts and .d.mts, the field TSDoc in the kernel, data, api and browser JS bundles only (a field comment is not emitted into a declaration); spec typecheck exit 0; check:docs exit 0; dispatch-gates --ran: 102 derived, 101 run, 1 NOT-MEASURED (check:dual-build-cjs-loads, exit 3 PREREQUISITE NOT MET, needs a repo-wide build, left to Build Core). Line budget against the merge base: 101 changed lines (68 added, 33 removed), 6 files, 0 governed paths. This round the comments do ship in JS bundles, so the round-0 statement that the diff emits no JavaScript no longer holds; the gate stays NOT MEASURED on the basis that every changed line in the three .ts files is a comment line.
Acceptance notes added: (1) skills/objectstack-query/SKILL.md line 66 still reads that flowRunId is passed for provenance alone; it is a governed Tier H file, not edited, needs its own carrier. (2) packages/services/service-automation/src/runtime-identity.ts lines 56-81 and engine.ts lines 6042-6046 still describe the pre-refusal middleware skip, and the RunProvenanceContext export has no producer; noted, not edited, carrier none. (3) contracts/analytics-service.ts about line 441 stays, as the seat ruled: past-tense history.
CI note, not caused by a test: Test Core 6 of 6 fails at the step Check this shard timing drift (exit 1) on this PR at both 4363c58 and b7cc3e2, with the shard test step before it passing; the same shard is green on the current origin/main push run. The job log is not reachable from this container (the log blob host is refused), so the drift numbers are NOT MEASURED here.
Merge-main round (head
7c79aade4)#22014's shard-timings refresh (PR #22368) unblocked
Test Core (6/6)'s drift step, somain(27a8b33de, 53 commits past the earlier base28bff18d0) was merged withscripts/pm/os-regen-merge.sh. Zero files conflicted..tsfiles change comments only (stripCommentscomparison,27a8b33devs head).maintouched none of the six paths, and fix(service-automation)!: retire RunProvenanceContext; pre-D5 principal-less readings outside packages/spec say D5 refuses it #22357 / docs(skills): objectstack-query names the context the engine admits since ADR-0096 D5 #22382 restate the same principal-less rule in agreement.@objectstack/specunit,localproject 627 files / 18757 passed andrepoproject 54 / 915. 102 derived gates ran: 101 exited 0, andcheck:dual-build-cjs-loadswas left to CI's Build Core (comment-only diff).6076493127. The Verification and Line budget sections above keep the round-0 numbers for the record.Generated by Claude Code