Skip to content

feat(plugin-security)!: refuse a principal-less, non-system data-engine context (ADR-0096 D5 strict mode) - #22297

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21908-principal-less-deny
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21908-principal-less-deny

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21908
Clause-②: no (narrowing)

Summary

ADR-0096 D5 strict mode, the deny round of #21908. A data-engine context that carries no principal (no user id, no position, no permission set) and is not a system context is now refused at every place plugin-security used to hand it through. The published contract sentence on ChatWithToolsOptions.toolExecutionContext ("unauthenticated (RLS-on, sees-nothing)") now holds.

Before the deny, every producer of such a context that a run or a static read found was closed inside this claim: it carries the caller's principal or the explicit system opt-in (isSystem: true) inside its owning code. Breaking for in-process code (the ! banner); the changeset carries the migration and an ADR-0087 disposition. The at-tier contract review is owed before the queue.

The ADR-0096 D5 dated note is its own Tier H draft PR, on claude/issue-21908-adr-0096-d5-note, for the maintainer's approval.

What changes, by class

The predicate is isPrincipalLessContext(context) && !isSystem, the one the hand-off already used. These are the six sites, at origin/main 0e9371f0 positions:

Site (security-plugin.ts) Before After
engine middleware (near :2616) return next(): no CRUD gate, no RLS, no field mask, no tenant wall throws PermissionDeniedError (403 PERMISSION_DENIED) for every verb, before the operation runs
getReadFilter (near :5875) the sharing predicate alone the deny filter (zero rows)
resolveProjectionFieldMask (near :6126) the full field set no early answer: the zero-set field answers, like any caller that resolves no set
canReadObject (near :6299) true false
canWriteObject (near :6585) true false
canExport (near :6688) true false
  • Unchanged: a system context is admitted everywhere.
  • Unchanged: a context that carries a principal without a user id is decided by what it carries. That covers a named permission set, the guest principal, and the public-form grant.
  • The gates that run before the refusal keep their own answers: package-managed, system-row, curated-capability, audience-anchor, engine-owned and delegated-admin.

Why the five service sites return rather than throw. The ruling's error class is the engine-level refusal, and the middleware throws it. The published ISecurityService contract has canReadObject, canWriteObject and canExport return false, and has getReadFilter return the deny filter. Each of them now gives a principal-less context the deny it already gives a caller that carries a principal and resolves no set (ADR-0056 D2). The field projections are field answers, not admission answers. The contract text for getReadableFields names the answer for "a non-system caller who resolves no permission set", and that answer now applies to this context too. Object admission is refused by the middleware and the three probes.

Producer census (H1), by class, position and function

Method.

  • A temporary probe recorded the call stack at every principal-less exit while the deny was in place. It was never committed; it was reverted, and the blob was proven equal to HEAD.
  • It ran over the full dogfood suite (10 shards, 239 files) and over every unit file in 22 packages that composes the security plugin (about 90 files).
  • A static sweep covered every sys_* engine call in production source that passes no context.
  • After the fixes, a real boot was driven, with a member's first query and the admin authoring doors.
Class Position Function From a request Closure
production, measured metadata-protocol protocol.ts promoteDraftForPublish (the draft read) yes: the publish door, behind its authorization explicit system opt-in
production, static metadata-protocol protocol.ts resolveDraftOrgScopeForPublish (both probes) yes: an organization-scoped publish explicit system opt-in
production, static metadata-protocol protocol.ts resolveMetaItemOrgScope (both history probes) yes: the organization-scoped revert path explicit system opt-in
production, static metadata-protocol protocol.ts auditMetaItem yes: the audit door explicit system opt-in
production, static metadata-protocol protocol.ts diffMetaItem (the history read and the current-row read) yes: the diff door explicit system opt-in
production, static metadata-protocol protocol.ts listCommits, revertCommit, rollbackToPackageCommit yes: the commit doors explicit system opt-in
production, static metadata-protocol protocol.ts metaTypeNamespaceExists, migrateStoredMetadata yes: the authoring and migration doors explicit system opt-in
production, static metadata-protocol protocol.ts deleteMetaItem legacy path (read and delete) yes: the code-only delete on a control-plane kernel explicit system opt-in
test harness qa/dogfood the federated-fixture test (context passed as the options argument) no carries { context }
test harness qa/dogfood package-first-authoring (a direct store insert) no system opt-in
test harness runtime standalone-stack-seeder-declaration-copy (context-less reads) no system opt-in
test harness runtime the share-links-enforcement-context engine double (dropped the trailing read options) no honours the trailing context, as mergeReadContext does

Not producers today. loadMetaFromDb and reportUnhydratableOrgScopedRows read context-less at boot. They run inside ObjectQL's start(), and plugin-security depends on ObjectQL, so they run before the security middleware is registered. Noted, not edited.

No producer was found outside plugin-security, protocol.ts and the test harnesses. The static sweep's other 25 hits all go through a system-context wrapper or a context-carrying options object, or are comments. Every edit uses only the declared opt-in; nothing guesses a boundary. Each moved call is a sys_metadata* store read, or the legacy delete of a sys_metadata row. None of the six gates the middleware runs before the refusal keys on those objects, so the opt-in changes nothing they decide.

The dispatcher's identity-resolution fault class (the domain:cli seat's note) is closed by this deny by construction. It is pinned below, and http-dispatcher.ts is not edited.

Pins

The ruling's three pins:

  • A principal-less context is refused on read and write: plugin-security principal-less-strict-mode.test.ts. It covers every spelling: no context, an empty one, empty arrays, isSystem: false, a tenant id alone, and provenance only. The middleware answers PERMISSION_DENIED / 403 for all seven verbs before the operation runs, the probes answer false, and the row scope is the deny filter.
  • An explicit system context is unchanged: the same file, with and without a principal.
  • The dogfood suite is green under the deny: see Evidence.

Further pins:

  • The hand-through pins became refusal pins: zero-set-deny-baseline, zero-set-masking, zero-set-capability-fold, metadata-unresolvable-posture, predicate-related-read-tenant-scope and security-plugin.test.ts's row-scope case.
  • The deny is keyed on carrying no principal: a user-less named set, the guest principal and the public-form grant are each decided by what they carry.
  • The resolveRequestScope fault class is now denied: runtime dispatcher-plugin.endpoint-principal-less-deny.integration.test.ts, over the real dispatcher plugin and the real security middleware. The engine is handed no principal, the store is never reached, and the caller gets 403 with nothing of the rows. Its two controls: an anonymous request with no fault runs as the guest (the posture of security(rest, runtime): an anonymous request at an app-declared authRequired: false endpoint executes principal-less — execute it as the guest principal (ruled C), never principal-less, never system #22147, unchanged), and a granted signed-in caller is served.
  • The guest posture is unchanged at a real boot: test(runtime): pin the guest principal an anonymous request executes as at authRequired:false endpoints #22177's declarative-endpoint-anonymous-guest pins stay green under the deny.
  • The composed Q2 pin: qa/dogfood owner-of-private-object-under-strict-mode. An owner_of: audience on a private object reaches its owner's inbox, and nobody else's, end to end. In the same composition, a principal-less read of that object is refused.
  • The protocol producers: metadata-protocol protocol.platform-store-system-opt-in.test.ts, deny-round block. Each producer is driven, and every engine call it issues must carry isSystem: true.

Ablations

Each ablation ran through scripts/ablation-replace.mjs (wrap mode, plus a shell trap). Every mutation landed (anchor 1 → 0, blob changed), and every restore was proven (blob == HEAD, git diff HEAD empty). The dist legs were proven with ablation-dist-preflight (marker present while live, --absent after the restore rebuild, tree clean).

# Mutation Red
A1 the middleware refusal replaced by the old return next() (dist leg) plugin-security 13 of 42; runtime fault-class pin 1 of 3 (the store was reached)
A2 getReadFilter answers no scope for the class 8 of 305
A3 canReadObject admits the class 7 of 305
A4 the diff door's history read loses the opt-in 1 of 7
A5 the promotion's draft read loses the opt-in 1 of 7
A6 resolveOwnerOf loses its opt-in (service-messaging, dist leg) the composed Q2 pin 1 of 1 (0 recipients)

Evidence

Final head 454d0e89b9: origin/main 8cbe255ef6 merged at 6681b216e1, then a docblock-only test commit. All runs were made under the shared verify lock, so wall times are shared-box readings.

On the merged tree (6681b216e1):

  • plugin-security: 180 files, 3800 passed, 45 skipped.
  • metadata-protocol: 221 files (+3 skipped), 28280 passed.
  • runtime: 339 files, 5490 passed, 19 skipped.
  • The composing unit files of the packages main touched (rest, cli, lint, platform-objects, plugin-approvals, service-analytics, organizations, spec): all green.
  • dogfood, the whole suite in 10 shards: 223 files, 1754 passed, 9 skipped.
  • typecheck exits 0 for plugin-security, metadata-protocol, runtime and dogfood. tsc --listFiles shows metadata-protocol's program compiles the edited pin.

Before the merge (8248ceb97f): objectql 381 files / 7525 passed, and core 81 files / 2240 passed. Neither package is touched by main's incoming commits or by this diff.

Real boot, showcase --fresh, on the merged tree:

  • An anonymous request answers 401.
  • The admin signs in and invites a member. The member signs up, and the member's first data query answers 200, as do the member's object read and inbox.
  • The admin's draft save, publish, diff, audit, history, delete and package-commits doors all answer 200.
  • The server log carries zero strict-mode refusals.

Gates: dispatch-gates --commands derived 73 families at 454d0e89b9. All 73 ran and exited 0. --ran: "73 derived famil(ies) accounted for — 73 run, 0 NOT-MEASURED (a DERIVED zero — all 73 recorded an exit code and none of them is 3)". Selected verdict lines:

  • ✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.
  • ✓ This diff introduces no \major` bump.`
  • check-nul-bytes: OK
  • check-engine-double-contract: OK
  • check-test-source-alias OK
  • check:cross-package-test-inputs OK

Lint, a proven narrowing (CI runs the full pnpm lint):

  • The population read from eslint.config.mjs is **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} plus the packages/** blocks.
  • eslint --no-inline-config --format json over the 16 changed .ts files at 454d0e89b9 gives 16 files, 0 errors, 0 warnings.
  • The config enables no type-aware linting (no parserOptions.project, no typed rules), so no untouched file's verdict can move.

check-governed-merges --branch: 0 of 17 paths on the register; 1083 changed lines.

Acceptance notes

  • The spec's ISecurityService prose still describes the hand-off. Four TSDoc sentences on getReadFilter, canExport and canReadObject in packages/spec/src/contracts/security-service.ts say a context with no principal "is admitted" or "keeps the scope". packages/spec is outside this claim, so the sentences are not edited here. The report asks the seat how to route them.
  • Two of the producers closed here are outside the cross-lane declaration. The domain:engine declaration on [PM seat] domain:engine — ⏳ vacant #6367 names promoteDraftForPublish. The census found the other protocol.ts producers listed above in the same file: the same class, with the same mechanical opt-in. They are closed here and named for the seat, so the declaration can be amended.
  • A test-side spelling trap. Passing an ExecutionContext variable as the options argument of an engine verb compiles, and silently drops the context. One fixture did this. Under strict mode it is now a loud 403, no longer a silent hand-through.
  • What the census cannot see. The static sweep reads sys_* object names spelled as literals; a call whose object name is a variable is covered only by the runs.

Generated by Claude Code

claude added 7 commits October 8, 2026 08:25
…t the engine (ADR-0096 D5 strict mode)

The engine middleware refuses a context that carries no principal and is
not a system one with PermissionDeniedError (403 PERMISSION_DENIED), before
anything resolves. The object-admission probes answer false and the row
scope is the deny sentinel, read off the same predicate. The field
projections no longer settle such a context early.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…xplicit system opt-in

The publish path's draft read and org-scope probes, and the store reads and
writes behind the history, audit, diff, commit, migration and code-only
delete doors, reached the data engine with no context. Each now passes
context: { isSystem: true }, as the protocol's other store calls do. The pin
drives each producer and asserts every engine call it issues is a system one.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…carry one

A fixture passed the system context as the options argument, a fixture
inserted a store row and read seeded rows with no context, and an engine
double dropped the trailing read options that carry the permission-set
loader's system opt-in.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…refusal pins

A principal-less, non-system context is refused on read and write in every
spelling, at the middleware and the object-admission probes, with the deny
row scope. A system context is unchanged, and a context carrying a principal
without a user id is decided by what it carries. The runtime pin composes the
dispatcher's identity-resolution fault class with the real middleware; the
dogfood pin resolves an owner_of audience on a private object end to end.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…etadata-protocol opt-ins (patch)

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
@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 2 package(s): @objectstack/metadata-protocol, @objectstack/plugin-security, touching 27 documentable anchor(s).

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 4578c56e65c1f50f04da9259579091272d319558.

⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 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 — 24 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 4578c56e65c1f50f04da9259579091272d319558 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b49796f36f5fa21598680b59709d13f830252d96 — the merge of head 454d0e89b9397900c6d4b01bd30e8e505d886bbe into base 4578c56e65c1f50f04da9259579091272d319558, 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 b49796f36f5fa21598680b59709d13f830252d96 && git checkout b49796f36f5fa21598680b59709d13f830252d96
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4578c56e65c1f50f04da9259579091272d319558 454d0e89b9397900c6d4b01bd30e8e505d886bbe && git checkout -B drift-repro 4578c56e65c1f50f04da9259579091272d319558 && git merge --no-ff 454d0e89b9397900c6d4b01bd30e8e505d886bbe

node scripts/docs-audit/affected-docs.mjs --json 4578c56e65c1f50f04da9259579091272d319558

⚠️ 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 4578c56e65c1f50f04da9259579091272d319558 → 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 13:50
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 13:50
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit a3bcbcf Oct 8, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21908-principal-less-deny branch October 8, 2026 14:40
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…and the telemetry gate (objectstack-ai#22298)

Refs objectstack-ai#21908

**Tier H — governed surface (`docs/adr/**`).** This PR lands only on the
maintainer's approval: an authorized APPROVED review, or the
maintainer's own merge. No seat readies, queues or arms it.

## Summary

A dated note under ADR-0096 D5 records the maintainer's ruling Q3 (甲) on
objectstack-ai#21908 ([comment
6019001864](objectstack-ai#21908 (comment)),
2026-10-06). The note says:

- strict mode lands ON at the security plugin's hand-off sites, ahead of
the D2 `systemContext(reason)` door and the telemetry gate;
- the explicit opt-in is the wire-level `isSystem: true` flag, which is
E1's own prescription;
- no `security.identityStrict` switch is built, so there is no OFF mode.

The note cites what licenses that order: the published contract sentence
on `ChatWithToolsOptions.toolExecutionContext` ("unauthenticated
(RLS-on, sees-nothing)") and the maintainer's ruling 2B on
objectstack-ai/cloud#2006. The ADR's own text is not rewritten, and its
status stays `Proposed`.

The behaviour the note records ships in objectstack-ai#22297, the deny round, which is
a separate PR and not governed.

## The diff

One blockquote note, 2 added lines, placed at the end of D5 and before
D6. Nothing else in ADR-0096 changes.

## 维护者速读(草稿)

### 改了什么
在 ADR-0096 的 D5(严格模式)末尾加一段带日期的注记,不改动 ADR 原文与状态。注记记录:严格模式现在就默认开启,先于 D2 的
`systemContext(reason)` 入口和遥测门;显式提权沿用线上已有的 `isSystem: true`;不提供关闭开关。

### 为什么改
你在 objectstack-ai#21908 的 Q3 选了「甲」:只加注记,不重写 ADR。代码那一半(拒绝没有主体的数据引擎调用)在 objectstack-ai#22297。ADR
是受管面,所以单独成 PR,由你批准。

### 风险与代价(含回滚)
纯文档,对运行时零影响。回滚就是撤掉这两行。真正的行为风险在 objectstack-ai#22297(进程内未带上下文的调用会被 403
拒绝),那边有迁移说明和合同级复核。

### 席位意见


### 你要做的
读一遍注记的措辞,同意就批准(APPROVED);要改措辞就在 PR 上留言。

## Evidence

- `dispatch-gates --commands` derived 19 families at `2becf9015f`. All
19 ran and exited 0. `--ran`: "19 derived famil(ies) accounted for — 19
run, 0 NOT-MEASURED (a DERIVED zero — all 19 recorded an exit code and
none of them is 3)". The first `check:doc-formula-expressions` run
exited 3 (prerequisite not met: `formula` and `lint` were unbuilt in
this worktree). They were built, and it then exited 0.
- `check-governed-merges --branch claude/issue-21908-adr-0096-d5-note`:
GOVERNED, landing tier H, `docs/adr/**` ×1
(`docs/adr/0096-execution-surface-identity-admission.md`), 2 changed
lines.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…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>
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>
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/xl tests tooling

Projects

None yet

2 participants