Skip to content

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

Merged
os-zhuang merged 12 commits into
mainfrom
claude/issue-23287-key-posture-scope
Oct 14, 2026
Merged

os-zhuang merged 12 commits into
mainfrom
claude/issue-23287-key-posture-scope

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #23287
Clause-②: yes (narrowing) — the published contract widens (a closed posture vocabulary with a declared scope, the new PASSWORD_RESET_REQUIRED code, a per-user posture member on the auth service contract, new @objectstack/core exports) and the API-key door narrows (a key whose owner holds an account-scope posture is refused where it was served).

What changed

This is the code half of ruling C′ (director record 6159450584, the maintainer's 「同意」). The ADR-0069 D3 sentence (ruling item 5) is a separate Tier H PR. The key-lifecycle policy (ruling item 6) is #23367. Nothing here touches the cloud repository.

1. Posture codes are a closed vocabulary, and each code declares its scope (@objectstack/spec/kernel, ruling items 1 and 2).

  • AuthGateCodeSchema = PASSWORD_EXPIRED | PASSWORD_RESET_REQUIRED | MFA_REQUIRED. ExecutionContext.authGate.code is now typed against it.
  • AUTH_GATE_SCOPES declares each code's scope once, beside the codes: MFA_REQUIRED → account; PASSWORD_EXPIRED and PASSWORD_RESET_REQUIRED → credential. The table is typed total over the enum, so a new code without a scope does not compile.
  • PASSWORD_RESET_REQUIRED is registered under @objectstack/plugin-auth in the error-code ledger.

2. One per-user posture computation (@objectstack/plugin-auth). AuthManager.resolveUserAuthGates(userId, { organizationId }) answers every posture the user holds, most urgent first: forced reset, then expiry, then MFA. It is declared on the contract as IAuthService.resolveUserAuthGates. The session gate (computeAuthGate) takes its first entry, so a session sees what it saw before. The key seam reads the whole list, so a credential-scope entry at the head cannot hide an MFA requirement behind it. It is keyed by user id, which is the shape seat 1's waiter note asks for: #23335's impersonation leg can call the same oracle.

3. One judgment at the shared key-admission seam (@objectstack/core, ruling item 3).

  • resolveApiKeyAdmission takes an optional userAuthGates source. It asks it for the key's owner, in the key's own organization, AFTER the tenancy refusal. An account-scope posture yields a new outcome, held, carrying the principal and the posture.
  • resolveAuthzContext resolves a held key as a held principal: the owner, credential: 'api_key', and the posture on authGate. A held key does not fall through to the session path. The session arm now carries credential: 'session' and the session's own posture, so a door reads one field for either credential.
  • userAuthGateSourceOf(authService) binds the source from an auth service. A posture computation that rejects is the branded store outage (503), never an admission.
  • The refusal is not a revocation (ruling item 4): nothing is written, and the same key is admitted again once the posture clears.

4. REST and the dispatcher drop the cookie re-read after a key has admitted a user. RestServer.computeExecCtx and HttpDispatcher.enforceAuthGate now re-read the session for the gate only when a session admitted the caller. For a key, they use the key's answer. The dispatcher learns the admitting credential through a new onAdmission option on resolveExecutionContext; the envelope's authGate stays withheld on that face, as before. The declared-endpoint fallback (dispatcher-plugin.ts, gate step ②) does the same.

5. The five raw-host families, measured on main e4ec6ec1a.

Family Admits an API key? Here
datasource admin (requireDatasourceAdmin) yes, through resolveAuthzContext the door asks evaluateAuthGate about the resolver's posture, and wires userAuthGates
share links (SharingServicePlugin resolver) yes the resolver wires userAuthGates; the posture rides the spread envelope to the door
settings (SettingsServicePlugin resolver) yes the resolver wires userAuthGates and puts authz.authGate on the context
storage (registerStorageFamily door) yes, on the parent-governed download the door's gate reader asks resolveApiKeyAdmission for a key, and reads the session only when no key was recognised
webhook redeliver (serveRedeliver) no: the door authenticates by session alone no code change; a pin holds that a key alone is 401 on both faces

So the claim's assumption that the families needed only test changes does not hold: four of the five read the posture off the session user, which a key-admitted request never has. Each now reads the shared resolver's answer.

6. The forced reset (ruling item 2). sys_user.must_change_password now answers PASSWORD_RESET_REQUIRED instead of reusing PASSWORD_EXPIRED. runAdminSetUserPassword (the set_user_password action) carries revokeSessionsAndKeys, on by default. When on, after the new password is set, it revokes every live session (ADR-0069 D4's shape: expires_at past, revoked_at, revoke_reason: admin_password_reset) and every unrevoked API key (sys_api_key.revoked) of the user. The response and the audit row report what landed: revoked: { sessions, apiKeys, failed, complete }. A refused write is counted and logged, and the reset still succeeds. The act records no reason, so the switch defaults on, which is the suspected-compromise default (see Open questions). The action gains the switch as a param, translated in the four platform-objects bundles.

Seven faces, each with its pin

Every face pins three facts: a key whose owner holds MFA_REQUIRED gets 403 MFA_REQUIRED with the session's message; a key whose owner holds only a credential-scope posture is served; and the same key is served again once the posture clears.

Face Pin
core seam packages/core/src/security/api-key-account-posture.test.ts
REST packages/rest/src/rest-api-key-account-posture.pin.test.ts (also: a held cookie beside the key is never re-read)
dispatcher packages/runtime/src/http-dispatcher.api-key-account-posture.pin.test.ts, plus three key cases in endpoint-policy.auth-gate.pin.test.ts for the declared-endpoint fallback
datasource admin service-datasource/src/__tests__/admin-routes-api-key-account-posture.pin.test.ts (every mounted route)
share links plugin-sharing/src/share-link-routes.api-key-account-posture.pin.test.ts (booted plugin)
settings service-settings/src/settings-routes.api-key-account-posture.pin.test.ts (booted plugin, every route)
storage service-storage/src/storage-routes.api-key-account-posture.pin.test.ts (composed mount, every route)
webhook redeliver plugin-webhooks/src/webhook-redeliver-api-key.pin.test.ts (non-admission, both faces)

The forced-reset switch is pinned in admin-user-endpoints.test.ts: the default on position, the off position, a malformed value keeping the default, an incomplete revocation, and an engine with no read surface. The posture list is pinned in auth-manager.test.ts.

Negative control: the seam's judgment removed

The held return in resolveApiKeyAdmission was replaced through scripts/ablation-replace.mjs in WRAP mode, on the committed tree 2d2dd3fa4. Anchor 1 → 0, blob 8f6d0a1daa71 → aaabc628a1e4. Core was rebuilt, and ablation-dist-preflight.mjs --absent read the marker absent from all 14 built files. The face pins import core through its exports, so this dist leg was required.

Face Ablated
core seam 4 failed / 13 passed
REST 2 failed / 3 passed
dispatcher + declared endpoint 3 failed / 21 passed
datasource admin 12 failed / 12 passed
share links 2 failed / 1 passed
settings 2 failed / 1 passed
storage 11 failed / 20 passed
webhook redeliver 2 passed: green by construction, because the door never asks the seam

Only the held cases went red. The credential-scope, cookie-beside-key and session controls stayed green. Restore: the blob after restore equals HEAD (8f6d0a1daa71) and git diff HEAD is empty. Core was then rebuilt, and the preflight read the marker present in dist/index.js and dist/index.cjs with a clean tree.

Verification (head 56004af60)

Tests and type checks, per package, all passing:

  • Full suites on the pre-merge tree, every touched package:
    • spec 673 files (1 stale-artifact case, re-run green after regeneration);
    • core 98 / 2439; plugin-auth 140 / 2815; rest 282 / 5850; runtime 374 / 5460;
    • plugin-sharing 45 / 1058; plugin-webhooks 19 / 201; service-datasource 45 / 838;
    • service-settings 46 / 826; service-storage 56 / 987; platform-objects 70 / 1091.
  • After origin/main (b5b1bf727) was merged in, rebuilt and re-run: runtime 374 / 5463; spec 672 of 673 files. The one red case was the ADR-0122 isomorphic-pin count, raised 785 → 787 for the two new enums (93db5c5f0) and re-run green.
  • pnpm --filter … run typecheck exits 0 for all eleven packages, including their check:test-typecheck ledgers.
  • ESLint, narrowed, on 56004af60: eslint --no-inline-config --format json over the 45 changed .ts files reports 45 files linted, 0 errors, 0 warnings, none ignored. eslint.config.mjs never enables type-aware linting, so this diff cannot move a verdict on an untouched file.

Gates, on head 56004af60.

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, with no paths, derived 125 commands from that tree.
  • All 125 ran, each recording its exit code before any pipe, and all 125 exited 0.
  • --ran reconcile: 125 derived, 125 run, 0 NOT-MEASURED, 0 UNRUN.
  • Earlier passes caught and fixed these on the way:
    • the tenant-audit census and its prose figures (two new write sites, regenerated, and again after the merge);
    • the posture seam's fake engine: read-only, limit-bound, refusing an unimplemented combinator;
    • ADR-0122 bare-alias naming for the two enums, with their isomorphic pins (785 → 787);
    • a typed auth slot lookup in the settings plugin;
    • six spec gates that refused (exit 3) on a stale dist until spec was rebuilt.
  • A full workspace build (75 tasks) preceded the final pass, so check:dual-build-cjs-loads, check:i18n and check:skill-examples measured rather than refused. The i18n bundles, which carry the new action param, are in sync with the generator.
  • pnpm --filter @objectstack/spec check:generated: every artifact up to date. The regenerated skills/** index lines are forced by check:skill-refs, which measured stale on this diff before regeneration.
  • Size: 61 files, +2678 / −204 = 2882 changed lines against origin/main, under the 3,000-line threshold.

Acceptance notes

  • The Console does not recognise PASSWORD_RESET_REQUIRED yet. objectui's detectAuthGate (packages/auth/src/auth-gate-events.ts at origin/main b434f5f4, and at the pinned .objectui-sha) raises the remediation overlay for MFA_REQUIRED and PASSWORD_EXPIRED only, and RemediationOverlay routes PASSWORD_EXPIRED to the change-password form. Once this lands, a force-reset user gets 403 PASSWORD_RESET_REQUIRED and no overlay. See Open questions; this PR should not land ahead of the objectui change.
  • Faces outside the seven that admit keys and do not ask the owner yet: the marketplace install-local doors in @objectstack/cloud-connection, which read the gate off the session user only, and the MCP stdio face (resolveStdioExecutionContext), which carries no gate at all. Both are reported for the seat to route. The cloud-connection binding doors, the platform-admin judge and the webhook redeliver door authenticate by session alone.
  • Declared endpoints: the fallback's authRequired floor keys on the session principal (resolveSessionPrincipalId), so a key alone meets 401 on a default endpoint whatever its owner's standing. The posture bites on authRequired: false endpoints, which run as the key's principal. Pre-existing, not changed here.
  • Storage corner: the storage door asks the admission seam, not the full resolver, so a key whose membership ended AND whose owner is held answers 403 MFA_REQUIRED there, while REST answers the membership refusal's 401. Both refuse.
  • MFA grace clock: the posture list evaluates every policy, so a user under a forced reset has their MFA grace clock started on first sight instead of after the reset clears.
  • Session provenance: a credential-scope posture still holds every session, an SSO-opened one included, as before. The ruling's scope decides what holds a key; it does not change the session gate.
  • Stale two-code comments at the doors (share-link-routes.ts, settings-routes.ts, storage-routes.ts, the two REST sub-route families, platform-admin-gate.ts) now name the closed vocabulary.
  • skills/**: the only paths are gen:skill-refs outputs (skills/objectstack-{api,data,ui}/references/_index.md, one line each), generator-owned under the [finding] skills/*/references/_index.md is generator-owned (gen:skill-refs) yet sits inside the skills/** governed-merge fence — every spec PR touching an indexed headline is forced to carry a governed-surface file #11705 carve-out. Line readings: each file 46 → 46, 72 → 72 and 66 → 66; all SKILL.md together 4408 → 4408.

Open questions

  1. Landing order with objectui (the Console) — a sequencing decision for the seat. objectui recognises only MFA_REQUIRED and PASSWORD_EXPIRED as remediation codes, so once this lands a force-reset user gets 403 PASSWORD_RESET_REQUIRED and no change-password overlay.
    • A. objectui first: one small objectui change makes detectAuthGate accept PASSWORD_RESET_REQUIRED and routes it to the change-password form. It is harmless before the backend emits the code. This PR waits on it and on the .objectui-sha bump.
    • B. One landing: this PR carries the pin bump and manifest regeneration, the way a sibling-breaking removal ships.
    • C. Land this first and accept a window where force-reset users are stuck on raw 403s.
    • Recommendation: A. It is backward-compatible, keeps each repo's diff in its own repo, and leaves no window. C strands exactly the users an administrator just reset.
  2. The forced reset records no reason, so "on by default when the reason is a suspected compromise" has no reason to read. I measured the act (runAdminSetUserPassword, the set_user_password action): its body is userId, newPassword / generatePassword and mustChangePassword.
    • A. What is built: the switch is ON for every forced reset, which treats every forced reset as a possible compromise. Turning it off is the administrator's explicit act.
    • B. Add a reason field (suspected_compromise | routine) whose value sets the switch's default. That is a new API and action field nobody pulls today.
    • Recommendation: A, the narrowest honest default. B adds a surface whose only reader would be the default it replaces.
  3. The Clause-② grammar. The closed form takes both facts: Clause-②: yes (narrowing) reads as "widens one surface and narrows another" (scripts/pm/clause2-line.mjs: value yes, arm narrowing). Nothing is picked silently; noted here only because the dispatch asked.

维护者速读(草稿)

  • 改了什么: API key 现在和它主人的登录会话同答:主人被「强制两步验证」扣住时,key 在所有接口上返回同样的 403 MFA_REQUIRED;主人只是密码过期或被管理员强制改密时,key 照常工作。管理员强制改密改用自己的码 PASSWORD_RESET_REQUIRED,并带一个「退出所有会话并吊销 API key」的开关,默认开。
  • 为什么改: 裁决 C′:扣账号的姿态扣住账号的每种凭证,扣密码的姿态只扣密码登录;同一个请求在不同接口上要同一个答案。
  • 风险与代价(含回滚): 开了强制两步验证的部署里,没绑定的员工手里的 key 在宽限期满后会被拒,直到本人绑定(不需要重新签发)。管理员重置密码默认会让该用户在所有设备上退出、key 被吊销。Console 尚不认识新码,需先合 objectui 的配套改动。回滚:回退本 PR 即恢复旧行为;已吊销的 key 需在 sys_api_key 上逐个恢复。
  • 席位意见:
  • 你要做的: 确认 objectui 配套改动的先后顺序(见 Open questions);ADR-0069 D3 那句另走 Tier H PR。

Generated by Claude Code

… holds an account-scope owner

Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
Co-authored-by: Claude <noreply@anthropic.com>
…nswer; forced reset gets its own code and a revocation switch

Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
Co-authored-by: Claude <noreply@anthropic.com>
…in-auth posture list and the forced-reset switch

Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
Co-authored-by: Claude <noreply@anthropic.com>
…ion-map, docs, skill refs); declared-endpoint key pin

Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
Co-authored-by: Claude <noreply@anthropic.com>
…he key is looked up

Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
Co-authored-by: Claude <noreply@anthropic.com>
… pins, typed settings slot lookup

Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
Co-authored-by: Claude <noreply@anthropic.com>
The tenant-audit census took main's side and was regenerated on the merged
tree (tenant-audit-census --write); its hand-written figures follow the census.

Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
Co-authored-by: Claude <noreply@anthropic.com>
…e two posture enums

Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
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

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 10 package(s): @objectstack/core, @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/plugin-sharing, @objectstack/rest, @objectstack/runtime, @objectstack/service-datasource, @objectstack/service-settings, @objectstack/service-storage, @objectstack/spec, touching 77 documentable anchor(s). ⚠️ 8 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/admin-import-users.ts, packages/plugins/plugin-auth/src/platform-admin-gate.ts, packages/plugins/plugin-sharing/src/share-link-routes.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

43 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json b60eb78d35044949839e383823b228bcaf67dec4.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 8 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/admin-import-users.ts, packages/plugins/plugin-auth/src/platform-admin-gate.ts, packages/plugins/plugin-sharing/src/share-link-routes.ts, …) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (symbol, 41 pages)
  • 9 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 — 157 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 b60eb78d35044949839e383823b228bcaf67dec4 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6879ae4c29bfc06ca41bbdaae7648a1ff65a28d7 — the merge of head 5b59be6e66d7b2419c754ee756ee82dd30d1f1d2 into base b60eb78d35044949839e383823b228bcaf67dec4, 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 6879ae4c29bfc06ca41bbdaae7648a1ff65a28d7 && git checkout 6879ae4c29bfc06ca41bbdaae7648a1ff65a28d7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b60eb78d35044949839e383823b228bcaf67dec4 5b59be6e66d7b2419c754ee756ee82dd30d1f1d2 && git checkout -B drift-repro b60eb78d35044949839e383823b228bcaf67dec4 && git merge --no-ff 5b59be6e66d7b2419c754ee756ee82dd30d1f1d2

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

⚠️ 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 b60eb78d35044949839e383823b228bcaf67dec4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… it does not implement

Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

objectstack-fleet Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

维护者速读

席位核对

domain:spec seat 4 (#18917) · sunhaolin · session session_0121ooBYRmS8ziFXm9ai5YHm · claim 6165524186 on #23287 · reviewed head 5b59be6e66d7b2419c754ee756ee82dd30d1f1d2 · 2026-10-14T11:40Z (first verdict REWORK on 56004af60cb3de3f5de9509abe6989ac9cb9ad95, edited in place)

Verdict: ACCEPT. Tier H: it lands only on an authorized approval, with the landing clauses in item 7.

  1. Shape. Draft, base main, first line Fixes #23287; no other closing keyword sits beside any card number in the body (scanned). Clause-②: yes (narrowing) at line start in the body and in the changeset. 61 files, +2678 / −204 = 2882 changed lines against main. check-governed-merges --pr 23440: governed, Tier H, on the three skills/** gen:skill-refs outputs (the generated-surface exception did not lift them).
  2. Premises measured against ruling C′ (6159450584), read on the diff.
    • Item 1: AuthGateCodeSchema is closed (PASSWORD_EXPIRED, PASSWORD_RESET_REQUIRED, MFA_REQUIRED). AUTH_GATE_SCOPES is total over it: MFA_REQUIRED → account, both password codes → credential. authGate.code is typed against it.
    • Item 2: must_change_password answers PASSWORD_RESET_REQUIRED, and revokeSessionsAndKeys defaults ON (the maintainer's letter A, 6165848580).
    • Item 3: one judgment, in resolveApiKeyAdmission (the held outcome, after the tenancy refusal, in the key's own organization). resolveAuthzContext resolves a held key as a held principal and never falls through to the session. REST computeExecCtx and HttpDispatcher.enforceAuthGate re-read the cookie only for credential: 'session'.
    • Item 4: nothing is written on a hold, and the pins show the same key served again once the posture clears.
    • The dev's three disproved or partial premises are accepted as reported.
  3. Gates.
    • On 56004af60 (the reviewed code): 36 success, 7 skipped, 0 failures. Lint & Repo Gates, TypeScript Type Check, Test Core (six shards), Build Core, Governed Surface Queue Guard and Check Changeset all succeeded.
    • On the patch head 5b59be6e6, CI is converging (no failure at this edit).
    • Rejection pins assert status, error.code and message.
  4. REWORK item, closed. The changeset sentence on revoked keys now names the restore_api_key action (commit 5b59be6e66, one line). The patch round's base merge (37d650fb32, origin/main 4e0838d997) regenerated nothing; its only PR-owned overlap is a doc-comment hunk in execution-context.zod.ts, auto-merged.
  5. Accepted deviations. The file surface grew producer-side as the dispatch allowed: runtime dispatcher-plugin.ts and resolve-execution-context.ts, platform-objects (action param and four bundles), plugin-auth forced-reset-revocation.ts and admin-user-endpoints.ts, stale two-code comments at the doors, regenerated references. The patch round took the role file's base merge.
  6. Out-of-scope dispositions.
  7. Landing clauses.

…can restore a key a routine reset revoked

The sentence on the forced reset's revocation read as if the revocation were
final ("the owner mints replacements"). sys_api_key declares the
restore_api_key row action (bodyExtra revoked false) and key admission reads
revoked false, so an administrator can restore a key a routine reset revoked.
The sentence now says so; the rest of the changeset is byte-identical.

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5b59be6e66d7b2419c754ee756ee82dd30d1f1d2
Local-runs: none

Inputs: card #23287 (body and all 15 comments, ruling C′ 6159450584 and the letter A record 6165848580 included), PR #23440 (body, 61-file list, the diff against the merge base 4e0838d997, +2678 / −204; the base merge 37d650fb32 brought no PR-owned change beyond one doc-comment hunk), and the check-runs on this head. Nothing was built, run, re-run or ablated.

① Derived judgments

  1. Closed posture vocabulary and its scope (@objectstack/spec/kernel) — right. AuthGateCodeSchema is the closed enum PASSWORD_EXPIRED | PASSWORD_RESET_REQUIRED | MFA_REQUIRED; AuthGateScopeSchema is account | credential; AUTH_GATE_SCOPES is a frozen Readonly Record typed total over the enum (MFA_REQUIRED account, the two password codes credential), so an undeclared scope does not compile. ExecutionContext.authGate.code narrows from z.string() to the enum: a published accept-set narrowing, server-constructed and never authored, with one in-tree producer. The api-surface, declaration-map, export-origins and json-schema manifest shards, the reference docs (1533 to 1535 schemas) and the ADR-0122 isomorphic pin count (785 to 787) move in the same diff. Ruling items 1 and 2 as written.
  2. PASSWORD_RESET_REQUIRED in ERROR_CODE_LEDGER under @objectstack/plugin-auth — right. must_change_password stops reusing PASSWORD_EXPIRED (the import-users and set-user-password paths both now answer the new code); the generated contract and ledger pages carry it. The wire code for an administrator-forced change changes, which is breaking for a client keyed on PASSWORD_EXPIRED; the Console half is objectui#12365.
  3. IAuthService.resolveUserAuthGates?(userId, { organizationId? }) — right. An optional contract member keyed by user id, answering every held posture most-urgent-first (forced reset, expiry, MFA), [] when no policy is enabled (no read) or on a lookup error (fail-open, as the session gate). AuthManager.resolveUserAuthGates is the one implementation; computeAuthGate takes its first entry, so a session sees what it saw before. The real composition registers the AuthManager instance itself as the auth service, so userAuthGateSourceOf binds in production and not only in the pins. Keyed by user id, which is the shape auth-gate parity: the remediation allow-list exempts the whole auth namespace, and the platform-admin mounts under it judge the caller without the ADR-0069 posture, so a held platform admin may reach user administration (read from source, not yet measured) #23335's leg asked for.
  4. The @objectstack/core additions and the held outcome — right, with one named fail-open. authGateScope, accountScopeAuthGate, userAuthGateSourceOf and the UserAuthGateSource type are exported; resolveApiKeyAdmission takes an optional posture source and returns held (principal plus gate) when the owner holds an account-scope posture, asked after the tenancy refusal and in the key's own organization; a rejecting computation raises AuthzStoreUnavailableError (503), never an admission; nothing is written, so the refusal is not a revocation (ruling item 4). resolveAuthzContext resolves a held key as a held principal (credential: 'api_key', authGate set) and never falls through to the session; the session arm now carries credential: 'session' and its own posture, so every door reads one field. normalizeAuthGate now answers null for a code outside the vocabulary: a fail-open that only a producer bypassing the typed declaration can reach, and the envelope schema refuses such a code anyway; named here, judged consistent with the closed-enum ruling. resolveApiKeyPrincipal is unchanged and cannot be wired; see ③.
  5. The API-key door at the seven faces — right. REST: computeExecCtx hands the source to the resolver and takes a key-admitted principal's posture from the key's answer. Dispatcher: resolveExecutionContext hands the source and reports the admitting credential through onAdmission; enforceAuthGate judges a key-admitted principal with evaluateAuthGate (allow-list included) and the declared-endpoint fallback reads the same admission off the same protocol context. Datasource admin: requireDatasourceAdmin hands the source and judges the resolved context. Share links and settings: each plugin's resolver hands the source and the resolver's posture rides the context the door refuses on. Storage: the door's gate reader asks resolveApiKeyAdmission with the door's tenancy posture (held refuses, admitted and refused read no session, none falls to the session), and every ledger route is mounted behind that door, so the download authorizer's own unwired resolver call sits behind it. Webhook redeliver: admits no key on either face, pinned as 401. Each face carries its negative control (credential-scope served, cleared posture served again), and the recorded ablation of the held arm reddened only the held cases.
  6. Cookie re-read dropped for key-admitted requests — right. REST, the dispatcher and the declared-endpoint fallback re-read the session for the gate only when a session admitted the caller; the held-session controls on all three still refuse through the re-read.
  7. The forced-reset revokeSessionsAndKeys switch — right, one omission. Default ON for every forced reset (letter A, the maintainer's word on 6165848580); only a literal false turns it off; it runs after the password write; it stamps every live sys_session row with the ADR-0069 D4 shape (expires_at past, revoked_at, revoke_reason admin_password_reset) and sets sys_api_key.revoked on every unrevoked key of the user, system context, a 500-row scan ceiling; the response and the audit row carry counts only, an incomplete revocation is reported and logged and the reset still lands. The set_user_password action gains the param with its four translation bundles. Omission: admin_password_reset is a new value of sys_session.revoke_reason, whose description is, by the field's own comment, the published vocabulary and the only contract a consumer can read; the diff does not add it there. Prose-level, in ③.
  8. The skills/** lines — right. Three one-line regenerations of the export-list fallback in packages/spec/scripts/build-skill-references.ts, forced by check:skill-refs, generator-owned. skills/** is a Tier H row of the governed register, so the landing is the authorized approval's clause (recorded on the card, 6167553667, for this head), not this record's.

② Semver level

  • The changeset grades minor on ten packages: spec, core, rest, runtime, plugin-auth, plugin-sharing, service-datasource, service-settings, service-storage, platform-objects. That is exactly the set of published packages whose source moves; plugin-webhooks gains only a pin and is rightly absent. Under the launch-window convention a breaking change ships as minor, and all three breaking carriers are present and agree: the **BREAKING** banner, the feat(spec,core,plugin-auth)!: summary, and the narrowing arm.
  • Clause-②: yes (narrowing) — right, and the one legal reading of both facts (scripts/pm/clause2-line.mjs: value yes, arm narrowing). Widening: the closed vocabulary and scope table, the new error code, the optional contract member, the new core exports and the held arm, the optional userAuthGates and onAdmission inputs, HttpProtocolContext.admission. Narrowing: a held owner's key refused where it was served; authGate.code from string to enum; a changed wire code for the forced reset; exhaustive switches over ApiKeyAdmission['outcome'] must take held. The level axis is met: every moved package is at least minor.
  • ADR-0087 marker not-required (no-migration-prescription) — right. No authorable spec key, stored shape or metadata surface is renamed or removed; authGate.code is server-constructed; the body carries no rewrite prescription, so the exemption's own refusal rule does not bite.

③ Boundary flags

Dev report 6165340573:

Patch-round report 6167457099: no open questions. Its two findings are agreed: the forced-reset-revocation.ts docblock still ends "the owner mints a replacement" while the changeset now says a routine reset's key can be restored, and the helpText and docs are silent on restore — prose only, the seat's to fold or leave; main moved after the merge with no overlap, so no re-merge is owed.

Security, key-admitting surfaces the diff leaves unasked and nothing has filed:

  • (a) resolveApiKeyPrincipal in packages/core/src/security/api-key.ts, exported by core and re-exported by runtime: it calls the seam with no posture input and collapses the verdict to a principal, so any host that admits keys through it admits a held owner's key. The ruling named it as one of the seam's two callers; no door in this tree calls it today, and the changeset's "pass userAuthGates" sentence does not reach it because it has no such input. Escalated to the seat: file it (wire an optional source through it, or retire it), function level. Not a seam this tree serves, so not a FAIL input.
  • (b) The sys_session.revoke_reason published vocabulary line (item 7 above) lacks admin_password_reset. Escalated to the seat: fold the one-line description edit into the PR or file it with the landing.
  • (c) The normalizeAuthGate fail-open on an out-of-vocabulary code (item 4): named, no filing owed under the closed-enum ruling.
  • Not gaps: the storage download authorizer's unwired resolver call sits behind the family door; the plugin-sharing exec-context testkit and the dogfood harness are not request seams.

Governed landing: three skills/** generated lines make the PR Tier H; the authorized approval for this head is recorded on the card. Tier S does not apply.

Check-runs on 5b59be6e66, read 2026-10-14T11:57Z: 48 runs — 37 success, 10 skipped, 1 in_progress (Test Core 4/6), 0 failure. The in_progress is recorded as such; the landing clause that every check be green is the seat's.

Implemented-by: claude/issue-23287-key-posture-scope
Reviewed-by: session_0121ooBYRmS8ziFXm9ai5YHm

VERDICT: PASS

Merged via the queue into main with commit 0f05907 Oct 14, 2026
49 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-23287-key-posture-scope branch October 14, 2026 12:32
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

4 participants