feat(spec,core,rest,runtime): declare ExecutionContext.authGate so the ADR-0069 gate joins the closed field set (#7280) - #7434
Conversation
…e ADR-0069 gate joins the closed field set (#7280) The authentication-policy gate rode the execution context undeclared: REST's `computeExecCtx` spread it on with `...(authGate ? { authGate } : {})` behind an `as any`, and `enforceAuth` read it back ten lines later. #6216's closed entry field set is derived from `keyof ExecutionContext`, so a field living only inside an `as any` is outside every closure gate by construction — the exact blind spot that gate exists to remove. Measured as ENTRY-decided, not a mid-request mutation: it is resolved from the request's own session inside `computeExecCtx`, immediately before assembly, and no handler writes it. So it joins the closed set rather than the non-entry partition, and `ExecutionContextAssemblyInput` gains a REQUIRED `authGate` input on the `accessToken` template — every face decides on the record. REST carries it (its consumer reads it off the envelope); the runtime/MCP dispatcher passes `undefined` because it enforces the same gate at its own seam (`HttpDispatcher.enforceAuthGate`) and never reads `context.authGate`. `normalizeAuthGate` completes a session user's loose gate into the declared shape at the one producer, so a gate naming a code but no message no longer renders a 403 body with `message: undefined`. `AuthGate` is now derived from the schema instead of being a second hand-written declaration. No runtime behaviour change: the assembler omits undefined-valued keys, so the key is present exactly when it was before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 4 package(s): 118 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 8 release-owned page(s) also reference the affected code. These are read-only:
|
Closes #7280
Premise, re-measured on
origin/main@f188ed60eAll four legs hold; line numbers moved post-#7259, so this was re-anchored by content.
authGateundeclared in specgit grep -n authGate origin/main -- packages/spec/src→ zero hitsas anyrest-server.ts:2636-2643—const execCtx = { ...base, ...(authGate ? { authGate } : {}), __kernel: kernel } as any;enforceAuthrest-server.ts:2133—const gate = context?.authGate;packages/core/src/security/assemble-execution-context.tsonmain(PR #7259, merged 05:41Z)The dataflow question the card left open: entry-decided, or mid-request mutation?
Entry-decided, measured rather than assumed — which is what makes the card's "join the closed set" premise the right one:
rest-server.ts:2588-2594, insidecomputeExecCtx(the transport entry point), from the request's own session (getSession(headers)→session.user.authGate), immediately before theassembleExecutionContextcall.git grep "authGate\s*=|authGate:" -- packages --include=*.ts(non-test) returns onlyauth-manager.ts:2621(the upstream session enrichment) and the REST local. No handler writesctx.authGate.enforceAuthonly reads it.So it belongs in
EntryExecutionContextField, notNonEntryExecutionContextField.What changed
@objectstack/specdeclares the field besideposture:Both inner keys required, matching the sole producer
AuthManager.computeAuthGate(Promise< { code: string; message: string } | undefined >, both set on every return branch). The.describe()is deliberate:gen:docsrenders.describe()and never TSDoc, so a bare key ships a blank description cell — the #6881 defect, avoided at the moment the row is added rather than after it ships.@objectstack/core—authGatejoinsENTRY_EXECUTION_CONTEXT_FIELDS, andExecutionContextAssemblyInputgains a requiredauthGateinput, on theaccessTokentemplate from #7259. A guest never carries one (no authenticated session for a policy gate to attach to). New exportnormalizeAuthGatecompletes a session user's loose gate into the declared shape at the one producer;evaluateAuthGatenow calls it, so the two consumers cannot re-derive it differently.AuthGateis derived from the schema (NonNullable< ExecutionContext['authGate'] >) instead of being a second hand-written declaration.@objectstack/restpasses the gate as an assembler input; the post-assembly spread is gone and the remainingas anycovers__kernelalone.@objectstack/runtimepassesauthGate: undefined, on the record.Face census — every face the closed set forces a decision on
rest-server.tscomputeExecCtxenforceAuthanswers403 { code, message }off the enveloperuntime/src/security/resolve-execution-context.ts(dispatcher / MCP)undefined, withheld on the recordHttpDispatcher.enforceAuthGate(http-dispatcher.ts:910-947) re-reads the session and callsevaluateAuthGatethere — and never readscontext.authGate. Carrying it would be a second copy no consumer reads. Converging the dispatcher onto the envelope is a real option but a behaviour-bearing change to a security seam, not a declaration's rider.assemble-execution-context.tsentryFieldsassemble-execution-context.test.tsNo silent
undefinedanywhere: the input is required, so omission does not compile.Reverse verification — directions predicted in writing before running
Probe A — the closure bites. Declared the field on the schema alone, left core untouched. Predicted RED with two independent errors; got exactly that:
(148,7)is_ENTRY_FIELDS_EXHAUSTIVE—MissingEntryFieldresolves to'authGate', so the annotation becomesnever. Green after the fix (tsc --noEmit -p packages/core/tsconfig.json, zero source-file errors).Probe B — every face must decide. Deleted
authGate: undefinedfrom the runtime face. Predicted RED at compile; got it:Probe C — the REST face is INVERTED, and this was predicted up front rather than discovered. Restoring the old post-assembly spread produces the identical envelope, so there is no before-green/after-red behavioural direction for that revert. Measured both halves:
rest-auth-gate.test.ts→ 4 passed with the old spread restored. Green, as predicted.rest-server.ts(2619,51): error TS2345: ... Property 'authGate' is missing ... but required in type 'ExecutionContextAssemblyInput'.Reporting that honestly rather than manufacturing a red: for this face the evidence is compile-time, by construction.
Probe D — the declaration does the work. Removed the declaration; the new spec rejection cases flip from
success: falsetosuccess: true(an undeclared key is stripped, never rejected): 3 failed / 14 passed. Restored → 17 passed.Probe E — the new REST pins bite. Withheld the gate at the REST face only (
authGate: undefined): 3 failed — the envelope pin, the normalization pin and the new end-to-end 403. Restored → green. This is the genuine before-green/after-red direction, and it is on the pins this PR adds.Every probe was taken out with
git checkout/ an in-place edit and a byte-identical restore verified against a savedgit diff— nevergit stash.Tests
New coverage, and one gap closed: nothing pinned the ADR-0069 seam end to end before —
rest-auth-gate.test.tshand-builds a context, so it proves only thatenforceAuthreads the key, never that this face still puts it there.packages/spec/.../execution-context.test.ts— accepts a well-formed gate; optional; rejects a code-without-message and a non-string code, asserted by issue path (authGate.message/authGate.code) andcode: 'invalid_type', not by issue count; plus the [观察]ExecutionContextSchema.preserveAudit无.describe()—— 生成的 reference 行描述为空(#6827 刻意划出的那半) #6881-style anti-vacuity pins on the published description.packages/core/.../assemble-execution-context.test.ts— carried verbatim; no key when absent; guest never carries one; membership of the closed set.packages/core/.../auth-gate.test.ts—normalizeAuthGate: null cases, verbatim pass-through, message completion, and that it drops any key the declaration does not name.packages/rest/.../rest-exec-ctx-principal-kind.test.ts— on the wire through the realcomputeExecCtx: gate on the envelope, no key when ungated, message completed, and the end-to-end 403 on a protected data route withfindDatanever called.pnpm --filter @objectstack/spec testpnpm --filter @objectstack/core testpnpm --filter @objectstack/runtime testpnpm --filter @objectstack/rest testpnpm --filter @objectstack/{spec,rest,runtime} typechecktsc --noEmit -p packages/core/tsconfig.jsontypecheckscript; its test layer carries pre-existingTS2835noise onmain)pnpm linton the 10 changed filesnode scripts/check-nul-bytes.mjs18 further lint-job gates run green locally, including
check:authz-resolver,check:adr-anchors,check:error-code-casing,check:route-envelope,check:engine-double-contract. Two could not run in a fresh worktree and say so themselves —check:i18n("PREREQUISITE NOT MET — the workspace CLI is not built") andcheck:type-check-debt("5 workspace dependenc(ies) ... have no built type entry point"); CI builds the closure before those steps.Acceptance face
ExecutionContextis an authorable surface, so the four-step regen ran after a real spec build (build→check:generated→--fix→ re-check): all 11 generated artifacts up to date. Exactly three generated lines moved, all mechanical:packages/spec/authorable-surface/kernel.json—+ "kernel/ExecutionContext:authGate"content/docs/references/kernel/execution-context.mdx— one row, with a real descriptiondocs/audits/2026-07-unknown-key-strictness-ledger.counts.md—kernel/295 → 296 (census only;kernel/is not in the triaged strictness campaign, and the nested object is plainz.objectlike its neighbouronBehalfOf)api-surface/andexport-origins/did not move — no new spec export — so the dual-snapshot rule does not apply here. Purely additive, so no ADR-0087 conversion/migration entry: nodocs/adr/**file is touched by this PR.Changeset level from the #6216 precedent (
.changeset/execution-context-single-assembler.md: coreminor, runtime/restpatch): specminor(a new declared public key), coreminor(new required input + new export), rest/runtimepatch.Out-of-scope finding filed
#7432 —
enforceAuthpassesreq.pathtoisAuthGateAllowlistedunguarded, andisAuthGateAllowlisted(undefined)returnstrue, so a request with no path silently disables the gate. The sibling seam (anonymous-deny.ts:117-122) guards against exactly this and documents it. Observation-class, not live: the Hono adapter setspathat all three request-construction sites. Surfaced while writing the wire fixture here — it sat green through a gated session untilpathwas spelled.Generated by Claude Code