Skip to content

Commit 081e6a0

Browse files
docs(spec): ISecurityService and the data-engine contract page state the ADR-0096 D5 refusal of a principal-less context (#22327)
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. - **The PR's delta is unchanged:** the same six files, +68 / −33, 101 changed lines. The three `.ts` files change comments only (`stripComments` comparison, `27a8b33de` vs head). - **No sentence went false.** `main` touched none of the six paths, and #22357 / #22382 restate the same principal-less rule in agreement. - **Measured:** `@objectstack/spec` unit, `local` project 627 files / 18757 passed and `repo` project 54 / 915. 102 derived gates ran: 101 exited 0, and `check:dual-build-cjs-loads` was left to CI's Build Core (comment-only diff). - **CI on this head:** 33 success, 2 skipped. - **Review:** the fresh contract review **PASS** `6076493127`. The Verification and Line budget sections above keep the round-0 numbers for the record. --- _Generated by [Claude Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 05c7c3f commit 081e6a0

6 files changed

Lines changed: 68 additions & 33 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): `ISecurityService` no longer says a context with no principal is admitted or keeps its scope
6+
7+
Clause-②: no
8+
9+
The contract text on `ISecurityService.getReadFilter`, `ISecurityService.canExport` and `ISecurityService.canReadObject` (its verdict's arm list and its fails-closed paragraph) said that a context carrying no principal at all keeps the scope the other layers compose for it, or is admitted, as the security middleware hands it through. Since ADR-0096 D5 strict mode `plugin-security` does neither. A non-system context with no position, no named permission set and no user id now gets `false` from `canReadObject` and `canExport`, the deny filter from `getReadFilter`, and `403 PERMISSION_DENIED` (`PermissionDeniedError`) from the engine middleware's principal-less refusal. The text now says so, at each of the three methods, and cites ADR-0096 D5.
10+
11+
The TSDoc on `ExecutionContext.flowRunId` and on the `ExecutionContext` input type (`kernel/execution-context.zod.ts`), and on `BaseEngineOptionsSchema.context` (`data/data-engine.zod.ts`), no longer teaches `{ flowRunId }` alone as an admitted, unscoped context: it carries no principal and is refused like any other.
12+
13+
Only comments change. No behaviour, export or type moves, and the system bypass and the ADR-0056 D2 deny baseline for a caller that carries a principal and resolves no permission set read as before. The corrected text ships in the published `.d.ts`.
14+
15+
What changes for you: nothing at runtime. If your code read the old text and passes the engine a context that carries neither the caller's principal nor `isSystem: true`, it is refused; pass the caller's own context or, for platform code whose own door already authorized the caller, the explicit system opt-in `{ isSystem: true }`.

‎content/docs/kernel/contracts/data-engine.mdx‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,16 @@ interface EngineQueryOptions {
123123
```
124124

125125
`ExecutionContext` is the author state — every field optional (the parse result,
126-
with defaults applied, is `ExecutionContextParsed`). Supply what you have, the engine reads what it needs. A system read passes
127-
`{ isSystem: true }`; an automation run with no resolvable identity passes only
128-
its run id (`{ flowRunId }`), a context that deliberately carries no principal.
126+
with defaults applied, is `ExecutionContextParsed`). Supply what you have, the engine reads what it needs.
127+
128+
What it needs is a principal or an explicit elevation. Pass the caller's own
129+
context (a user id, a position or a named permission set), or, for platform code
130+
whose own door already authorized the caller, the system opt-in
131+
`{ isSystem: true }`. A context that carries neither is refused by the security
132+
plugin with `403 PERMISSION_DENIED` (`PermissionDeniedError`) before the operation
133+
runs, for every verb (ADR-0096 D5). `{ flowRunId }` alone is such a context: a run
134+
id is provenance, not identity, and grants nothing, so a context that carries it
135+
must also carry the caller's principal or `isSystem: true`.
129136

130137
#### Removed in protocol 17: `cursor` and `distinct`
131138

‎content/docs/permissions/access-recipes.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ Two separate questions:
4949
- `runAs: 'user'` (default) — runs as the triggering user; CRUD nodes respect that user's RLS. **Safer default.**
5050
- `runAs: 'system'` — elevated, bypasses RLS. Make elevation *explicit*, and surface it in the UI as "runs as system".
5151

52-
A schedule-triggered run has no user, so under `user` it runs unscoped — declare `system` to make that intentional.
52+
A schedule-triggered run has no user, so under `user` its data operations are refused — declare `system` to make the elevation explicit and intended.
5353

5454
### Interface: gate the app, the nav, and the action
5555

‎packages/spec/src/contracts/security-service.ts‎

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -313,9 +313,10 @@ export interface ISecurityService {
313313
* **The deny baseline (ADR-0056 D2).** A non-system caller that carries a
314314
* principal (a position, a named permission set or a user id) and resolves
315315
* NO permission set gets the same DENY filter: an empty set list grants no
316-
* row, as it grants no object ({@link canReadObject}). Only a context that
317-
* carries no principal at all keeps the scope the other layers compose for
318-
* it (ADR-0096 stages that context separately).
316+
* row, as it grants no object ({@link canReadObject}). So does a non-system
317+
* context that carries no principal at all (ADR-0096 D5 strict mode): the
318+
* DENY filter, before any layer composes a scope, as the engine middleware's
319+
* principal-less refusal (`403 PERMISSION_DENIED`) answers the same context.
319320
*
320321
* **⚠️ Request-scoped: call it per request, and never memoise what it
321322
* returns.** The documented use — `engine.find(object, { where: await
@@ -627,8 +628,10 @@ export interface ISecurityService {
627628
* "allowed". A system context bypasses and returns `true`. A caller that
628629
* carries a principal and resolves no permission set gets `false` — the
629630
* ADR-0056 D2 deny baseline, mirroring the middleware, whose CRUD gate
630-
* refuses that caller's read; only a context that carries no principal at
631-
* all is admitted, as the middleware hands it through (ADR-0096).
631+
* refuses that caller's read. So does a non-system context that carries no
632+
* principal at all (ADR-0096 D5 strict mode): `false` before anything
633+
* resolves, the answer the middleware's principal-less refusal
634+
* (`403 PERMISSION_DENIED`) gives it.
632635
*/
633636
canExport(object: string, context?: SecurityContext): Promise<boolean>;
634637

@@ -647,13 +650,13 @@ export interface ISecurityService {
647650
* deployment. Any door that bypasses the engine middleware MUST ask both.
648651
*
649652
* The verdict is the middleware's own read gate, arm for arm and in its order:
650-
* the `isSystem` bypass, the ADR-0056 D2 deny baseline for a caller that
651-
* carries a principal and resolves no permission set (only a context that
652-
* carries no principal at all is handed through), the
653-
* fail-closed refusal on an unresolvable object posture, the ADR-0066 D3
654-
* `requiredPermissions` capability AND-gate, the `allowRead` CRUD grant, and
655-
* the ADR-0090 D10 delegator intersection for an on-behalf-of caller. It is
656-
* computed from the SAME resolution the enforcement path uses — never
653+
* the `isSystem` bypass, the ADR-0096 D5 refusal of a context that carries no
654+
* principal at all (`false`, before anything resolves), the ADR-0056 D2 deny
655+
* baseline for a caller that carries a principal and resolves no permission
656+
* set, the fail-closed refusal on an unresolvable object posture, the
657+
* ADR-0066 D3 `requiredPermissions` capability AND-gate, the `allowRead` CRUD
658+
* grant, and the ADR-0090 D10 delegator intersection for an on-behalf-of
659+
* caller. It is computed from the SAME resolution the enforcement path uses — never
657660
* re-derived from permission sets by the caller — so a door that asks reaches
658661
* the same admission verdict `/data` reaches, by construction.
659662
*
@@ -667,8 +670,10 @@ export interface ISecurityService {
667670
* "allowed". A system context bypasses and returns `true`. A caller that
668671
* carries a principal and resolves no permission set gets `false` — the
669672
* ADR-0056 D2 deny baseline, mirroring the middleware, whose CRUD gate
670-
* refuses that caller; only a context that carries no principal at all is
671-
* admitted, as the middleware hands it through (ADR-0096).
673+
* refuses that caller. So does a non-system context that carries no
674+
* principal at all (ADR-0096 D5 strict mode): `false` before anything
675+
* resolves, the answer the middleware's principal-less refusal
676+
* (`403 PERMISSION_DENIED`) gives it.
672677
*
673678
* **OPTIONAL, and absence is a defined state — not a bug.** A security service
674679
* that predates this method omits it, and a consumer resolving the service as

‎packages/spec/src/data/data-engine.zod.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,13 @@ export const BaseEngineOptionsSchema = lazySchema(() => z.object({
6464
* `ExecutionContextSchema` gives `positions`/`permissions`/`isSystem`
6565
* parse-time defaults, which makes them REQUIRED in its inferred output
6666
* type. On a caller-supplied option that asserts something untrue: that
67-
* every data-engine context carries a principal. Callers routinely pass a
68-
* slice — `{ isSystem: true }` for a system read — and a flow run that
69-
* resolves no identity passes provenance alone (`{ flowRunId }`, #3712), a
70-
* context deliberately carrying no principal at all. `.partial()` states the
71-
* real contract: supply what you have, the engine reads what it needs.
67+
* every data-engine context carries a principal. A system context does not:
68+
* callers routinely pass a slice, `{ isSystem: true }` for a system read.
69+
* Every other context must carry the caller's principal. The security plugin
70+
* refuses a non-system context with no user id, no position and no named
71+
* permission set (`{ flowRunId }` alone included) with `403 PERMISSION_DENIED`
72+
* (ADR-0096 D5). `.partial()` states the real contract: supply what you have,
73+
* the engine reads what it needs.
7274
*/
7375
context: ExecutionContextSchema.partial().optional(),
7476
}));

‎packages/spec/src/kernel/execution-context.zod.ts‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -331,13 +331,16 @@ export const ExecutionContextSchema = lazySchema(() => z.object({
331331
* its own target record, which `isSystem` cannot express for a
332332
* `runAs:'user'` run without elevating it.
333333
*
334-
* It may be the ONLY populated field of the envelope. A run that resolves no
335-
* principal (a schedule-triggered `runAs:'user'` run) passes exactly
336-
* `{ flowRunId }`: every principal gate keys on `isSystem`/`userId`/
337-
* `positions`/`permissions`, so such a context authorizes identically to no
338-
* context at all, and the run keeps the #1888 unscoped posture it already had
339-
* while becoming attributable (#3712). Surfaced to hooks as
340-
* `HookContext.provenance`, deliberately not folded into `session`.
334+
* It is never a principal, so a context carrying `{ flowRunId }` alone is
335+
* not admitted: every principal gate keys on `isSystem`/`userId`/`positions`/
336+
* `permissions`, and a non-system context with none of the principal fields
337+
* is refused with `403 PERMISSION_DENIED` (ADR-0096 D5). The automation
338+
* engine builds no such context. A data node of a run that resolves no
339+
* principal (a schedule-triggered `runAs:'user'` run) is refused with
340+
* `AUTOMATION_UNSCOPED_RUN_DATA_ACCESS` before the data engine is called, and
341+
* a `runAs:'system'` run passes `isSystem: true` with this id beside it.
342+
* Surfaced to hooks as `HookContext.provenance`, deliberately not folded into
343+
* `session`.
341344
*/
342345
flowRunId: z.string().optional(),
343346

@@ -496,9 +499,12 @@ export const ExecutionContextSchema = lazySchema(() => z.object({
496499
* caller rather than out of a parse — `options.context` and everything the
497500
* engine threads it into.
498501
*
499-
* Some callers have no principal to state at all: a system read passes
500-
* `{ isSystem: true }`, and a flow run with no resolvable identity passes
501-
* provenance alone (`{ flowRunId }`, #3712).
502+
* Any subset is legal input, but not every subset is admitted. A non-system
503+
* context that carries no principal (no user id, no position, no named
504+
* permission set) is refused by the security plugin with `403 PERMISSION_DENIED`
505+
* (ADR-0096 D5); a run id alone (`{ flowRunId }`) is provenance, not a
506+
* principal, and is refused the same way. A caller acting for no user passes the
507+
* explicit system opt-in: a system read passes `{ isSystem: true }`.
502508
*
503509
* Spelled `ExecutionContextInput` until protocol 17; ADR-0122 phase 2 moved the
504510
* author state onto the bare name and retired that synonym.

0 commit comments

Comments
 (0)