Skip to content

fix(service-automation)!: retire RunProvenanceContext; pre-D5 principal-less readings outside packages/spec say D5 refuses it - #22357

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22345-pre-d5-reading-pass
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22345-pre-d5-reading-pass

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #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 #22302 / PR #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 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 hold flowRunId (#3456 residual) #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 feat(plugin-security)!: refuse a principal-less, non-system data-engine context (ADR-0096 D5 strict mode) #22297), and git merge-base --is-ancestor a3bcbcf3ca 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 #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.
    • security(analytics): ObjectQLStrategy 不消费 getReadScope — NativeSQL 回落后聚合查询无 RLS/租户谓词(#2852 修复未覆盖的另一半) #3597 and [P0][security] Flow runAs never switches execution identity #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 #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 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 #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 --filters. 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 #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 Approval: a schedule-triggered run still can't write its own locked record — it carries no ObjectQL context to hold 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: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:
  • 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:
  • A possible overlap. PR feat(spec)!: flow text slots read the {{ }} delimiter, refusing a single-brace token with its hole spelling (#22110) #22315 (domain:spec seat 2) edits service-automation/src/engine.ts and README.md in other regions.

Generated by Claude Code

claude added 4 commits October 8, 2026 20:39
… 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>
@github-actions github-actions Bot added the size/m label Oct 8, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 7 package(s): @objectstack/metadata-protocol, @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/plugin-security, @objectstack/runtime, @objectstack/service-analytics, @objectstack/service-automation, touching 15 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/scim-connection-service.ts, packages/services/service-automation/README.md, packages/services/service-automation/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via getAudit (sdk, the bare tail of client method meta.getAudit, bound to GET /api/v1/meta/:type/:name/audit), meta.getAudit (sdk, the route ledger binds it to GET /api/v1/meta/:type/:name/audit, selected by route anchor /:type/:name/audit))
  • content/docs/api/environment-routing.mdx (via HttpDispatcher (symbol, a top-level class), enforceProjectMembership (symbol, a method of class HttpDispatcher))
  • content/docs/automation/webhooks.mdx (via HttpDispatcher (symbol, a top-level class))
  • content/docs/kernel/cluster.mdx (via HttpDispatcher (symbol, a top-level class))
  • content/docs/kernel/contracts/data-engine.mdx (via flowRunId (symbol, a field of interface RunProvenanceContext))
  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via HttpDispatcher (symbol, a top-level class), SecurityPlugin (symbol, a top-level class))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

⛔ 6 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-1.mdx (via auditMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/releases/v17/17-5.mdx (via HttpDispatcher (symbol, a top-level class), getAudit (sdk, the bare tail of client method meta.getAudit, bound to GET /api/v1/meta/:type/:name/audit), meta.getAudit (sdk, the route ledger binds it to GET /api/v1/meta/:type/:name/audit, selected by route anchor /:type/:name/audit))
  • content/docs/releases/v17/17-6.mdx (via AutomationEngine (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/scim-connection-service.ts, packages/services/service-automation/README.md, packages/services/service-automation/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 33 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 52 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b7e01fbbd2c3340ae95e54e2ec220314e9ae728e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d6f1acc5b197169f663442cb6cdc02a62083c465 — the merge of head f3a9675f4b07a27ddc4d50622060450a1036d429 into base b7e01fbbd2c3340ae95e54e2ec220314e9ae728e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b7e01fbbd2c3340ae95e54e2ec220314e9ae728e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 22:25
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 746637e Oct 8, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22345-pre-d5-reading-pass branch October 8, 2026 23:00
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… 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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants