Skip to content

docs(spec): ISecurityService and the data-engine contract page state the ADR-0096 D5 refusal of a principal-less context - #22327

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22302-security-service-principal-less-tsdoc
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22302-security-service-principal-less-tsdoc

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22302
Clause-②: no

Summary

ADR-0096 D5 strict mode (merged as a3bcbcf) made plugin-security refuse a data-engine context that carries no principal and is not a system context. Two published texts still taught the old hand-off, and this PR corrects them. Text only: no shape, runtime or export moves.

  • packages/spec/src/contracts/security-service.ts: the four sentences the card names, at three methods. Each now states what that context gets, in the words of the method's return, and cites ADR-0096 D5.
  • content/docs/kernel/contracts/data-engine.mdx: the ExecutionContext passage no longer teaches { flowRunId } alone as an admitted context. It names the two admitted contexts and the refusal.
  • .changeset/22302-security-service-principal-less-text.md: @objectstack/spec patch (the TSDoc ships in the published .d.ts).

What each sentence says now

A "principal" is what the existing text already defines: a position, a named permission set or a user id. The context in question has none of the three and is not isSystem.

Where Before After
getReadFilter, deny baseline paragraph only a context with no principal at all keeps the scope the other layers compose, "ADR-0096 stages that context separately" so does a non-system context with no principal at all (ADR-0096 D5 strict mode): the DENY filter, before any layer composes a scope, as the engine middleware's principal-less refusal (403 PERMISSION_DENIED) answers the same context
canExport, fails-closed paragraph only a context with no principal at all is admitted, as the middleware hands it through so does a non-system context with no principal at all (ADR-0096 D5 strict mode): false before anything resolves, the answer the middleware's principal-less refusal (403 PERMISSION_DENIED) gives it
canReadObject, arm list the deny baseline arm, with "only a context that carries no principal at all is handed through" in parentheses a new arm after isSystem: the ADR-0096 D5 refusal of a context with no principal at all (false, before anything resolves); the ADR-0056 D2 arm keeps its wording
canReadObject, fails-closed paragraph only a context with no principal at all is admitted, as the middleware hands it through same sentence as canExport
data-engine.mdx, ExecutionContext passage a run with no resolvable identity passes only { flowRunId }, a context that deliberately carries no principal what the engine needs is a principal or an explicit elevation: the caller's own context, or { isSystem: true }. A context with neither is refused by the security plugin with 403 PERMISSION_DENIED before the operation runs, for every verb. { flowRunId } alone is such a context: a run id is provenance, not identity

Unchanged on purpose: the system bypass sentences, the ADR-0056 D2 deny baseline for a caller that carries a principal and resolves no set, and the getReadableFields paragraph (it already says "a non-system caller who resolves no permission set", which a principal-less context now is). No PR number or tracker id is cited in the shipped text.

Hypotheses measured before writing

Measured against origin/main at fe98cc6 (the tree moved after the base 59d993c; the moved files do not touch principal-less handling, checked by diff of security-plugin.ts, which changed only in the organization-wall region).

  • H1 holds. No mode switch. isPrincipalLessContext(context) is read at five call sites in security-plugin.ts: the engine middleware (throws principalLessDenial, a PermissionDeniedError, 403 PERMISSION_DENIED, for every verb, after the isSystem bypass, the public-form-grant branch and the pre-resolution gates, and before any permission-set resolution), getReadFilter (returns the deny filter), canReadObject, canWriteObject and canExport (each returns false). The sixth site, resolveProjectionFieldMask, no longer settles a principal-less context early: it falls through to the zero-set mask like any caller that resolves no set. SecurityPluginOptions has two keys (defaultPermissionSets, fallbackPermissionSet) and the file reads no environment variable on this path. principal-less-strict-mode.test.ts, can-read-object-admission.test.ts and can-write-object-admission.test.ts pass (3 files, 103 tests).
  • H2: the file holds exactly the four sentences, and no canWriteObject passage. canWriteObject is not a member of ISecurityService (it is a method of the plugin only), so the contract file never describes it. The other principal hits in the file were each read: hasWriteBypass (:698), resolveWriteScope (:718), describeDelegationNarrowing (:754) and checkAuthoredRowWrite (:798) say a principal-less context fails closed, answers narrowed: false or abstains, all still true. getMetadataReadableFields (:389) says canReadObject is "the deny baseline for a caller that carries a principal", which is true and incomplete, not a hand-off statement, and is left alone.
  • H3 holds for content/docs. data-engine.mdx is the only page that teaches { flowRunId } as an admitted context. The other flowRunId and "no principal" hits are auto-generated reference tables or are about other subjects (record-view auditing, the time-triggered flow acting organization, realtime subscriptions, rate-limit keys). One neighbouring sentence is named under Acceptance notes.

Verification

All on commit 4363c58 (the head of this branch).

  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 101 commands (95 at dispatch, plus 6 that apply once the changeset exists). 100 ran with exit 0. 1 is NOT MEASURED: pnpm check:dual-build-cjs-loads exited 3, PREREQUISITE NOT MET (it reads dist/ of 32 packages and needs a repo-wide pnpm build; nothing was measured). Basis for leaving it to CI (Build Core): this diff emits no JavaScript. The new sentences appear only in packages/spec/dist/contracts/index.d.ts and index.d.mts; a recursive grep of dist/ finds them in no .js or .cjs, and finds none of the old sentences anywhere. Three gates first exited 3 for unbuilt prerequisites (@objectstack/formula, @objectstack/lint, @objectstack/client-react); after building those they ran and exited 0.
  • --ran verdict line: dispatch-gates --ran: 101 derived famil(ies) accounted for — 100 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3). Exit 0.
  • pnpm --filter @objectstack/spec build exit 0. pnpm --filter @objectstack/spec typecheck exit 0.
  • pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/contracts/security-service.test.ts src/contracts/core-service-contracts.test.ts: 2 files, 33 tests passed.
  • Reverse verification and ablation do not apply: no behaviour moves, so there is no assertion to turn red.
  • Not run here, left to CI: the repo-wide lint run, Test Core, Dogfood Regression Gate, Build Docs and the type-check lanes outside @objectstack/spec.

Line budget

59 changed lines against the merge base (42 added, 17 removed) in 3 files. No governed path is touched (docs/adr/**, skills/**, .claude/**, AGENTS.md, CLAUDE.md: 0).

Acceptance notes

Noted, not edited, because they sit outside the two files the claim names. They state the same hand-off in published or shipped text and are worth one follow-up edit together:

  • packages/spec/src/kernel/execution-context.zod.ts, the flowRunId field TSDoc (about :336-341): "A run that resolves no principal ... passes exactly { flowRunId }: ... such a context authorizes identically to no context at all, and the run keeps the unscoped posture it already had". Stale: that context is refused now.
  • packages/spec/src/kernel/execution-context.zod.ts about :499-501, the ExecutionContext type TSDoc: "a flow run with no resolvable identity passes provenance alone ({ flowRunId })". Teaches the refused context as a valid one.
  • packages/spec/src/data/data-engine.zod.ts about :66-71, the BaseEngineOptionsSchema.context TSDoc: "a flow run that resolves no identity passes provenance alone ({ flowRunId }), a context deliberately carrying no principal at all". Same.
  • packages/spec/src/contracts/analytics-service.ts about :441-443: the rationale for forwarding the request context says a context-less bridge ran "completely unscoped" because "the security middleware's fall-open skipped its own RLS injection". Historical, but with plugin-security composed the context-less call is refused rather than unscoped.
  • content/docs/permissions/access-recipes.mdx :52: "A schedule-triggered run has no user, so under user it runs unscoped". The runAs text in content/docs/automation/approvals.mdx, hooks.mdx and the generated automation-api.mdx says such a run's data operations are refused. I did not run a schedule-triggered flow, so this is a read-only inference.
  • hook.zod.ts about :1126 ("a schedule-triggered flow run resolves no principal") reads true as written and is left alone.

One behaviour edge, measured by reading and by principal-less-strict-mode.test.ts, not a text defect: the middleware's public-form-grant branch (ADR-0056) sits ahead of the principal-less refusal, so a hand-built { publicFormGrant: { object } } with no named set would be admitted for its create and read-back on that object, while canReadObject, canExport and getReadFilter answer deny for it. The only producer in the tree, the REST form-submit route, builds its context with permissions: ['guest_portal'], so it carries a principal and is not in the class this text describes. The new text says "the middleware's principal-less refusal" rather than claiming the middleware refuses every such context for that reason.

Patch round 1 (the seat amended the claim surface)

Adds the TSDoc of two more published sources and one docs line. Comment and docs text only: 0 of 71 changed lines in the three .ts files is outside a comment block.

  • packages/spec/src/kernel/execution-context.zod.ts: the flowRunId field TSDoc and the ExecutionContext input type TSDoc. packages/spec/src/data/data-engine.zod.ts: the BaseEngineOptionsSchema.context TSDoc. They no longer say a run with no principal passes exactly the flowRunId-only context, which authorizes like no context and keeps the unscoped posture, or that such a context is deliberately principal-less. They now say a non-system context with no principal is refused with 403 PERMISSION_DENIED (ADR-0096 D5), that a run id is provenance and not a principal, and, for the flowRunId field, what the automation engine really hands the engine.
  • content/docs/permissions/access-recipes.mdx line 52: a schedule-triggered run under runAs user is refused, not run unscoped.
  • The changeset gains one sentence naming the three passages (still @objectstack/spec patch, Clause-② no).

Measured before written, on origin/main at 28bff18 (the merge base of this round): packages/services/service-automation/src/runtime-identity.ts, resolveRunDataContext (line 178). With runAs system it returns isSystem true plus actor svc:flow:NAME, empty positions and permissions, the run id, and the trigger userId and tenantId only when the trigger resolved them (lines 180-218). With runAs user and no trigger user, which is what a schedule run has, it throws UnscopedRunDataAccessError (code AUTOMATION_UNSCOPED_RUN_DATA_ACCESS) at line 231, before any call reaches the data engine. With a trigger user it returns that user identity (line 233 on). engine.ts resolveRunContext (lines 6057 and 6062) stamps runAs (default user) and flowRunId on the run context. Nothing in service-automation builds a flowRunId-only context: the RunProvenanceContext type is exported but produced nowhere. Pinned by src/builtin/crud-runas.test.ts (24 tests pass; lines 221, 247, 288 and 316: a user-mode run with no user throws, a schedule run reaches the data layer with zero operations and fails, a record-change run fired by a user-less write is refused the same way).

Verification on head b7cc3e2: spec build exit 0; the old sentences are in no file of packages/spec/dist (.d.ts, .d.mts, .js, .mjs: 0 files); the type TSDoc lands in manifest.zod-*.d.ts and .d.mts, the field TSDoc in the kernel, data, api and browser JS bundles only (a field comment is not emitted into a declaration); spec typecheck exit 0; check:docs exit 0; dispatch-gates --ran: 102 derived, 101 run, 1 NOT-MEASURED (check:dual-build-cjs-loads, exit 3 PREREQUISITE NOT MET, needs a repo-wide build, left to Build Core). Line budget against the merge base: 101 changed lines (68 added, 33 removed), 6 files, 0 governed paths. This round the comments do ship in JS bundles, so the round-0 statement that the diff emits no JavaScript no longer holds; the gate stays NOT MEASURED on the basis that every changed line in the three .ts files is a comment line.

Acceptance notes added: (1) skills/objectstack-query/SKILL.md line 66 still reads that flowRunId is passed for provenance alone; it is a governed Tier H file, not edited, needs its own carrier. (2) packages/services/service-automation/src/runtime-identity.ts lines 56-81 and engine.ts lines 6042-6046 still describe the pre-refusal middleware skip, and the RunProvenanceContext export has no producer; noted, not edited, carrier none. (3) contracts/analytics-service.ts about line 441 stays, as the seat ruled: past-tense history.

CI note, not caused by a test: Test Core 6 of 6 fails at the step Check this shard timing drift (exit 1) on this PR at both 4363c58 and b7cc3e2, with the shard test step before it passing; the same shard is green on the current origin/main push run. The job log is not reachable from this container (the log blob host is refused), so the drift numbers are NOT MEASURED here.

Merge-main round (head 7c79aade4)

#22014's shard-timings refresh (PR #22368) unblocked Test Core (6/6)'s drift step, so main (27a8b33de, 53 commits past the earlier base 28bff18d0) was merged with scripts/pm/os-regen-merge.sh. Zero files conflicted.


Generated by Claude Code

…the ADR-0096 D5 refusal of a principal-less context

The TSDoc on getReadFilter, canExport and canReadObject said a context that
carries no principal at all keeps its composed scope or is admitted, as the
security middleware hands it through. Since ADR-0096 D5 strict mode that
context gets the deny filter, false, and 403 PERMISSION_DENIED. The
data-engine contract page taught `{ flowRunId }` alone as an admitted
context; it now names the two admitted contexts and the refusal.

Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation 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 1 package(s): @objectstack/spec, touching 3 documentable anchor(s).

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

  • content/docs/kernel/contracts/index.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/kernel/runtime-services/index.mdx (via ISecurityService (symbol, a top-level interface))

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

  • content/docs/releases/v17/17-0.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/releases/v17/17-5.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/releases/v17/17-6.mdx (via ISecurityService (symbol, a top-level interface))
  • content/docs/releases/v17/17-7.mdx (via ISecurityService (symbol, a top-level interface))

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
  • 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 — 139 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 3054516ef1f16f0e5839cb33d13c0c9d5f8ab84f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 3054516ef1f16f0e5839cb33d13c0c9d5f8ab84f

⚠️ 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 3054516ef1f16f0e5839cb33d13c0c9d5f8ab84f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

CI on 4363c583d: Test Core (6/6) red on its timing-drift step, not on a test · domain:spec seat 2 (#18549) · session session_01DhTqaEHqPVSVnAkjG3jywn · 2026-10-08T17:44Z.


Generated by Claude Code

claude added 2 commits October 8, 2026 18:05
…oc no longer teach a flowRunId-only context as admitted

The three passages said a flow run that resolves no principal passes
exactly { flowRunId }, a context that authorizes like no context and keeps
the unscoped posture. Since ADR-0096 D5 that context is refused with 403
PERMISSION_DENIED, and the automation engine never builds it: a data node
of a user-mode run with no trigger user is refused at resolveRunDataContext,
and a system-mode run passes isSystem: true. access-recipes.mdx said the same
run "runs unscoped"; it is refused.

Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b7cc3e2178f4881d57785baa3d20e125df7e0055
Local-runs: none

Inputs read: card #22302 (body and all 7 comments, triage 6061436428 through ACCEPT 6067117343), PR #22327 (body, 6-file list, net diff against merge base 28bff18d0), the 42 check-runs on the head, and the governing text on origin/main (3599fef12): AGENTS.md, ADR-0096 D5 and its dated note, plugin-security/src/security-plugin.ts, service-automation/src/runtime-identity.ts. Scratch: pr-22327/ only. Read at 2026-10-08T19:15Z.

① Derived judgments

No accept-set change and no public-surface change. Every hunk in the three .ts files sits inside a /** … */ block (33 + 26 + 12 changed lines, 0 outside a comment); the other three files are two docs pages and the changeset. No schema, export, type or .describe() string moves. Check Changeset, Lint & Repo Gates (check:docs, check:api-surface), Build Core, Build Docs and every type-check lane are green on the head.

Each rewritten sentence, judged against the code on origin/main:

Sentence (head) Judged against Verdict
security-service.ts:316-319 getReadFilter: a non-system context with no principal gets the DENY filter before any layer composes a scope security-plugin.ts:5942-5946: isSystem → undefined; isPrincipalLessContext → { ...RLS_DENY_FILTER } ahead of resolveSharingReadFilter right
security-service.ts:631-634 canExport: false before anything resolves security-plugin.ts:6774-6777: isSystem → true; isPrincipalLessContext → false before resolvePermissionSetsForContext right
security-service.ts:653-659 canReadObject arm list: isSystem, the D5 refusal, the D2 deny baseline, unresolved posture, the D3 AND-gate, allowRead, the D10 delegator security-plugin.ts:6380-6420: arms 1–6 in that order (isSystem, isPrincipalLessContext, permissionSets.length === 0, unresolved, capability AND-gate, CRUD grant, delegator) right
security-service.ts:673-676 canReadObject fails-closed paragraph same site right
execution-context.zod.ts:334-343 flowRunId: never a principal; { flowRunId } alone refused 403 PERMISSION_DENIED; the automation engine builds no such context; a runAs:'user' run with no trigger user is refused AUTOMATION_UNSCOPED_RUN_DATA_ACCESS before the data engine; a runAs:'system' run passes isSystem: true with the id beside it isPrincipalLessContext (security-plugin.ts:383-387) keys on positions/permissions/userId after the isSystem bypass; resolveRunDataContext (runtime-identity.ts:178-241): system branch returns isSystem: true, actor, positions: [], permissions: [], flowRunId; !context?.userId throws UnscopedRunDataAccessError (code = 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS'); every data node calls it (crud-nodes.ts:484/572/720/812, plugin.ts:1248); no producer of a { flowRunId }-only engine context in packages/** (git grep) right
execution-context.zod.ts:502-507 the ExecutionContext type: any subset is legal input, not every subset admitted; the two admitted contexts ADR-0096 D5 dated note, word for word on the refusal, the three principal fields and the isSystem: true opt-in right
data-engine.zod.ts:67-72 BaseEngineOptionsSchema.context same right
data-engine.mdx:128-135 ExecutionContext passage: refused for every verb, { flowRunId } alone is such a context the one engine middleware (security-plugin.ts:2654) throws principalLessDenial for every operation; the ADR note says "every verb" right
access-recipes.mdx:52: a schedule-triggered runAs: 'user' run's data operations are refused UnscopedRunDataAccessError message prescribes runAs: 'system' "to make the elevation explicit and intended"; hooks.mdx:176 already says the same right

Sentences the diff leaves in the touched files, re-read: security-service.ts:203 (narrowed: false for a principal-less context), :703, :723, :759, :803 (fail closed / abstain for a principal-less context) stay true and are now the stronger reading. :390 (canReadObject is "the deny baseline for a caller that carries a principal") is incomplete, not false: the D5 arm answers the other caller false too. execution-context.zod.ts:57-58 ("a context carrying only [attributedUserId] authorizes exactly like a context carrying nothing — ANONYMOUS") stays true: both are refused. Nothing left in the five files states the hand-off. content/docs on origin/main holds no other "carries no principal" / "provenance alone" / "runs unscoped" sentence about a principal (the flows.mdx:2387 hit is tenant scoping under the single posture, a different subject).

One imprecision, not a defect: the changeset's "The corrected text ships in the published .d.ts" holds for the ISecurityService and the ExecutionContext type TSDoc; the flowRunId field and BaseEngineOptionsSchema.context comments sit inside z.object({}) literals and ship in the JS bundles, as the dev's round-1 measurement says. The changeset names the right surface for the text its title is about.

② Semver level

.changeset/22302-security-service-principal-less-text.md: @objectstack/spec: patch, Clause-②: no, no arm — matches the diff. Comment text in a released package's .d.ts and JS bundles publishes, so skip-changeset would be wrong (AGENTS.md Post-Task Checklist §3), and nothing is added, widened or narrowed, so minor would over-declare. No ADR-0087 marker is owed (not breaking). content/docs/** is not an npm surface. PR body carries Clause-②: no, the same line. Check Changeset green on the head.

③ Boundary flags

open_questions: [] in both dev reports (6065357115, 6067044558). Dev flags and acceptance notes, each answered:

  1. Public-form-grant edge (a hand-built { publicFormGrant: { object } } with no named set is admitted for create / read-back by the middleware, while the three probes deny it). Answered, no escalation: publicFormGrant is not a field of ExecutionContextSchema (no hit in packages/spec/src/kernel/execution-context.zod.ts); it is an ADR-0056 declaration-derived admission on a server-constructed key whose only producer (rest-server.ts:11200-11202) carries permissions: ['guest_portal'], so it carries a principal and is outside the class the new text describes. The contract text is correct for the published context shape. Stays in Acceptance notes.
  2. skills/objectstack-query/SKILL.md:66 ("flowRunId for provenance alone"). Tier H, not editable here; the seat's ACCEPT (6067117343) owes the Tier H card after the merge. Answered: that card is the carrier; it is owed, not optional.
  3. Stale sibling comments, "carrier none" — service-automation/src/runtime-identity.ts:56-81, service-automation/src/engine.ts:6042-6046, and (found here) objectql/src/engine.ts:5393 ("{ flowRunId }, all an identity-less flow run has"). Escalated. RunProvenanceContext is exported from service-automation/src/index.ts:195 and listed in its README, so its docstring ("the empty-principal fall-open … indistinguishable from passing no context at all", present tense) is published contract text of @objectstack/service-automation that the runtime no longer honours — the same filing gate ① this card was filed on. It needs a domain:services card, not "carrier none". Not this PR's defect (outside the claim surface, another lane).
  4. analytics-service.ts:441-443: "That is exactly how security(analytics): ObjectQLStrategy 不消费 getReadScope — NativeSQL 回落后聚合查询无 RLS/租户谓词(#2852 修复未覆盖的另一半) #3597 happened … the security middleware's fall-open skipped its own RLS injection" is past tense, as the seat ruled. Answered: stays.
  5. check:dual-build-cjs-loads NOT MEASURED locally: Build Core is green on the head. Answered by the check-run.
  6. Red check-runs on the head, recorded as the brief requires: Test Core (6/6) — failure at step 14 Check this shard's timing drift (step 12 Run this shard's tests and step 15 Test completeness guard succeeded); Test Core (the aggregate) — failure at step 4 Verify test shard results, which follows from the shard. The drift readings are NOT MEASURED here (the job-log blob host is refused from this container); the dev and seat attribute them to the dataset tracked on finding(ci): the shard-timings dataset rests on ONE scheduled run, and records @objectstack/spec at 1134.86 s against 1573–1651 s executed — #16468's 25%-headroom ceilings built on it would red every PR that runs spec #22014. A comment-only diff cannot move shard timing, and the shard's tests passed. The seat's landing to-do already holds the PR until every check is green; this record does not green it.

Implemented-by: claude/issue-22302-security-service-principal-less-tsdoc
Reviewed-by: session_01DhTqaEHqPVSVnAkjG3jywn

VERDICT: PASS


Generated by Claude Code

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7c79aade4b1bb4ae31ab3b88f867b7b5039aad93
Local-runs: none

Inputs read: card #22302 (body and all 9 comments, triage 6061436428 through the merge-main dev report 6075540848), PR #22327 (body, 6-file list, net diff against the merge base 27a8b33de, which is the main this head merges and an ancestor of origin/main), the 35 check-runs on the head, and the merged tree itself at 27a8b33de / the head: plugin-security/src/security-plugin.ts, service-automation/src/runtime-identity.ts, ADR-0096 D5 and its dated note, the two pages the Docs Drift Check names, and the main commits #22357 (746637e51) and #22382 (191543456). Scratch: pr-22327/ only. Read at 2026-10-09T07:29Z. This record judges this head on its own; the record 6067252881 on b7cc3e217 is an input, not a carrier.

① Derived judgments

No accept-set change and no public-surface change. The head is a merge of 27a8b33de (53 commits past the previous base 28bff18d0) into b7cc3e217, with zero conflicted files, and its net delta against main is the same six files as before: +68 / −33, 101 changed lines. In the three .ts files (33 + 12 + 26 changed lines) every added and removed line is a comment line: a filter over the diff for a changed line that does not open with * or /** returns nothing. No schema, export, type or .describe() string moves; publicFormGrant is still not a field of ExecutionContextSchema (0 hits), and canWriteObject is still not a member of ISecurityService (0 hits), so the contract text rightly names only canReadObject and canExport. main touched none of the six paths between 28bff18d0 and 27a8b33de (git log on each path is empty), so the merge could not have made a sentence false by editing its file. Lint & Repo Gates, TypeScript Type Check, Build Core, Build Docs, Check Changeset and every type-check lane are success on the head.

Each sentence the PR states, judged against the merged main (27a8b33de), not against the earlier tree:

Sentence (head) Judged against Verdict
security-service.ts:316-319 getReadFilter: a non-system context with no principal gets the DENY filter, before any layer composes a scope, as the middleware's principal-less refusal answers it security-plugin.ts:5943 isSystem → undefined; :5947 isPrincipalLessContext → { ...RLS_DENY_FILTER }, ahead of the OWD/sharing predicate and every RLS branch right
security-service.ts:631-634 canExport: false before anything resolves security-plugin.ts:6778 isSystem → true; :6781 isPrincipalLessContext → false, before resolvePermissionSetsForContext right
security-service.ts:653-659 canReadObject arm list: isSystem, the D5 refusal, the D2 deny baseline, unresolvable posture, the D3 AND-gate, allowRead, the D10 delegator security-plugin.ts:6384-6420: arm 1 isSystem, arm 2 isPrincipalLessContext, permissionSets.length === 0, arm 3 unresolved, arm 4 capability AND-gate, arm 5 CRUD grant, the delegator intersection (resolved once ahead of arms 4 and 6, a dangling link denying there) right
security-service.ts:673-676 canReadObject fails-closed paragraph same site right
data-engine.mdx:128-135: refused with 403 PERMISSION_DENIED (PermissionDeniedError) before the operation runs, for every verb; { flowRunId } alone is such a context security-plugin.ts:2451 isSystem → next(); :2655-2656 isPrincipalLessContext(opCtx.context) → throw principalLessDenial(opCtx.operation, opCtx.object) in the one operation middleware, keyed on no verb; principalLessDenial (:398-406) constructs PermissionDeniedError with the two-remedy text; the D5 dated note says "every verb" and 403 PERMISSION_DENIED right
execution-context.zod.ts:334-343 flowRunId: never a principal; { flowRunId } alone refused; the automation engine builds no such context; a runAs:'user' run with no trigger user is refused AUTOMATION_UNSCOPED_RUN_DATA_ACCESS before the data engine; a runAs:'system' run passes isSystem: true with the id beside it isPrincipalLessContext (security-plugin.ts:383-387) keys on positions / permissions / userId; runtime-identity.ts after #22357: RunProvenanceContext is retired and RunDataContext extends RunIdentityContext (:103-108), so the type system now also says no provenance-only context exists; resolveRunDataContext returns isSystem: true (:172) with flowRunId beside it (:201) for system, and throws UnscopedRunDataAccessError (code = 'AUTOMATION_UNSCOPED_RUN_DATA_ACCESS', :77) at :219 when !context?.userId right, and stronger than at b7cc3e217
execution-context.zod.ts:502-507 the ExecutionContext type: any subset is legal input, not every subset admitted; the two admitted contexts ADR-0096 D5 dated note at head, word for word on the refusal, the three principal fields and the isSystem: true opt-in right
data-engine.zod.ts:67-72 BaseEngineOptionsSchema.context same note; the same five predicate sites right
access-recipes.mdx:52: a schedule-triggered runAs: 'user' run's data operations are refused; declare system to make the elevation explicit and intended runtime-identity.ts:219 throws before the engine is called; hooks.mdx:176 on the merged tree says the same ("refuses its data operations instead of running them with no principal at all") right

The two main commits that restate the rule agree with this PR, sentence for sentence. #22357 (746637e51, domain:services) retired RunProvenanceContext and rewrote 24 pre-D5 comment sites outside packages/spec to say the middleware "used to" hand the context through and that D5 refuses it; its security-plugin.ts hunk is comment text only (getMetadataReadableFields docstring), so the five predicate sites this PR describes are the code the earlier head was judged on. #22382 (191543456) rewrote skills/objectstack-query/SKILL.md:65-70 to "{ isSystem: true } for a system read, otherwise the caller's own context (user, position or permission set), or 403 PERMISSION_DENIED (ADR-0096 D5)", the same two admitted contexts and the same refusal this PR names. Neither contradicts a sentence in the diff, and neither touches a PR file.

The Docs Drift Check pages, re-read on the merged tree. content/docs/kernel/contracts/index.mdx:43 is one table row: ISecurityService is "Query surface for access decisions — row-level read scope, readable-field projection, effective permission sets, access explanation". content/docs/kernel/runtime-services/index.mdx:66-69 says only that the interface "is listed with the other kernel contracts". Neither states who is admitted or what a principal-less context gets, so neither contradicts this PR; neither needs an edit.

Sweep of the merged tree for leftover hand-off wording, outside releases/ and references/: content/docs yields hooks.mdx:176 (agrees), flows.mdx:2393 ("timeRelative sweep runs unscoped" is organization scoping under the single posture, another subject), forms.mdx:47 ("never the anonymous fall-open", a negation, true) and tenant-audit-census.mdx:124 (a spread literal, another subject). packages/spec/src yields the two new sentences, security-service.ts:803 (a principal-less context abstains, true), analytics-service.ts:442 (past tense, the seat's ruling stands) and :502 (an absent getReadScope hook on an analytics strategy, not an engine context). skills/ yields nothing now that #22382 is in the tree. Nothing left in the merged tree states the hand-off in the present tense.

One imprecision, carried over, not a defect: the changeset's "The corrected text ships in the published .d.ts" holds for the ISecurityService and ExecutionContext type TSDoc; the flowRunId field and BaseEngineOptionsSchema.context comments sit inside z.object({}) literals and ship in the JS bundles, as the dev measured at b7cc3e217 and again at this head. The changeset names the right surface for the text its title is about.

② Semver level

.changeset/22302-security-service-principal-less-text.md: @objectstack/spec: patch, Clause-②: no, no arm — matches the diff. Comment text in a released package's .d.ts and JS bundles publishes, so skip-changeset would be wrong (AGENTS.md Post-Task Checklist §3); nothing is added, widened or narrowed, so minor would over-declare. No ADR-0087 marker is owed (not breaking). content/docs/** is not an npm surface. The PR body carries Clause-②: no, the same line. Check Changeset is success on the head. The merge brought no second changeset onto the branch and the delta still holds exactly one.

③ Boundary flags

open_questions: [] in all three dev reports (6065357115, 6067044558, 6075540848); out_of_scope_findings: [] in the merge-main report. The flags the earlier record carried are re-judged on this head, and the merge-main report's own notes are answered:

  1. Public-form-grant edge (a hand-built { publicFormGrant: { object } } with no named set is admitted by the middleware's ADR-0056 branch, which sits after isSystem and ahead of the D5 refusal, while the three probes deny it). Answered, no escalation: publicFormGrant is not a field of ExecutionContextSchema at this head (0 hits), so the published context shape has no such key, and the contract text is correct for the shape it documents. Stays in the PR's Acceptance notes, carrier none.
  2. skills/objectstack-query/SKILL.md:66 (Tier H, owed as a post-merge card by the ACCEPT 6067117343). Resolved on main: docs(skills): objectstack-query names the context the engine admits since ADR-0096 D5 #22382 (191543456) landed the correction, and the merged tree carries it. The post-merge Tier H card is no longer owed.
  3. RunProvenanceContext docstring (the earlier record's escalation, filed as service-automation: the exported RunProvenanceContext docstring still says a { flowRunId }-only context falls open ("indistinguishable from passing no context at all"); ADR-0096 D5 now refuses it, and the type is produced nowhere #22345). Resolved on main: service-automation: the exported RunProvenanceContext docstring still says a { flowRunId }-only context falls open ("indistinguishable from passing no context at all"); ADR-0096 D5 now refuses it, and the type is produced nowhere #22345 is closed by fix(service-automation)!: retire RunProvenanceContext; pre-D5 principal-less readings outside packages/spec say D5 refuses it #22357 (746637e51), which retired the type and rewrote the sibling sites in runtime-identity.ts and engine.ts. The merged tree carries it, and the flowRunId TSDoc in this PR is now backed by the type as well as the code.
  4. analytics-service.ts:441-443: past tense ("That is exactly how security(analytics): ObjectQLStrategy 不消费 getReadScope — NativeSQL 回落后聚合查询无 RLS/租户谓词(#2852 修复未覆盖的另一半) #3597 happened"), the seat's ruling. Answered: stays.
  5. check:dual-build-cjs-loads NOT MEASURED locally (exit 3, needs a repo-wide build): Build Core is success on the head. Answered by the check-run.
  6. Test Core (6/6) timing-drift red at the two earlier heads: Test Core and all six shards are success on this head; the shard-timings dataset refresh (chore(ci): refresh the Test Core shard-timings dataset #22368) is in the merged tree. Answered by the check-runs.
  7. Two main commits after the merge, not merged (86ae11992 fix(cli): with no built Console, /_console/ answers 503 naming the remedy instead of a bare 404 #22419, 3054516ef fix(cli): doctor's Environment files row names os serve / os start as its loaders in the source posture (#22249) #22417): both are packages/cli plus their changesets and tests, no path in common with this PR, and the merge queue rebuilds the PR onto current main anyway (AGENTS.md §7). Answered: no second merge lap is owed for this record.
  8. ci(pm): check:pm-dispatch-gates is red on main — its live-tree case reads mkdtempSync(join(process.cwd(), …)) in a #22365 dogfood test as an unresolved temp base #22422 (security-catalog-cold-boot-environment-holder.dogfood.test.ts:108), named by the dev as never in play: Dogfood Regression Gate and its three shards are success on this head. Not this diff.
  9. PR body prose: the "Verification" and "Line budget" sections still carry the round-0 numbers (4363c583d, 59 lines, 3 files); the round-1 addendum in the same body carries the current ones (101 lines, 6 files), and the merge-main round wrote no addendum. The file list and the diff are the record of what lands; this is prose staleness, not a contract defect, and a body rewrite is a footer hazard (AGENTS.md, PR body). Noted for the owning seat; not blocking.

Check-runs on the head: 33 success, 2 skipped (Console Pin Gate, no .objectui-sha change; Packed-tarball smoke, opt-in), 0 failure. The seven required contexts are all success. Governed surfaces: none of the six paths; Governed Surface Queue Guard success. Head repo equals base repo; 101 changed lines. The PR is still a draft: landing is the owning seat's act after this record, as the ACCEPT's to-do states.

Implemented-by: claude/issue-22302-security-service-principal-less-tsdoc
Reviewed-by: session_01DhTqaEHqPVSVnAkjG3jywn

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 07:32
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 9, 2026 07:32
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit 081e6a0 Oct 9, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22302-security-service-principal-less-tsdoc branch October 9, 2026 07:57
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 protocol:data size/m tooling

Projects

None yet

2 participants