Skip to content

fix(core,cloud-connection,mcp)!: every API-key admission names its owner-posture source, so a held owner's key is refused at install-local and on every MCP stdio call (#23455, #23456) - #23508

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23455-key-admission-posture-family
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23455-key-admission-posture-family

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23455
Fixes #23456
Clause-②: no

What changed

This PR covers the family card #23455 (one card for the family) and #23456. It closes the seams of ruling C′ on #23287 (6159450584, maintainer 「同意」) that PR #23440 did not reach. It then makes the owner-posture input required, so the compiler names any seam that forgets it.

The ruling, quoted:

  • Item 1: "a held user's API key is refused at every seam with the same code".
  • Item 4: "The refusal is not a revocation: the key resumes the moment the user enrols (or the account posture clears)".

AUTH_GATE_SCOPES decides which posture holds a key. MFA_REQUIRED has scope account and holds the key. PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED have scope credential and do not.

1. The marketplace install-local resolver (@objectstack/cloud-connection, member 1)

resolveInstallPrincipal does two new things:

  • It hands the shared resolver userAuthGates: userAuthGateSourceOf(authService). It reaches the auth service through ctx.getService('auth'), read per request, the way its sibling seams do.
  • It asks evaluateAuthGate about the resolver's own posture (authz.authGate), instead of the session user it used to capture.

So all five doors refuse a held owner's key through the existing refuseHeldSession: 403, the posture's code and the session's message, before any effect. A held session is refused exactly as before.

2. The MCP stdio face, asked on every call (@objectstack/mcp, member 2, #23456)

Measured on origin/main:

What changed:

  • resolveStdioExecutionContext takes a REQUIRED userAuthGates parameter. resolveStdioUserAuthGates(ctx) reads it per call, beside resolveStdioTenancyPosture. The function now assembles authGate: authz.authGate instead of authGate: undefined.
  • resolvePrincipal asks evaluateAuthGate on the context and refuses a held owner's call before any read or write. The refusal is a thrown envelope: code MFA_REQUIRED, status 403, and the session's remediation message. That is what REST's enforceAuth and the dispatcher answer, in the shape this transport's other refusals take (recordNotFoundError).
  • A held key still STARTS the transport, and one warn line names the hold and the remedy. The key is valid and nothing revokes it. A refusal to start would outlast the remediation until someone restarted the process, which item 4 rules out.
  • So a posture that appears mid-session refuses the next call, and one that clears serves the next call, with no restart.

3. resolveApiKeyPrincipal (@objectstack/core, member 3): wired, not retired, on an unmeasured premise

The ruling's premise for retirement is that no non-test caller exists here or in objectstack-ai/objectui or objectstack-ai/cloud.

Repo Reading
this repo, at fc4aff4a17 git grep resolveApiKeyPrincipal finds the definition, its tests and three re-exports (core's security/index.ts, runtime's security/api-key.ts and security/index.ts), plus prose. No non-test call.
objectui main at 52d61943c0 Fetched into a private ref and git grepped there: zero hits for resolveApiKeyPrincipal. Control: the same grep for resolveAuthzContext returns 8 lines (comments and docs), so the grep reads that tree.
cloud NOT MEASURED. This session has no read access: add_repo answered "you don't have access", and a REST read answered 403. No published cloud package exists on npm to read instead. /search/* was not used.

Since a live cloud caller could not be ruled out, the ruling's prohibition branch was taken: no retirement, and no silent fail-open.

  • resolveApiKeyPrincipal takes a REQUIRED userAuthGates.
  • A held verdict collapses to no principal (fail closed).
  • Its docblock states the cost: a principal-or-nothing answer cannot carry the posture's code, so a request-answering host should read resolveApiKeyAdmission and refuse held with gate.code.

Open question 1 asks the seat to retire it once cloud is read.

4. The class fix: the owner-posture input is REQUIRED (@objectstack/core)

This is PR #23440's seat record item 6:

  • ResolveAuthzInput.userAuthGates changes from ?: UserAuthGateSource to : UserAuthGateSource | undefined.
  • On resolveApiKeyAdmission and resolveApiKeyPrincipal, userAuthGates is now required.
  • tenancyPosture is required with it, because a required parameter cannot follow an optional one.

undefined is a value a caller passes on purpose. The docblocks say where it is legitimate: a kernel with no auth service. They also say where it is not: a seam that could reach one.

Premise measured: it holds. Every in-tree production caller names its source without new plumbing.

Caller Source
REST computeExecCtx, runtime resolveExecutionContext, sharing resolver, datasource admin, settings resolver, storage door gate reader userAuthGateSourceOf(…) already (PR #23440)
cloud-connection resolveInstallPrincipal new, ctx.getService('auth')
MCP resolveStdioExecutionContext (start probe and per call) new, ctx.getService('auth')
storage buildFileReadAuthorizer new, registry.getService('auth') through a shared resolveGateAuthService
core resolveAuthzContext and resolveApiKeyPrincipal pass their own required input through

The compiler's enumeration matches the git grep census: after the change, every package holding a call site type-checks (see Verification).

Storage, measured. The authorizer's call is an explicit pass-through, not a second gate:

  • Every storage route is mounted through mountFamilyRoute. Its door asks buildAuthGateSessionReader, which already refuses a held owner's key on the same request.
  • composeStorageRoutes binds the reader and the authorizer from the same auth service.
  • So the authorizer now resolves a held key truthfully, as a held principal, and does not judge it again.

Test and kit callers.

  • 141 object literals in tests and the sharing testkit gained an explicit userAuthGates: undefined, and 20 positional test calls gained explicit undefined arguments. They present no key, or test something other than the owner's posture, so their subjects are unchanged.
  • The dogfood helper resolveAuthzFor (armed.ts) documents itself as resolving "the SAME way the REST entry point does", so it gets the real source from the stack's auth service.

Pins (three facts per seam, PR #23440's shape)

Seam Pin Held (MFA_REQUIRED) Credential scope only Posture clears
install-local, all five doors cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts 403, { code: 'MFA_REQUIRED', message } on every door, zero effects, ledger untouched served (install and list) 403, then served; key row unchanged
MCP stdio, per call mcp/src/stdio-api-key-account-posture.pin.test.ts (real kernel, real core chain) start succeeds with one warning; query, get, create and the record resource reject { code: 'MFA_REQUIRED', status: 403, message }; nothing written; a real StdioServerTransport tools/call answers isError with the remediation message served same live session: a posture that appears refuses the next call, one that clears serves the next call
core core/src/security/api-key-account-posture.test.ts resolveApiKeyPrincipal answers no principal answers the owner answers the owner again

The compile-level pin is in the same core file. Four @ts-expect-error lines cover each omitted input: the ResolveAuthzInput key, the admission with two and with three positional arguments, and the principal with four. Explicit undefined controls sit beside them. They live in a function that is never invoked and are read by check:test-typecheck's tsc program, where an unused directive is TS2578.

Negative control (reverse verification), on the committed head a15aecc0aa

Each leg went through scripts/ablation-replace.mjs. The anchor hit exactly once, and the blob changed. A restore trap covered all four paths. After each leg the blob equals HEAD and git diff HEAD is empty. The cloud-connection and MCP subjects are imported from src, and the compile pins read src, so no dist leg applies.

Leg Mutation Result
A install-local resolver passes userAuthGates: undefined 2 failed / 1 passed: the held and clears cases are red, the credential-scope control is green
B stdio per-call refusal disabled (the if (gate) guard ANDed with a never-true clock comparison) 2 failed / 1 passed, same split
C1 ResolveAuthzInput.userAuthGates? optional again check:test-typecheck red: one unledgered type error in api-key-account-posture.test.ts (the unused directive)
C2 resolveApiKeyAdmission's userAuthGates? optional again check:test-typecheck red, same file

Verification

Every reading below is on head fc4aff4a17. That is the branch after origin/main 394f65e891 was merged in, with pnpm install --frozen-lockfile and the closure rebuilt.

  • Full suites (each package's own test script, --maxWorkers=2), all exit 0:
    • core 98 files / 2441 tests;
    • cloud-connection 46 / 555;
    • mcp 37 / 420;
    • service-storage 56 / 987;
    • plugin-sharing 45 / 1058;
    • rest 283 / 5852 passed, 326 skipped.
  • Touched test files in the other caller packages, all exit 0:
    • plugin-security 2 files / 120 tests;
    • plugin-auth 1 / 17;
    • cli integration layer (security-catalog-echo-env-kernel.integration.test.ts, the one cli file in the diff) 1 / 9;
    • dogfood 15 / 163: the 12 touched dogfood tests, plus the three suites that call the edited resolveAuthzFor helper.
  • Typecheck (pnpm --filter … run typecheck, including each package's check:test-typecheck ledger) exits 0 for all 13 packages that hold a call site: core, mcp, cloud-connection, service-storage, rest, runtime, plugin-sharing, plugin-security, plugin-auth, service-datasource, service-settings, dogfood and cli.
  • ESLint, narrowed: eslint --no-inline-config over the 37 changed .ts files reports 0 errors and 0 warnings.
  • Gates:
    • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 76 commands from fc4aff4a17. All 76 ran, each recording its exit code before any pipe.
    • --ran: 76 derived, 76 run, 0 NOT-MEASURED, 0 UNRUN.
    • One gate needed a second run. check:dual-build-cjs-loads first refused with exit 3 (PREREQUISITE NOT MET: eight packages outside this diff had no dist/). After those were built, it exited 0 (110 entry points across 68 packages load). The reconciled list carries the re-run.
    • check:adr-0087-registration verified the changeset's runtime-interface-only operand.
  • Size: 38 files, +1055 / −171 = 1226 changed lines against the merge base, under the 3,000-line threshold.

Acceptance notes

  • The stdio client reads the message, not the code. The data tools render a thrown error with errorResult(messageOf(err)), and the record resource writes { error: message }. Both are shared by the HTTP and stdio faces, and both flatten EVERY coded refusal on this transport to its message, RECORD_NOT_FOUND included. MCP run_action never enforces ai.requiresConfirmation — it is surfaced in list_actions only, while @objectstack/spec guidance tells authors it stops execution #15942 adopted envelope rendering (errorResultFromThrown) for the action tools only. The stdio refusal itself carries code and status, and the client receives the session's remediation message verbatim. Reported as an out-of-scope finding, and see Open question 2. The HTTP MCP face is unaffected: the dispatcher refuses the whole request with 403 MFA_REQUIRED before the MCP server sees it.
  • The stdio face's principal-free calls are as before. list_objects, describe_object, prompts and resource listings resolve no principal (the ADR-0101 boundary: the identity binds row data). So a held key reaches them exactly as a revoked key does today. Carrier: none.
  • Stale chain prose outside this diff:
    • ADR-0101 names resolveApiKeyPrincipal as part of the stdio verify chain (Tier H, not edited).
    • qa/dogfood/test/authz-conformance.matrix.ts's stdio row does the same.
      The chain is resolveAuthzContext → resolveApiKeyAdmission. This PR's own docblock in mcp/src/plugin.ts is corrected.
  • The changeset's remedy is one prose line, not a FROM → TO block. Measured with check:adr-0087-registration: a detectable FROM → TO for a required-parameter change is refused under every honest disposition.
    • no-migration-prescription and runtime-interface-only inherit the prescription refusal.
    • type-surface-only needs a base type that was erased.
    • runtime-export-retired needs a retired export.
      So the remedy is one instruction line, and the marker is runtime-interface-only on resolve-authz-context.ts#ResolveAuthzInput. The gate verified that marker.
  • @objectstack/runtime is graded minor with no source change. Its entry re-exports resolveApiKeyPrincipal explicitly, and the rest through export *, so its importers see the new required inputs.
  • No governed surface is in the file list: no .claude/**, docs/adr/**, skills/**, AGENTS.md or workflows.

Open questions

  1. Retire resolveApiKeyPrincipal once cloud is read? The ruling's premise could not be measured on cloud from this session.
    • A. The seat reads cloud. On zero callers, a follow-up retires it under runtime-export-retired @objectstack/core#resolveApiKeyPrincipal, and @objectstack/runtime's two explicit re-exports go with it.
    • B. Keep it wired as here.
    • Recommendation: A. The collapse cannot carry the posture's code (item 1), it has no in-tree caller, and the startup axis retires zero-pull exports at once. B stays correct and fail-closed in the meantime.
  2. The stdio code on the wire.
    • A. Leave the shared tool layer as it is: every coded refusal is presented as its message on both faces.
    • B. A separate card renders coded bridge refusals through errorResultFromThrown on the data tools and the record resource. That makes the code machine-readable on both faces for every refusal: a widening of what a client reads, with tests that pin today's text to re-judge.
    • Recommendation: B, as its own card. It reaches far beyond this family.

Generated by Claude Code

…seam names its owner-posture source (#23455, #23456) [WIP]

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
…local and per stdio call (#23455, #23456) [WIP]

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
…se a held owner's key (#23455, #23456)

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/cloud-connection, @objectstack/core, @objectstack/mcp, @objectstack/plugin-sharing, @objectstack/service-storage, touching 19 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/contracts/auth-service.mdx (via isAuthGateActive (symbol, a method of type GateAuthService))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 42 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f9b421716dd79df6c7c3a34bbd5a3a9f425e84f4 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e70398ac366d29035d5fb806996075faa603805b — the merge of head fc4aff4a17a782a70e82cecb27e8effe36e2a144 into base f9b421716dd79df6c7c3a34bbd5a3a9f425e84f4, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e70398ac366d29035d5fb806996075faa603805b && git checkout e70398ac366d29035d5fb806996075faa603805b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f9b421716dd79df6c7c3a34bbd5a3a9f425e84f4 fc4aff4a17a782a70e82cecb27e8effe36e2a144 && git checkout -B drift-repro f9b421716dd79df6c7c3a34bbd5a3a9f425e84f4 && git merge --no-ff fc4aff4a17a782a70e82cecb27e8effe36e2a144

node scripts/docs-audit/affected-docs.mjs --json f9b421716dd79df6c7c3a34bbd5a3a9f425e84f4

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f9b421716dd79df6c7c3a34bbd5a3a9f425e84f4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: fc4aff4a17a782a70e82cecb27e8effe36e2a144
Local-runs: none

① Derived judgments

Inputs: cards #23455 and #23456 (bodies, the family scope 6167950628, both claims, the dev report 6171624798), ruling C′ 6159450584 items 1, 3 and 4 and letter A 6165848580 on #23287, PR #23508's body, file list and net diff against the merge base 394f65e891, and the head's check-runs, read last. The seat's own Collected · comment was not an input. Code read on the head fetched into a private ref at this sha; nothing built, run or re-run.

  1. Install-local resolver (@objectstack/cloud-connection, member 1): right. resolveInstallPrincipal hands resolveAuthzContext userAuthGates: userAuthGateSourceOf(authService) (ctx.getService('auth') read per request, the sibling seams' shape) and refuses on evaluateAuthGate(authz, ''). The session path's answer is unchanged: normalizeAuthGate reads .authGate off whatever it is handed, and the shared resolver sets authGate = normalizeAuthGate(sessionData.user) on its session path, so the gate reads the same field off the same session user the resolver used to capture as sessionUser; if (!authz.userId) return null still precedes the consult and refuseHeldSession is unchanged. Key path: resolveApiKeyAdmission asks the source after the org-less refusal, accountScopeAuthGate picks the first account-scope posture, held carries the same principal as admitted plus gate, and the resolver lifts gate to authz.authGate. MFA_REQUIRED is account scope; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED are credential (AUTH_GATE_SCOPES), so a credential-scope posture carries no gate and is served. Pinned over the booted plugin's five doors: 403 with { success: false, error: { code: 'MFA_REQUIRED', message } }, zero effects, ledger untouched; served on PASSWORD_RESET_REQUIRED; served again once the posture clears with the key row unchanged. Items 1 and 4 met on this seam.

  2. MCP stdio (@objectstack/mcp, member 2, security(mcp): the stdio face resolves OS_MCP_STDIO_API_KEY with no owner posture, so a held owner's key starts and serves stdio MCP where ruling C′ refuses it at every seam #23456): right. resolveStdioExecutionContext takes a required userAuthGates, read per call by resolveStdioUserAuthGates(ctx) beside resolveStdioTenancyPosture, and assembles authGate: authz.authGate. resolvePrincipal asks evaluateAuthGate(ec, '') and throws stdioAuthGateRefusal(gate) (code the posture's, status 403, message the session's) before it returns a context. Every principal-bound path goes through it: the six bridge verbs in stdio-data-bridge.ts (query, get, create, update, remove, aggregate) each await resolvePrincipal() as their first statement, before enforceApiExposure and any engine call, and the record resource's getRecord in plugin.ts does the same before its exposure gate and find. listObjects, describeObject, prompts and resource listings resolve no principal, as before (the ADR-0101 boundary). After the start probe initial feeds only the warning, the info line and the localization hoist's tenantId/userId (a settings read, not a principal-bound row read). Start-with-a-warning is the right reading of item 4: the key is valid and nothing is written, so a start refusal would outlast the owner's remediation until a restart, while the per-call ask (the ADR-0101 D1 schedule revocation already rides) lets a posture that appears refuse the next call and one that clears serve the next; warn is the right level for a visible functional hold. Pinned on a real ObjectKernel with core unmocked: start succeeds with exactly one warning naming MFA_REQUIRED; query, get, create and the resource reject { code: 'MFA_REQUIRED', status: 403, message } with nothing written; a real StdioServerTransport tools/call answers isError with the remediation message; credential-scope served; appear-then-clear on one live session with the key row unchanged.

  3. The required inputs: right, and the census is complete. ResolveAuthzInput.userAuthGates: UserAuthGateSource | undefined, and tenancyPosture and userAuthGates on resolveApiKeyAdmission and resolveApiKeyPrincipal, are required (tenancyPosture because a required parameter cannot follow an optional one; ResolveAuthzInput.tenancyPosture stays optional). Production resolveAuthzContext callers on the head, git grep over the whole tree minus tests, test kits and test/ directories: rest-server.ts:3206, runtime/security/resolve-execution-context.ts:244, sharing-plugin.ts:965, service-datasource/admin-routes.ts:501, settings-service-plugin.ts:324 (untouched, each already userAuthGateSourceOf(authService) from PR feat(spec,core,plugin-auth)!: an API key answers as its owner's session does when the ADR-0069 policy holds the account (#23287) #23440); cloud-connection:3363 and mcp/plugin.ts:231 (new, real source); storage-service-plugin.ts:1403 (new, userAuthGateSourceOf(resolveGateAuthService(registry))). resolveApiKeyAdmission callers: resolve-authz-context.ts:454 and api-key.ts:286 (pass-throughs), storage-service-plugin.ts:1230 (userAuthGateSourceOf(auth), untouched). No caller in examples/, apps/, scripts/, qa/, or any .mts/.tsx/.js/.mjs, on origin/main or on the head; the caller set is the same eight sites plus the definition on both. No production site passes a bare undefined. The diff removes exactly two userAuthGates lines, the two ?: declarations, and adds 142 userAuthGates: undefined in tests and the sharing testkit plus the positional undefineds; the three test files whose call count exceeds their key count name the call in a docblock only, and the two others name it inside a string literal. exec-context-seam.testkit.ts composes no auth service and is neither on plugin-sharing's index.ts nor in its published dist; armed.ts names the stack's real auth service. The four @ts-expect-error pins sit in a never-invoked function compiled by check:test-typecheck, where an unused directive is TS2578.

  4. resolveApiKeyPrincipal: fail-closed, right as the interim. admission.outcome === 'admitted' ? principal : undefined, so held collapses to no principal exactly as refused and none do. It stays exported by core security/index.ts and re-exported by runtime (security/api-key.ts, security/index.ts, and export * from '@objectstack/core'). The collapse cannot carry the posture's code; its docblock says so and points a request-answering host at resolveApiKeyAdmission. Pinned held / credential / clears.

  5. Storage :1383 authorizer: the pass-through claim is true. registerStorageFamily mounts all eleven routes (storage-routes.ts :880 to :1563) through mountFamilyRoute, which runs refuseHeldSession(readGatedSessionUser, …) before the handler; buildAuthGateSessionReader's reader asks resolveApiKeyAdmission with userAuthGateSourceOf(auth) and the door's tenancy posture and answers { authGate } on held, which evaluateAuthGate turns into the 403. composeStorageRoutes binds that reader and the download authorizer from the same auth service through resolveGateAuthService, and both mounts (start() and mount-storage-routes.ts) go through it. buildFileReadAuthorizer never reads authGate, so with the source a held key resolves to the same userId, tenantId and grants as before and is judged on ownership as before. When no gate feature is active the reader returns before asking and plugin-auth's resolveUserAuthGates answers [] with no read, so no held can arise at the authorizer either; it fails open to [] on a lookup error. Nothing changes on the wire.

Public surface otherwise: no route, header, status or body moves beyond the two narrowings; no governed path in the file list (.claude/**, docs/adr/**, skills/**, AGENTS.md, CLAUDE.md, .github/workflows/** all absent); 38 files, 1226 changed lines.

② Semver level

  • The changeset .changeset/23455-key-admission-posture-required.md: @objectstack/core, runtime, cloud-connection and mcp minor; service-storage patch. Right. Two doors narrow their accept-set and core's three entries take required inputs, a BREAKING narrowing shipped minor under the launch-window convention; runtime re-exports resolveApiKeyPrincipal explicitly and the rest through export *, so its importers see the same narrowing; service-storage has no wire change (① 5). No other published package carries a non-test source change.
  • The ADR-0087 marker not-required (runtime-interface-only packages/core/src/security/resolve-authz-context.ts#ResolveAuthzInput …): right. The operand is an exported interface at the head, not a *.zod.ts, not under spec/src/contracts/, not a Zod projection, and git grep finds no .zod.ts, contracts or .object.ts file naming it in code. The two function signatures that narrow with it are named in the marker's prose and are runtime functions, not metadata surfaces.
  • Clause-②: no (narrowing) in the changeset is the arm check:adr-0087-registration reads, and the marker discharges it. The PR body carries a bare Clause-②: no, the claim's line verbatim: the value is right (no accept-set widens) and judgeLevel reaches clean on it beside the minor grades; it drops the (narrowing) arm the changeset and the ! title assert, and no gate cross-checks the two. See ③ 8.
  • The remedy as a prose line: the dev's claim holds. no-migration-prescription refuses a detectable prescription; runtime-interface-only inherits that refusal as a narrowing of it, by the gate's own header; type-surface-only needs an erased base type and runtime-export-retired a retired export, neither of which this diff has; registered would name a metadata conversion for a change objectstack migrate meta cannot reach. Nothing an author can write is removed or renamed, and the one-line fix is stated: userAuthGates: userAuthGateSourceOf(authService) wherever auth is reachable, undefined on a kernel without plugin-auth, tenancyPosture explicit on the two positional functions. Check Changeset and Lint & Repo Gates are success on this head.

③ Boundary flags

The dev's deviations, each answered:

  1. Member 3 not retired: Q1 below.
  2. The prose remedy: checked true in ②.
  3. The stdio code flattened on the wire: Q2 below.
  4. runtime minor and service-storage patch: checked right in ②.
  5. tenancyPosture required positionally while ResolveAuthzInput.tenancyPosture stays optional: right for this card, whose contract is the owner-posture source. Residual, not this PR's: an omitted tenancyPosture on the interface still compiles and silently disables the two tenancy refusals, the same by-omission class this PR closes for userAuthGates; every production caller passes it today. Escalated to the seat as a candidate card; no carrier here.
  6. Test-layer reach (141 literals, 20 positional calls, armed.ts on the real source): verified in ① 3; no test subject changed.
  7. origin/main 394f65e891 merged before the PR: it is the merge base, and the caller census on main and head is identical.
  8. PR body Clause-②: no beside the changeset's no (narrowing): each gate reads a consistent input for its own purpose; the body line under-declares the direction. Escalated to the seat: carry no (narrowing) on the body when the seat record is posted, or keep the claim's verbatim line; not blocking.
  9. Model-free trailers: nothing in the diff or the changeset names a model.

Q1, retire resolveApiKeyPrincipal: B stands for this head; A is escalated. The function is fail-closed on held (① 4), has zero non-test callers here on main and head and none in the dev's objectui reading, and objectstack-ai/cloud is unreadable from this fleet and from this review. The ruling's retirement premise has an unmeasured third leg, so retirement waits on a reading by someone who can read cloud (a follow-up under runtime-export-retired with runtime's explicit re-exports). Nothing is open meanwhile: the function cannot admit a held owner's key.

Q2, the stdio code on the wire: this PR is correct without it. The seam refuses with the posture's code, status 403 and the session's message in the thrown envelope, which is item 1's "same code" at the seam and item 4's remediation pointer. The data tools' errorResult(messageOf(err)) catches and the record resource's { error: message } flatten every coded refusal on both MCP faces, RECORD_NOT_FOUND included, and #15942 chose errorResultFromThrown for the action tools only; the HTTP MCP face is refused 403 MFA_REQUIRED by the dispatcher before the server sees it. A shared-layer widening beyond this family, filed by the seat as #23509.

Out-of-scope findings: (a) the flattening above, filed; (b) ADR-0101 (Tier H) and qa/dogfood/test/authz-conformance.matrix.ts still name resolveApiKeyPrincipal in the stdio chain, prose only, no carrier; the matrix row can ride the Tier H ADR-0101 correction or the Q1 follow-up; (c) principal-free stdio calls reach a held key exactly as a revoked key does, the pre-existing ADR-0101 boundary, no carrier.

Check-runs on this head, read at 2026-10-14T15:36Z: 40 runs, 33 success, 6 skipped (Build Docs, Console Pin Gate, the dogfood shard matrix and Verify CLI, the opt-in packed-tarball smoke, Temporal Conformance: full-battery or opt-in jobs), 0 failure, 1 in_progress: Test Core (4/6), started 15:11:27Z with its five siblings, which concluded success between 15:30Z and 15:34Z. Of the five required contexts, Lint & Repo Gates, TypeScript Type Check, Build Core and Governed Surface Queue Guard are success; Test Core is not yet green. in_progress is not a pass: the owning seat arms nothing until that shard concludes success.

Implemented-by: claude/issue-23455-key-admission-posture-family
Reviewed-by: session_0121ooBYRmS8ziFXm9ai5YHm

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

domain:spec seat 4 (#18917) · sunhaolin · session session_0121ooBYRmS8ziFXm9ai5YHm · claims 6168614481 (#23455) and 6168634754 (#23456) · reviewed head fc4aff4a17a782a70e82cecb27e8effe36e2a144 · 2026-10-14T15:41Z

Verdict: ACCEPT. Not governed (check-governed-merges --pr 23508), 38 files, +1055 / −171 (1226 lines, under the 3000 threshold). The CONTRACT_REVIEW_TIER review on this head is PASS (6172195145).

  1. Shape. Draft, Fixes #23455 and Fixes #23456 on their own lines, Clause-②: no at line start (the claim's closed spelling). The changeset carries Clause-②: no (narrowing): @objectstack/core, runtime, cloud-connection and mcp are minor (BREAKING), service-storage is patch. Its ADR-0087 marker is runtime-interface-only on ResolveAuthzInput, verified by the gate.
  2. Premises against the dispatch.
    • Family members 1 and 2 were wired on a real source. Member 3, resolveApiKeyPrincipal, is not retired: the ruling's premise has an unmeasured leg (objectstack-ai/cloud is unreadable from this fleet). The prohibition branch was taken instead: a required source, with held collapsed to no principal. That is fail-closed; retirement waits on a cloud reading.
    • The class fix's premise held: every production caller names its source with no new plumbing. The review's census found eight call sites, identical on main and head, with no bare undefined in production.
    • MCP stdio asks the posture on every principal-bound call (six bridge verbs plus the record resource) and starts with one warning, per ruling C′ item 4.
    • The storage :1383 authorizer's pass-through claim is true: all eleven routes mount through mountFamilyRoute.
  3. Gates and evidence.
    • Head check-runs read at this record: 35 success, 6 skipped (full-battery or opt-in jobs), 0 failures, 0 in progress. mergeable_state is clean.
    • The dev's local reconciliation was 76 derived, 76 run, 0 not measured.
    • Ablations: install-local and stdio went 2F/1P each; two compile pins went red through TS2578 when the input was made optional.
  4. Accepted deviations.
    • Member 3 kept, as above.
    • The remedy is written as a prose line, not a FROM → TO block. The gate refuses a detectable prescription under every honest disposition; the review checked that claim against the gate's header.
    • tenancyPosture became positional-required beside userAuthGates.
    • runtime is graded minor for its re-exports.
    • 141 test literals carry an explicit userAuthGates: undefined, and armed.ts names the real source.
  5. Out-of-scope dispositions.
  6. Landing clauses.
    • (a) The CONTRACT_REVIEW_TIER PASS on the landing head: met, 6172195145.
    • (b) Every check green or skipped by design: met at this reading.
    • (c) mergeable_state clean at enqueue: met at this reading.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 14, 2026 15:43
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit daafae6 Oct 14, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23455-key-admission-posture-family branch October 14, 2026 16:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment