Repository navigation
Commit f5ef054
feat(core): resolveRequestExecutionContext, the one request-to-ExecutionContext composition; the runtime dispatcher's door calls it (#24248)
Part of #23217
Clause-②: yes (widening)
Ruling-ref: 6227928430
Slice 1 of #23217 under ruling record 6227928430 (letter A): the one
request-to-ExecutionContext function in `@objectstack/core`, plus the
runtime dispatcher's own door. #23217 remains open for the other four
doors (`rest-server.ts`, `service-settings`, `service-storage`,
`plugin-hono-server`), each a later slice with `Clause-②: no`. No door's
outward behaviour changes, and there is no app-author surface.
## What changed
- **`@objectstack/core`**: new
`packages/core/src/security/request-execution-context.ts` with
`resolveRequestExecutionContext`, exported from core's `.` entry through
`security/index.ts`, beside `classifyAdmissionTenancyPosture`. Its
docblock states the two choices (the anonymous entry, the ADR-0069 gate)
and every named parameter with the door that needs it.
- **`@objectstack/runtime`**: `resolveExecutionContext`
(`packages/runtime/src/security/resolve-execution-context.ts`) keeps
what is its own (the service lookups, the header normalisation, the
`/mcp` OAuth verification and scope interpretation) and hands the rest
to the function. 209 lines out, 87 in.
- **Ruling item 4**:
`packages/runtime/src/security/request-execution-context.conformance.test.ts`,
one row per converted door.
- **Unit tests**:
`packages/core/src/security/request-execution-context.test.ts`, 20
cases, one battery per choice and per parameter, each reading paired
with its control.
- **Two files outside the claim's surface, both forced by the move**
(named for the seat to declare):
- `packages/core/src/security/authz-store-unavailable.test.ts`: its
transport ledger is set-equal to the files that call
`resolveAuthzContext({`. That call moved from the runtime door into the
new core module, which is ledgered `propagates`.
- `scripts/check-single-authz-resolver.mjs`: check (2) required the
runtime door to contain `resolveAuthzContext`, and after the move only
its docblock prose would have satisfied that. `DELEGATORS` now names
each delegator's call: rest and the new core module call
`resolveAuthzContext`, and the runtime door calls
`resolveRequestExecutionContext`. The self-test drives the
non-delegating case for every row.
- **Changeset**: `minor` on `@objectstack/core`. Runtime has none: no
byte a reader sees moves.
## The exported signature (for the seat's review of the new export)
This is verbatim except for one change: `‹` and `›` stand for the angle
brackets, because the body sanitizer strips tag-shaped fragments. The
byte-exact text is
`packages/core/src/security/request-execution-context.ts` lines 84-166
and 214-221 at `f5a955e12`.
```ts
export type RequestAuthService = Pick‹
IAuthService,
'api' | 'getApi' | 'isAuthGateActive' | 'resolveUserAuthGates' | 'resolveApiKeyLifecyclePolicy'
›;
export interface EstablishedRequestSession {
user: { id: string };
session: { userId: string; activeOrganizationId?: string };
}
export interface RequestAdmission {
credential: 'api_key' | 'session';
authGate: AuthGate | undefined;
}
export interface RequestExecutionContextInput {
headers: Headers;
ql: unknown;
authService: RequestAuthService | undefined;
tenancy: TenancyServiceResolver | undefined;
anonymous: 'fail-closed' | 'guest';
authGate: 'withhold' | 'resolved' | 'reread-when-active';
credentials: 'api-key-and-session' | 'session-only';
sessionApi: 'optional' | 'required';
session: 'request' | 'none' | { readonly established: EstablishedRequestSession };
localization: 'withhold' | { readonly settings: () => unknown };
requestLocale: string | undefined;
accessToken: 'carry' | 'withhold';
oauth: OAuthTokenProvenance | undefined;
onAdmission: ((admission: RequestAdmission) => void) | undefined;
}
export function resolveRequestExecutionContext(
input: RequestExecutionContextInput & { anonymous: 'guest' },
): Promise‹ExecutionContext›;
export function resolveRequestExecutionContext(
input: RequestExecutionContextInput,
): Promise‹ExecutionContext | undefined›;
```
Every key is required, as on `ExecutionContextAssemblyInput`: a door
with no value passes `undefined` or the withholding literal, so no door
can drop an input by leaving it out. The function catches nothing around
the resolver. An unreadable permission store, tenancy service or
active-gate re-read (`AuthzStoreUnavailableError`, 503), and an API key
its own lifecycle refuses (`ApiKeyLifecycleRefusalError`, 403), reach
the door's own net.
Two notes on the name, for the seat. A runtime-internal helper of the
same name was deleted in the GraphQL retirement
(`packages/spec/CHANGELOG.md`, "the now-dead
`resolveRequestExecutionContext` helper removed"). It was never
exported, and this export is unrelated. The ruling's other name
candidates are open if the seat prefers one.
## H1: the door differences, counted before slice 1 was written
(`origin/main` `7be3cc728`)
Every door was read one step at a time: the session read, the ADR-0069
gate, `resolveAuthzContext`, the posture classification, the anonymous
entry, and any extra fields.
| # | step | rest `rest-server.ts` `computeExecCtx` | runtime
`resolve-execution-context.ts` | settings `settings-service-plugin.ts`
`verifiedContextFromRequest` | storage `storage-service-plugin.ts`
`buildFileReadAuthorizer` + `buildAuthGateSessionReader` | hono
`current-user-endpoints.ts` `makeExecutionContextResolver` | named
parameter |
|:--|:--|:--|:--|:--|:--|:--|:--|
| 1 | anonymous entry | fail-closed (no context, 401) | guest envelope +
guest anchor grants | fail-closed (`{ enforced: true }`) | fail-closed
(`'unauthenticated'`) | fail-closed (no context) | `anonymous` |
| 2 | ADR-0069 gate | key: the admission's posture; session: re-read
only while `isAuthGateActive()`, a failed re-read is 503 | withheld (the
dispatcher's `enforceAuthGate` reads the session; a key's answer leaves
through `onAdmission`) | `authz.authGate` from the admitting read, no
re-read | its own gate reader: active check, key arm, session re-read, a
failed re-read is 503 | withheld (`/me` routes are allow-listed for a
gated user) | `authGate` |
| 3 | credential doors | API key + session | API key + session | API key
+ session | API key + session | session only (`headers: undefined` to
the resolver) | `credentials` |
| 4 | no session API | no context (the key is not honoured) | resolves
(the key is honoured) | resolves | no auth service means no authorizer
(door-level); otherwise resolves | no context | `sessionApi` |
| 5 | session source | the auth service's in-process read | the same; on
`/mcp`, a verified token's session, or none for an unverifiable JWT |
the same | the same | the same (it drops a session with no `user.id`) |
`session` |
| 6 | tenancy posture | kernel `getServiceAsync`, or the provider | the
lookup thunk | kernel `getServiceAsync` when present | registry
`getServiceAsync` when present | none (the session's claim stands) |
`tenancy` (the resolution stays at the door, as #16013 left it) |
| 7 | admission channel | none | `onAdmission` | none | none | none |
`onAdmission` |
| 8 | localization | settings provider | settings lookup | none (no
envelope) | none (no envelope) | withheld (the endpoint reads the
cascade) | `localization` |
| 9 | request locale | header or `?locale` (`metaRequestLocale`) |
`Accept-Language` | none | none | none | `requestLocale` |
| 10 | session bearer | withheld | carried | none | carried (the raw
envelope) | withheld | `accessToken` |
| 11 | OAuth provenance | none | `/mcp` only | none | none | none |
`oauth` |
**Count: 11 differences, and every one is a named parameter. Zero doors
need the item-5 fallback, so the premise holds.**
The remaining differences are door-side. They are not the composition,
they stay at each door, and none of them is a copy:
- How a door turns its transport's headers into a Web `Headers`. The
function takes a `Headers`.
- How a door reaches its services.
- How a door answers a fault. The function propagates every fault.
- What a door adds after assembly: rest adds `__kernel`,
`__authEnvironmentId`, the per-request memo and the perf disclosure;
hono adds the perf disclosure.
- What a door does with the envelope: settings projects it to
`SettingsContext`, and storage hands it to `engine.find` as `context`.
## H2: the OAuth arm
The arm is split, measured as follows:
- **Stays at the door, ahead of the call.** Verification, and reading
the scopes in the AI vocabulary (`scopesToAgentPermissionSets`,
`MCP_OAUTH_SCOPE_ACTIONS`). Both need `@objectstack/spec/ai`, which core
deliberately does not depend on (see the `OAuthTokenProvenance` docblock
in `assemble-execution-context.ts`).
- **Enters the function as two named parameters.** `session` (`{
established }` for a verified token, `'none'` for an unverifiable JWT
bearer) and `oauth`.
#24137's pins, `resolve-execution-context.oauth-organization.test.ts`,
are green and unchanged (15 of 15).
## H3: the published surface
- Core's `.` entry reaches the new export through `export * from
'./security/index.js'` (`packages/core/src/index.ts:68`).
- No export census lists core's names. `classifyAdmissionTenancyPosture`
appears nowhere outside `src/`: 0 hits in `scripts/` and in package JSON
files.
- The published-surface gates the derivation named are in the gates
section below.
## H4: dependency direction
The new module imports:
- core-internal modules;
- `@objectstack/spec/kernel` and `@objectstack/spec/contracts`, types
only;
- `@objectstack/types` (`inProcessSessionReadInput`).
Both packages are already core dependencies, so no dependency was added.
Nothing in the module imports runtime, rest or a plugin.
## Ruling item 4: the conformance test
Each converted door is a row. A row gives the door's own entry and the
named parameters it declares. Every request is driven through both the
door and the function, and the two contexts must be equal. A principal
must also be the same principal at every door. The five requests were
chosen so that each parameter changes at least one answer: no
credential; a gated session user with a bearer and `Accept-Language`; an
API key; an ex-member's key under a walled posture; and an API key on a
host with no auth service. A CONTROL case shows that the fixture tells
the runtime door's parameters apart. Slice 2 adds its door as one more
row.
## Tests (HEAD `f5a955e12`, after merging `origin/main` `af284b05e`)
- `pnpm --filter @objectstack/core exec vitest run --project local`: 104
files, 2551 tests passed.
- The edited repo-project ledger, `vitest run --project repo
src/security/authz-store-unavailable.test.ts`: 40 passed.
- `pnpm --filter @objectstack/runtime exec vitest run --project local`:
413 files, 6318 passed and 19 skipped (VERDICT command-exit 0).
- The door pins and the dispatcher pins that drive the door, with the
verbose reporter, at `7667d9b4d` before the merge (the post-merge full
suite above includes them): 7 files, 111 tests passed. These are
`resolve-execution-context.test.ts` (39),
`resolve-execution-context.oauth-organization.test.ts` (15), the
conformance test (7), `http-dispatcher.mcp-oauth`,
`http-dispatcher.tenancy-posture-outage`, `http-dispatcher.keys` and
`in-process-session-renewal.pin`.
- `pnpm --filter @objectstack/core typecheck` and `pnpm --filter
@objectstack/runtime typecheck` both exit 0. The test layers are held at
their ledgers: core 4 files and 4 errors, runtime 27 files and 190
errors, unchanged.
**Ablation of choice 1** (at `7667d9b4d`). Declared direction: red. The
mutation makes the function always take the fail-closed entry: `return
assembleExecutionContext(assembly); // ABLATION-23217-anonymous-entry`.
- **Landing.** Through `scripts/ablation-replace.mjs`: anchor 1 to 0,
marker 0 to 1, blob `e3a17add` to `86ec4e1f`.
- **No dist step.** Runtime's vitest aliases `@objectstack/core` to
`../core/src/index.ts`, so no `dist/` step applies.
- **Result.** Core: 1 failed and 19 passed. The failing case is the
guest-envelope case of choice 1. Runtime: 10 failed and 36 passed. Nine
are door pins, among them "a sessionless request still yields the WHOLE
guest envelope" and the tenancy "POSITIVE CONTROL". The tenth is the
conformance CONTROL.
- **Restore.** The blob is `e3a17add` again, equal to HEAD, and `git
diff HEAD` is empty.
- **Reading.** The conformance equalities themselves stay green under
this ablation, by construction: both sides go through the one function.
The CONTROL case and the unit battery are what catch a defect in the
function.
## Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `f5a955e12` derived 84 commands. Results
follow in the os-dev-report.
The lane blind spot, `pnpm exec eslint --no-inline-config` over the 7
changed `.ts`/`.mjs` files, exits 0 with no output. `node
scripts/check-single-authz-resolver.mjs --self-test` and the gate itself
both exit 0 ("all 3 entry points delegate").
## Acceptance notes
- **Runtime door, one ordering difference no production lookup
reaches.** The door now looks up `auth` once, at the top. It used to
look it up three times: two lookups absorbed their faults, and the
gate-source lookup, which ran after the tenancy read, did not absorb a
synchronous throw. Every answer is the same for a lookup that resolves
or rejects. The answers differ only when the `auth` lookup throws
synchronously and a tenancy service that failed to build is also
present: the synchronous error now wins over the 503. The dispatcher's
lookup (`resolveService`) never throws.
- **The card's text.** #23217's title ("not a published export") and its
scope item 3 ("No new published export") still read as ruling C′ wrote
them. Letter A amended item 1 to an export from core's `.` entry. That
is a text fix for the seat, not a code question.
- **Storage (slice 4).** Storage hands the raw `ResolvedAuthzContext` to
`engine.find` as `context`. The function's envelope adds `isSystem:
false` and `principalKind: 'human'` and drops `credential`. Measured: no
objectql, plugin-security or plugin-sharing source reads
`context.credential`, and `principalKind` is read for `'guest'` only
(`explain-engine.ts:149`). Slice 4's pins decide whether the envelope
may be passed instead.
- **Hono (slice 5).** Hono's session read drops a session that has no
`user.id`. The resolver reads `user.id ?? session.userId`, and no
better-auth read answers without `user.id`. Slice 5 measures this.
## Handover for slice 2 (rest)
- **The call.** Convert `computeExecCtx` to call the function with
`anonymous: 'fail-closed'`, `authGate: 'reread-when-active'`,
`credentials: 'api-key-and-session'`, `sessionApi: 'required'`,
`session: 'request'`, `localization: { settings: () => provider ?
seamOrUndefined(() => provider(env)) : undefined }`, `requestLocale:
this.extractLocale(req)`, `accessToken: 'withhold'`, `oauth: undefined`
and `onAdmission: undefined`.
- **The tenancy thunk.** It stays rest's own: the kernel-or-provider
branch.
- **Kept at the door.** The `if (!authService) return undefined` lookup,
the header normalisation, `__kernel`, `__authEnvironmentId`, the memo,
the perf disclosure, and the `catch` with its lifecycle-refusal side
channel.
- **Then.** Move `DELEGATORS`' rest row to
`resolveRequestExecutionContext`, add rest as a conformance row (runtime
already depends on `@objectstack/rest`), and pin ADR-0096 and ADR-0069
per the ruling.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent cbfa5e4 commit f5ef054
8 files changed
Lines changed: 1055 additions & 231 deletions
File tree
- .changeset
- packages
- core/src/security
- runtime/src/security
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
180 | 180 | | |
181 | 181 | | |
182 | 182 | | |
183 | | - | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
184 | 188 | | |
185 | 189 | | |
186 | 190 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
160 | 160 | | |
161 | 161 | | |
162 | 162 | | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
163 | 176 | | |
164 | 177 | | |
165 | 178 | | |
| |||
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
0 commit comments