Repository navigation
feat(core): resolveRequestExecutionContext, the one request-to-ExecutionContext composition; the runtime dispatcher's door calls it - #24248
Conversation
…stExecutionContext (WIP) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
…tExecutionContext (WIP) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
…patcher door gives one context; changeset (WIP) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
📓 Docs Drift CheckThis PR changes 2 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 44 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3ef5c23d367d54e8bca8e47982609d3708f008bd && git checkout 3ef5c23d367d54e8bca8e47982609d3708f008bd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cbfa5e43354f430e35c4f9f24732e59848174889 f5a955e12a73e00e2d196e34ec16c0d1e529d12a && git checkout -B drift-repro cbfa5e43354f430e35c4f9f24732e59848174889 && git merge --no-ff f5a955e12a73e00e2d196e34ec16c0d1e529d12a
node scripts/docs-audit/affected-docs.mjs --json cbfa5e43354f430e35c4f9f24732e59848174889
|
席位核对 — ACCEPT · PR #24248 at
|
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 withClause-②: no. No door's outward behaviour changes, and there is no app-author surface.What changed
@objectstack/core: newpackages/core/src/security/request-execution-context.tswithresolveRequestExecutionContext, exported from core's.entry throughsecurity/index.ts, besideclassifyAdmissionTenancyPosture. 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/mcpOAuth verification and scope interpretation) and hands the rest to the function. 209 lines out, 87 in.packages/runtime/src/security/request-execution-context.conformance.test.ts, one row per converted door.packages/core/src/security/request-execution-context.test.ts, 20 cases, one battery per choice and per parameter, each reading paired with its control.packages/core/src/security/authz-store-unavailable.test.ts: its transport ledger is set-equal to the files that callresolveAuthzContext({. That call moved from the runtime door into the new core module, which is ledgeredpropagates.scripts/check-single-authz-resolver.mjs: check (2) required the runtime door to containresolveAuthzContext, and after the move only its docblock prose would have satisfied that.DELEGATORSnow names each delegator's call: rest and the new core module callresolveAuthzContext, and the runtime door callsresolveRequestExecutionContext. The self-test drives the non-delegating case for every row.minoron@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 ispackages/core/src/security/request-execution-context.tslines 84-166 and 214-221 atf5a955e12.Every key is required, as on
ExecutionContextAssemblyInput: a door with no value passesundefinedor 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-deadresolveRequestExecutionContexthelper 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/main7be3cc728)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.rest-server.tscomputeExecCtxresolve-execution-context.tssettings-service-plugin.tsverifiedContextFromRequeststorage-service-plugin.tsbuildFileReadAuthorizer+buildAuthGateSessionReadercurrent-user-endpoints.tsmakeExecutionContextResolver{ enforced: true })'unauthenticated')anonymousisAuthGateActive(), a failed re-read is 503enforceAuthGatereads the session; a key's answer leaves throughonAdmission)authz.authGatefrom the admitting read, no re-read/meroutes are allow-listed for a gated user)authGateheaders: undefinedto the resolver)credentialssessionApi/mcp, a verified token's session, or none for an unverifiable JWTuser.id)sessiongetServiceAsync, or the providergetServiceAsyncwhen presentgetServiceAsyncwhen presenttenancy(the resolution stays at the door, as #16013 left it)onAdmissiononAdmissionlocalization?locale(metaRequestLocale)Accept-LanguagerequestLocaleaccessToken/mcponlyoauthCount: 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:
Headers. The function takes aHeaders.__kernel,__authEnvironmentId, the per-request memo and the perf disclosure; hono adds the perf disclosure.SettingsContext, and storage hands it toengine.findascontext.H2: the OAuth arm
The arm is split, measured as follows:
scopesToAgentPermissionSets,MCP_OAUTH_SCOPE_ACTIONS). Both need@objectstack/spec/ai, which core deliberately does not depend on (see theOAuthTokenProvenancedocblock inassemble-execution-context.ts).session({ established }for a verified token,'none'for an unverifiable JWT bearer) andoauth.#24137's pins,
resolve-execution-context.oauth-organization.test.ts, are green and unchanged (15 of 15).H3: the published surface
.entry reaches the new export throughexport * from './security/index.js'(packages/core/src/index.ts:68).classifyAdmissionTenancyPostureappears nowhere outsidesrc/: 0 hits inscripts/and in package JSON files.H4: dependency direction
The new module imports:
@objectstack/spec/kerneland@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 mergingorigin/mainaf284b05e)pnpm --filter @objectstack/core exec vitest run --project local: 104 files, 2551 tests passed.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).7667d9b4dbefore the merge (the post-merge full suite above includes them): 7 files, 111 tests passed. These areresolve-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.keysandin-process-session-renewal.pin.pnpm --filter @objectstack/core typecheckandpnpm --filter @objectstack/runtime typecheckboth 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.scripts/ablation-replace.mjs: anchor 1 to 0, marker 0 to 1, blobe3a17addto86ec4e1f.@objectstack/coreto../core/src/index.ts, so nodist/step applies.e3a17addagain, equal to HEAD, andgit diff HEADis empty.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackatf5a955e12derived 84 commands. Results follow in the os-dev-report.The lane blind spot,
pnpm exec eslint --no-inline-configover the 7 changed.ts/.mjsfiles, exits 0 with no output.node scripts/check-single-authz-resolver.mjs --self-testand the gate itself both exit 0 ("all 3 entry points delegate").Acceptance notes
authonce, 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 theauthlookup 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..entry. That is a text fix for the seat, not a code question.ResolvedAuthzContexttoengine.findascontext. The function's envelope addsisSystem: falseandprincipalKind: 'human'and dropscredential. Measured: no objectql, plugin-security or plugin-sharing source readscontext.credential, andprincipalKindis read for'guest'only (explain-engine.ts:149). Slice 4's pins decide whether the envelope may be passed instead.user.id. The resolver readsuser.id ?? session.userId, and no better-auth read answers withoutuser.id. Slice 5 measures this.Handover for slice 2 (rest)
computeExecCtxto call the function withanonymous: '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: undefinedandonAdmission: undefined.if (!authService) return undefinedlookup, the header normalisation,__kernel,__authEnvironmentId, the memo, the perf disclosure, and thecatchwith its lifecycle-refusal side channel.DELEGATORS' rest row toresolveRequestExecutionContext, 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