Skip to content

refactor(core,runtime,rest): one ExecutionContext assembler, two named anonymous entries (#6216) - #7259

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-6216-execution-context-assembler
Aug 10, 2026
Merged

refactor(core,runtime,rest): one ExecutionContext assembler, two named anonymous entries (#6216)#7259
os-zhuang merged 3 commits into
mainfrom
claude/issue-6216-execution-context-assembler

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #6216

Implements the maintainer ruling of 2026-08-08 (comment 5227240904), Option A — one shared assembler, explicit dual mode, fail-closed default.

What changed

resolveAuthzContext already made AUTHORIZATION resolution single-sourced. The step after it — turning the resolved envelope into the ExecutionContext that reaches enforcement — was still one hand-written copy per transport, and that duplication produced a measured defect family: #6071 (the REST copy never set principalKind, so every enforcement judgment reading it was silently never-true on that face) and #6206 / #6551 (a dropped accessible_org_ids, and the group posture's Layer 0 wall reads it directly — real 403s).

New module packages/core/src/security/assemble-execution-context.ts, with the anonymous divergence as named API instead of drift:

  • assembleExecutionContext(input) — the fail-closed default entry. No resolved principal, no context; the surface answers 401. REST's contract, unchanged.
  • assembleExecutionContextOrGuest(input) — the explicit guest entry. No resolved principal produces today's dispatcher guest envelope (principalKind: 'guest', positions: ['guest']), whose consumers are live (plugin-security/explain-engine.ts reads guest for its EXTERNAL posture floor). Adopted only by a surface whose product semantics serve anonymous principals.
  • Field set closed by type. EntryExecutionContextField is Exclude< keyof ExecutionContext, NonEntryExecutionContextField >, and ExecutionContextEntryFields makes every one of those keys required (the value may be undefined; the decision may not be omitted).

packages/runtime/src/security/resolve-execution-context.ts (dispatcher / MCP door) now takes the guest entry; packages/rest/src/rest-server.ts computeExecCtx takes the fail-closed default.

The share-link surfaces already consume whole envelopes (#6552 / #6647) and were deliberately not re-plumbed.

The remaining divergences are values, not switches

Converging turned up a second real divergence beyond the anonymous face, and it is load-bearing:

accessToken. The dispatcher has always carried authz.accessToken; REST's literal never had the key. It is not inert — packages/objectql/src/engine.ts buildSession feeds it to HookContext.session.accessToken, a published hook surface (packages/spec/src/data/hook.zod.ts). A naive convergence would have started exposing the better-auth session token to hooks on every REST-driven write. So accessToken is a required input of the assembler: the dispatcher passes authz.accessToken, REST passes undefined with the reason written at the call site. Same for oauth (the /mcp door alone). Required inputs, not booleans — a face cannot silently omit one, and what it withholds it withholds on the record.

Evidence 1 — no behaviour change, measured both ways

A green suite proves nothing here; the suite was green before. Two independent measurements:

(a) Frozen-transcription parity matrix (packages/core/src/security/assemble-execution-context.test.ts). The pre-#6216 assembly of each face is transcribed verbatim from origin/main@0caf122f0, frozen, and every shape either face serves is assembled both ways and compared: 3 authz shapes (anonymous / minimal human / full human with tenant+email+token+tabs+posture) times 6 OAuth shapes (none, agent data:read, agent data:write, agent with actions:execute, bearer without azp, bearer for a different user) times 4 localization shapes times 2 request-locale shapes, plus the REST matrix. 179 tests, all green. The transcriptions are a pin, not a live second implementation — nothing imports them, and the day they need editing is the day a face's output changed.

(b) On the wire, against the real pipeline. Both face files were restored from origin/main with the new tests kept, and the tests re-run through the real computeExecCtx and the real resolveExecutionContext. Result below.

Evidence 2 — the type closure actually bites (shown, not asserted)

Probe A — add a field to ExecutionContext. Added probeField6216: z.string().optional() to ExecutionContextSchema, rebuilt @objectstack/spec, ran tsc on @objectstack/core. Core went from 98 pre-existing errors (its ledgered DEBT number, unchanged by this PR) to 100, and both new errors are the closure firing, independently:

src/security/assemble-execution-context.ts(149,7): error TS2322: Type 'true' is not assignable to type 'never'.
src/security/assemble-execution-context.ts(252,3): error TS2741: Property 'probeField6216' is missing in type '{ positions: string[]; ... }' but required in type 'ExecutionContextEntryFields'.

Line 252 is the assembly literal; line 149 is the order-list exhaustiveness constant. pnpm --filter @objectstack/core build fails on the same two errors (Error: error occurred in dts build), so the bite is visible to CI's build job, not only to a local tsc. The runtime pin went red too, naming the field:

AssertionError: expected [ 'accessToken', ...(29) ] to deeply equal [ 'accessToken', ...(30) ]
-   "probeField6216",

Probe B — add a required per-face input. Added a required probeInput6216: string to ExecutionContextAssemblyInput and rebuilt core. Both faces went red:

packages/rest    src/rest-server.ts(2605,51): error TS2345: Argument of type '{ authz: ...; oauth: undefined; ... }' is not assignable to parameter of type 'ExecutionContextAssemblyInput'.
packages/runtime src/security/resolve-execution-context.ts(193,42): error TS2345: Argument of type '{ authz: ...; oauth: ... }' is not assignable to parameter of type 'ExecutionContextAssemblyInput'.

Honest limit of the guarantee. The ruling says a new ExecutionContext field "must land in every face at compile time". What Probe A measures is that it lands at the one place where every face's value is decided — and Probe A's runtime and rest typechecks stayed green, which was predicted in writing before the run. That is the design working, not a gap: with one assembler the faces no longer assemble, so there is no per-face site left to forget. A new field propagates to the faces (Probe B) exactly when it needs a per-face value. Stated plainly rather than rounded up to the ruling's phrasing.

The one measured residual

The shared assembler omits undefined-valued keys; both legacy literals sometimes spelled them. Restoring the legacy REST face turned exactly one new assertion red, and the whole diff was one key:

+   "tenantId",

tenantId was present-with-undefined for a tenant-less principal. This is invisible to ctx.x reads, to JSON.stringify and to spreading the envelope; it is visible to Object.keys and in. No value moved on either face, for any shape. It is pinned rather than waved off — see the "measured residual" block in the core test.

Reverse verification — direction predicted before running

Predicted in writing (scratchpad, before the run): restore both face files from origin/main, keep the new tests. 1 red (the tenantId presence residual on REST), 3 green — because the other new pins assert unchanged behaviour and legacy code has that behaviour by construction.

pin predicted measured
runtime: sessionless request yields the whole guest envelope, key set included green green (31/31 in the file)
runtime: session bearer carried as accessToken green green
rest: exact key set for a session-backed request red, tenantId only red, tenantId only
rest: accessToken still withheld green green

Measured equals predicted, including the three deliberately-green ones. A guard that stays green under its author's own mutation is the result here, not a gap: these pins exist to catch a future change of behaviour, and their green under the legacy code is the proof that this PR did not change it.

One thing I did not predict: the first run of the REST key-set pin failed against my own code, because I guessed the golden set wrong (currency instead of email — the test's ql stub returns a sys_user row with an email, and no currency is configured). The pin caught its author. Corrected before anything was pushed.

Gates

gate result
pnpm --filter @objectstack/core test 703 passed / 29 files
pnpm --filter @objectstack/rest test 1221 passed / 76 files
pnpm --filter @objectstack/runtime test 1839 passed / 118 files
pnpm --filter @objectstack/runtime --filter @objectstack/rest typecheck Done (both)
eslint on all 7 touched files clean
check:authz-resolver PASS
check:org-identifier PASS
check:tenant-chokepoint PASS
check:required-contexts PASS
check:role-word / check:empty-changeset / check:spec-parsed-alias / check:slot-lookup PASS
check:type-check-coverage OK — core's DEBT stays at 98, the new files add zero
check:nul-bytes + targeted control-byte self-scan clean

Changeset: .changeset/execution-context-single-assembler.md.

Deliberately not done

…wo named anonymous entries (#6216)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 10, 2026 4:51am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/core, @objectstack/rest, @objectstack/runtime.

36 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx (via @objectstack/core)
  • content/docs/ai/connect-mcp.mdx (via @objectstack/rest)
  • content/docs/ai/knowledge-rag.mdx (via @objectstack/core)
  • content/docs/ai/natural-language-queries.mdx (via @objectstack/core)
  • content/docs/api/client-sdk.mdx (via packages/runtime)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest)
  • content/docs/api/index.mdx (via @objectstack/rest, @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime)
  • content/docs/automation/webhooks.mdx (via @objectstack/core)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx (via packages/core, packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/core)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime)
  • content/docs/kernel/cluster.mdx (via @objectstack/runtime)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/core)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/core)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/core)
  • content/docs/kernel/services.mdx (via @objectstack/core)
  • content/docs/permissions/authentication.mdx (via @objectstack/core, @objectstack/rest, @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/core, packages/runtime)
  • content/docs/permissions/system-context.mdx (via packages/rest, packages/runtime)
  • content/docs/plugins/anatomy.mdx (via @objectstack/core)
  • content/docs/plugins/development.mdx (via @objectstack/core)
  • content/docs/plugins/index.mdx (via @objectstack/core, @objectstack/rest)
  • content/docs/plugins/packages.mdx (via @objectstack/core, @objectstack/rest, @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/rest, @objectstack/runtime)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/rest)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/core, @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/core, @objectstack/runtime)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/core)

4 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/core, @objectstack/rest, @objectstack/runtime)
  • content/docs/releases/v12.mdx (via @objectstack/core, @objectstack/rest)
  • content/docs/releases/v15.mdx (via @objectstack/core)
  • content/docs/releases/v17.mdx (via @objectstack/core, @objectstack/rest, @objectstack/runtime)

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.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…eld-set pin for the shared assembler (#6216)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ
)

The shared assembler imported `scopesToAgentPermissionSets` /
`MCP_OAUTH_SCOPE_ACTIONS` from `@objectstack/spec/ai` as VALUES. Every package
whose vitest config aliases `@objectstack/core` to its source must then resolve
that subpath too, and five do not (metadata, driver-memory, driver-sql,
plugin-dev, plugin-hono-server) — driver-memory's suite died with
'ENOTDIR: not a directory, open .../spec/src/index.ts/ai'.

The scope vocabulary is MCP-domain knowledge, so it is interpreted at the only
door that speaks it: `OAuthTokenProvenance` now carries the already-derived
`scopePermissions` + `delegatesActions`, and the assembler still decides — once,
for every face — what that ceiling replaces on the envelope. No behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ

Copy link
Copy Markdown
Contributor Author

Note for the reviewer, and a tooling observation worth recording: the PR body and both filed findings (#7279, #7280) were each written ending with the required attribution block

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

and in all three cases the stored body comes back without it. Read-back confirms the text is absent at rest, not merely hidden — while the same block survives intact in issue comments (every comment on #6216 still carries it, including this one). So the strip happens on the body write path, not on read and not on comments. Recording it here so the convention is satisfied where it can be, and so the next agent does not spend the same time diagnosing it.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 10, 2026 05:07
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

PM review — PASS. Marked ready and enqueued. Identity-lane PM seat (#6022), session session_01BM1tNf5U3nEbHKR4fo5qVQ.

CI on head b74c8363d: 25 runs, none non-green. Boundaries re-measured against the merge base: 8 files; share-link surfaces untouched (0 hits — they are whole-envelope passthrough per #6552 / #6647), no content/docs/releases/, no new organizationId spellings.

The residual was disclosed — and this seat closed the question the disclosure left open

The report states the one measured delta honestly: the shared assembler omits undefined-valued keys where the legacy literals sometimes spelled them, so the entire on-the-wire diff is a single key (tenantId, present-with-undefined for a tenant-less REST principal), invisible to ctx.x, to JSON.stringify and to spreading — visible only to Object.keys / in. Insertion order also changes for REST.

That caveat is not theoretical in this repo, so I checked rather than accepting "semantically inert" on its face: packages/runtime/src/action-session-shape-contract.test.ts:89 asserts expect('tenantId' in built!).toBe(false) — a key-presence contract someone deliberately wrote. It runs in Test Core, which is green on this head, and the new direction (omit) agrees with it. So the class of consumer that could notice exists, and the one instance of it in the repo is satisfied. Landing on that basis.

Type closure: shown, with an honest limit that this seat accepts rather than waves through

Probe A (add an optional field to ExecutionContextSchema) turned core's tsc red at two independent sites — the assembly literal (TS2741) and the order-list exhaustiveness constant (TS2322 … not assignable to type 'never') — and pnpm --filter @objectstack/core build fails on the same two, so the bite is visible to CI's Build job, not just a local tsc. Probe B (add a required input) turned both faces red.

But under Probe A the runtime and rest typechecks stayed GREEN, which the author predicted in writing beforehand and refused to round up to the ruling's wording ("lands in every face at compile time"). That refusal is the right call and I am recording my judgement explicitly rather than letting it slide: the ruling's purpose was that the #6071 drift class become unrepresentable, and with a single assembler there is no per-face assembly left to forget — which achieves the purpose more directly than per-face compile errors would, and Probe B shows propagation to the faces exactly when a new field needs a per-face value. Not a needs_decision; a design that delivers the intent while the literal phrasing over-promised.

Converging surfaced a second real divergence, and it was handled by value rather than by switch

accessToken — carried by the dispatcher since always, never by REST, and not inert (it flows to the published HookContext.session.accessToken via objectql's buildSession). Made a required input each face supplies by value (dispatcher authz.accessToken; REST undefined, with the reason at the call site) instead of a boolean mode flag. That keeps the divergence named and typed rather than hidden behind a parameter, which is the same principle the ruling applied to the anonymous face.

Two things that raise this above a passing review

  • A CI-only breakage it found and fixed in-task, and reported as its own defect. The assembler's value import of @objectstack/spec/ai broke five packages whose vitest config aliases @objectstack/core to source but not that subpath (ENOTDIR … /spec/src/index.ts/ai, 17 suites). Rather than push MCP vocabulary into the microkernel or edit five unrelated test configs, the OAuth scope vocabulary now stays at the door that speaks it — OAuthTokenProvenance carries pre-derived scopePermissions / delegatesActions and the assembler still decides, once for every face, what that ceiling replaces. Verified by re-running all five green. Reported as "my defect, found by CI, fixed in-task" rather than quietly absorbed.
  • Its own golden pin caught its own wrong guess (currency where the harness yields email), corrected before any push. A pin that catches its author on first run is a pin doing its job.

Also correct: three of the four reverse-verification pins are deliberately GREEN under the author's own mutation, stated as a result rather than dressed as reds — they exist to catch a future behaviour change, and their green against restored legacy code is the proof this PR changed nothing.

Deliberately deferred, and rightly

The stdio MCP face (packages/mcp/src/plugin.ts) is a fourth hand-written assembly that drops tabPermissions and accessToken. Converging it would change output on a surface the ruling never weighed — filed as #7279 instead of fixed here. And #7280: context.authGate is written and read by rest-server.ts but is not declared on ExecutionContextSchema, so it sits one as any outside the closure this PR builds. Both are the right shape of follow-up: found while converging, not smuggled into the convergence.

Tooling, confirmed independently by this seat: the trailing --- + attribution block is stripped from issue and PR bodies on the write path while surviving in comments — verified by read-back on #6947, #7082 and #7281, all of which were authored with it. So its absence in bodies is a tool behaviour, not an authoring miss by anyone, and the convention is met where it can be.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding: ExecutionContext 在 dispatcher / REST / share-link 三处独立组装 —— 收敛为单一共享装配函数前,先裁决匿名面分歧

2 participants