Skip to content

feat(spec,core,runtime,plugin-auth)!: API keys get a lifecycle: 90-day default expiry, one-year ceiling, 90-day inactivity expiry, one-year grace, distinct refusal codes - #23541

Merged
objectstack-fleet[bot] merged 20 commits into
mainfrom
claude/issue-23367-api-key-lifecycle
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 20 commits into
mainfrom
claude/issue-23367-api-key-lifecycle

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #23367
Clause-②: yes

Executes ruling 6159880505 (letter A, maintainer 「同意」) on the re-scoped claim 6173499602, as amended by 6178205488 (the three relays below): the key store stays sys_api_key, and the API-key lifecycle is auth configuration. The changeset declares the arm, Clause-②: yes (narrowing): the published contract widens (four auth settings keys, IAuthService.resolveApiKeyLifecyclePolicy, four registered codes, new @objectstack/core exports), and the key doors narrow.

What changed

1. The policy is auth configuration (@objectstack/service-settings, @objectstack/plugin-auth, @objectstack/spec).

  • auth.manifest.ts gains the group API keys: api_key_default_expiry_days (90), api_key_max_expiry_days (365), api_key_inactivity_days (90, 0 = off), and api_key_policy_started_at.
  • bindAuthSettings binds the three numbers into AuthManager (apiKeyDefaultExpiryDays / apiKeyMaxExpiryDays / apiKeyInactivityDays). Only explicit values apply, as for every key there.
  • AuthManager.resolveApiKeyLifecyclePolicy() answers the policy with every number defaulted to the ruling's. The default never outlives the ceiling. It is declared on the contract as IAuthService.resolveApiKeyLifecyclePolicy (type ApiKeyLifecyclePolicy).
  • The landing is recorded once, in one place. On kernel:bootstrapped, after the settings engine binds, plugin-auth writes auth.api_key_policy_started_at when no value is stored, and never overwrites a stored one. The value survives restarts and every node reads the same instant; it is never the process start. Measured why kernel:bootstrapped: a write inside the kernel:ready window is refused SETTINGS_ENGINE_NOT_BOUND. With no settings service the anchor cannot be kept: said once at warn, and the two clocks it drives stay off. A failed write is said at error.

2. The signing door (POST /api/v1/keys, @objectstack/runtime). It reads the same source as the seam, through the auth service.

  • No expires_at gives now + defaultExpiryDays, echoed in the response.
  • An expires_at past now + maxExpiryDays is refused 400 API_KEY_EXPIRY_TOO_LONG, naming the latest date allowed. Exactly the ceiling is accepted, with 60 s of forgiven client clock skew.
  • A kernel with no auth service signs as before. An auth service that is registered and broken is the store outage (503), never a policy-less mint.

3. The key-admission seam (resolveApiKeyAdmission, @objectstack/core).

  • The lookup is by hash alone, so a revoked row is found and refused with its own code. A new outcome, lifecycle, carries one of three distinct codes: API_KEY_REVOKED, API_KEY_EXPIRED (past expires_at, or past the grace of a key with none) and API_KEY_INACTIVE. The located message names the key's prefix, the date and the remedy. An unknown key is still none.
  • Inactivity reads the PRE-stamp value. The verdict reads the row as found, and the stamp runs only for a key that verdict admits. A key's own call can never revive it, and a refusal writes nothing (not a revocation).
  • Keys with no recorded use count from the landing. They count from their own created_at only when that is later, i.e. a key signed after the landing. An earlier created_at is never the anchor: no use was recorded before the policy, so an old signing date says nothing about use. Measured deviation from the seat's wording "never created_at": counting a key signed after the landing from the landing would refuse it on its very first call once the landing is more than 90 days old. The later-of rule keeps the seat's intent for every pre-policy key.
  • Keys with no expiry get expires_at = landing + 365 days, stamped on the row the first time the key is admitted (the MFA-grace precedent, mfa_required_at). The keys page then names the date, and the 403 after it names it too.
  • resolveAuthzContext takes the policy as a REQUIRED input, apiKeyLifecycle, beside userAuthGates (the 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 shape). It raises a lifecycle verdict as the branded ApiKeyLifecycleRefusalError (403, code, located message), never falls through to the session, and logs one operator line.

4. last_used_at is written, and its description is now true. The field read "Automatically updated on each API call" (sys-api-key.object.ts), and nothing in this repository wrote it. The seat carried this as the dev's finding on the claim. The seam now stamps it on an admitted or held call, at most once an hour per key (API_KEY_LAST_USED_STAMP_INTERVAL_MS), through ql.update('sys_api_key', { id, ...fields }) in the system context. A held key is stamped too: ruling C′ item 4 serves it again when its owner remediates, which an idle clock run out during the hold would defeat.

  • Measured against the durability log-level rule. A failed stamp never fails the request, because the key was judged on the row as found. It is a durability degradation all the same: the field claims each use, and an unrecorded use lets a key in daily use read as idle. So it is said at error, with the consequence and the fix, once per data engine (a WeakSet latch), never once per request.
  • The DB-held throttle needs no in-memory state, survives restarts and is multi-node safe; a race costs one duplicate write. The description now reads "Recorded when the key is used, at most once an hour…". The en bundle was regenerated; zh-CN, ja-JP and es-ES were translated by hand.

5. Every call site that builds ResolveAuthzInput passes the source (apiKeyLifecyclePolicySourceOf(authService)). They are the 8 faces plus the two test seams; the test seams pass an explicit undefined (no auth service) or the real source (dogfood armed.ts). The storage gate reader's direct resolveApiKeyAdmission call passes it too. The refusal's answer on each face:

Face Answer to a revoked / expired / inactive key
REST data API (rest-server.ts) 403 + code + located message. computeExecCtx resolves no context, keeps the refusal beside the request (a WeakMap), and enforceAuth answers it. Every other door keeps its fail-closed answer.
MCP stdio (mcp/src/plugin.ts) per call: the thrown 403 envelope; at start: refuses to start, naming the code
datasource admin (admin-routes.ts) 403 + code (re-raised ahead of the routes' try; the host's thrown-envelope render answers it)
share links (sharing-plugin.ts) 403 + code (the family door relays a throw that declares its status)
marketplace install-local (marketplace-install-local-plugin.ts) 403 + code (raw-app routes; the host's onError envelope render)
runtime dispatcher (http-dispatcher.ts identity step), incl. MCP HTTP 403 + code (re-raised by brand beside the store outage; the transport's errorResponseBase renders the declared status and registered code)
settings (settings-service-plugin.ts → settings-routes.ts) 403 + code (the resolver re-raises the brand; the family door relays it beside relayAuthzStoreOutage)
storage download (storage-service-plugin.ts → storage-routes.ts) 403 + code (the authorizer re-raises the brand; the download gate relays it beside the outage relay)

6. Codes. All four are registered in ERROR_CODE_LEDGER and content/docs/api/error-catalog.mdx. API_KEY_EXPIRED, API_KEY_INACTIVE and API_KEY_REVOKED sit under @objectstack/core; API_KEY_EXPIRY_TOO_LONG sits under @objectstack/runtime. EXPIRED_TOKEN was judged and not reused. Its catalog meaning is a JWT or session token whose remedy is a refresh, and its retry is retry_immediate (after refreshing); a key has no refresh, and its remedy is a new key. Each code is declared as its wire pair ({ code, status }), so check:error-status-conformance derives a producer for it.

7. Docs. content/docs/ai/connect-mcp.mdx gains Key lifetime, inactivity and rotation: the policy, the codes, rotation without a gap, and which faces answer the codes. content/docs/api/index.mdx points to it, and the stdio paragraph names inactive keys. Cloud's agent-connection page is cloud's (the seat's pointer on cloud#2868).

OS_CLOUD_API_KEY

Read first, as ruling item 4 orders. In this repository it is the runtime's outbound bearer to the control plane (cloud-connection-plugin.ts:166, marketplace-install-local-plugin.ts, the marketplace fetch). It is never admitted by resolveApiKeyAdmission, and cloud-connection-plugin.ts records that the sys_api_key-backed data API rejects it with 401. It is not a sys_api_key row: untouched, and no exemption question arises. Its second use, the CLI publish --token env fallback, carries whatever key the user sets; a personal osk_ key there is an ordinary key on the cloud runtime. Cloud's own verification of the variable: NOT MEASURED (no cloud checkout in this container).

The three relays (claim amendment 6178205488)

The seat widened the claim by three files so ruling item 3's codes reach every face. Each relay is matched by the brand (isApiKeyLifecycleRefusalError); every other throw keeps today's answer, and an unknown key keeps the generic 401.

  • runtime dispatcher: packages/runtime/src/http-dispatcher.ts, the identity step's catch only: if (isApiKeyLifecycleRefusalError(err)) throw err; ahead of rethrowAuthzStoreUnavailable(err), plus the name in the file's @objectstack/core import. dispatch() leaves with the refusal, and dispatcher-plugin's errorResponseBase answers its 403 and code. This includes the MCP HTTP endpoint (/api/v1/mcp) and the declarative-endpoint fallback, which renders through the same errorResponse.
  • settings: settings-routes.ts gains relayApiKeyLifecycleRefusal beside relayAuthzStoreOutage, called from the same family-door catch; settings-service-plugin.ts's resolver re-raises the brand instead of returning the fail-closed { enforced: true }. Two doc comments in the door now name the relay.
  • storage: storage-routes.ts, the download gate's catch (authorizeDownload, shared by the URL and redirect routes): the brand is relayed beside the outage. storage-service-plugin.ts's buildFileReadAuthorizer re-raises it instead of returning 'unauthenticated'.
  • The storage gate catch (refuseHeldSession) is not edited, because no brand can reach it. Its reader (buildAuthGateSessionReader) calls resolveApiKeyAdmission, which returns a lifecycle outcome and never throws one. The reader answers that outcome with no posture, as it answers refused, and leaves the key to the route's own identity resolution. On a download, that is the authorizer above, which relays the code. The upload routes read the session cookie alone (buildAuthSessionResolver) and never admit a key, so no key face is left behind. A relay there would be unreachable code.

The CI red at d18cfe5488 (Test Core 1/6, packages/spec test:repo) is fixed in 469acd6a41. Reproduced locally: src/api/error-catalog-docs.test.ts › "the advertised code count matches the wire face" (expected 52 to be 56). The catalog page states how many codes reach the wire, and that claim is pinned to deriveWireFace(). The four codes registered here moved the face from 52 to 56, and the page still said 52. The page now says 56. No test was touched.

Tests

Core: api-key-lifecycle.test.ts (20). Runtime: api-key-lifecycle.pin.test.ts (14). It covers the signing door; on the dispatcher, dispatch() raising 403 API_KEY_REVOKED, and 403 API_KEY_EXPIRED with a valid cookie beside the key, nothing minted; an unbranded throw at the identity step spelling the same code and status, still answered the generic 401 UNAUTHENTICATED; an unknown key, 401; a live control, 201; and, on the wire through the mounted POST /api/v1/mcp route, 403 API_KEY_REVOKED in a schema-valid envelope and 403 API_KEY_INACTIVE. Settings: settings-routes.api-key-lifecycle.pin.test.ts (5): revoked on every mounted route, expired, inactive, unknown key 401 UNAUTHENTICATED, and a live control. Storage: storage-routes.api-key-lifecycle.pin.test.ts (4): revoked and expired give 403 with the code and nothing minted, an unknown key gives 401 AUTH_REQUIRED, and a live key gets its signed URL. REST: rest-api-key-lifecycle.pin.test.ts (4). plugin-auth: four policy cases in auth-plugin.test.ts. Dogfood: api-key-lifecycle.dogfood.test.ts (3, the showcase stack end to end). It proves the landing is recorded in the auth settings, the 90-day default, the 400 API_KEY_EXPIRY_TOO_LONG, and that one admitted call really writes last_used_at through the real engine. It also proves a pre-policy key gets its grace stamped. The three existing dogfood key probes now read a refused key as 401, or as 403 with code API_KEY_REVOKED and only that code.

Pins against the dispatch's list: no expires_at gives +90 d; +365 d accepted, +366 d refused with its code; idle > 90 d refused API_KEY_INACTIVE, the row not written, and re-issue works. A no-expiry key is served until the grace end and refused after it, naming the date; the date is on expires_at from first use. Revoked, expired and inactive give three codes. Each number comes from the source, so a changed setting changes the verdict. The stamp pin: one admitted call writes last_used_at, and the throttle holds. The pre-stamp pin and the NULL-anchor pin are listed above. The missed-face pin is now the dispatcher's unbranded-throw case: a face that misses the brand fails closed to its generic 401.

Ablations (scripts/ablation-replace.mjs, run against committed HEAD 204fb33318; the core suite imports ./api-key.js from source, so no build stands between the mutation and the run):

  • Inactivity check. nowMs - idle.ms > policy.inactivityDays * DAY_MS was replaced by false. Anchor x1 → x0, blob 2c7db74f5e18 → e3f45c2db200: 5 failed / 15 passed (three codes, pre-stamp, landing anchor, signing anchor, source number). Restored: blob == HEAD 2c7db74f5e18, git diff HEAD empty.
  • The stamp. The ql.update('sys_api_key', …) line was replaced by void patch;. Anchor x1 → x0, blob → 48112ae9cdca: 5 failed / 15 passed (grace stamp, one call writes, throttle, held key stamped, said-once). Restored: blob == HEAD, git diff HEAD empty.
  • The dispatcher relay (at committed HEAD e50fb94b57; the pin imports ./http-dispatcher.js and ./dispatcher-plugin.js from source, so no build stands between). if (isApiKeyLifecycleRefusalError(err)) throw err; was replaced by void 0;. Anchor x1 → x0, blob 22d1fe62b338 → e0459ffb1954: 4 failed / 10 passed (the two dispatch() codes and the two MCP HTTP wire codes). The unbranded-throw, unknown-key and live controls stayed green. Restored: blob == HEAD 22d1fe62b338, git diff HEAD empty.

Local verification (head 3c2b4152f9)

This round touched runtime, service-settings, service-storage, the changeset and two docs pages. The readings for the packages it did not touch are the previous round's, at d18cfe5488 after merging origin/main (989aa483fd). Those were: full suites green for core (105 files / 2544), plugin-auth (140 / 2819), mcp (37 / 420), service-datasource (45 / 838), plugin-sharing (46 / 1061) and cloud-connection (46 / 555); rest, the 30 key/resolver/enforceAuth files (474); typecheck exit 0 for spec, core, rest, mcp, plugin-auth, plugin-sharing, service-datasource, cloud-connection, plugin-security, cli and dogfood; and the CLI integration file (9).

  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 128 families on this head. All 128 ran with their exit codes captured before any pipe. 125 exited 0 on the first pass. check:skill-examples, check:dual-build-cjs-loads and check:i18n exited 3 (PREREQUISITE NOT MET: packages not yet built, so nothing was measured), then exited 0 when re-run on the built workspace. --ran: 128 derived, 128 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero: every family recorded an exit code). Local-only: check:dev-prereqs and check-nul-bytes exit 0.
  • Typecheck (exit 0): runtime (tsc plus check:test-typecheck), service-settings (tsc; its file list includes the new pin, counted with --listFiles) and service-storage (tsc, tsconfig.scripts.json, check:test-typecheck).
  • Tests:
    • full suites: service-settings (47 files / 831) and service-storage (57 / 991);
    • runtime: the 76 test files that name the key, the resolver or the identity step (2324 passed, 18 skipped). That set comes from a git grep, which is blind to a test that reaches the identity step without naming it; CI runs the full suite;
    • spec, the repo project in full (pnpm --filter @objectstack/spec run test:repo): 61 files / 1033 tests passed, including error-catalog-docs.test.ts, the file red at d18cfe5488;
    • dogfood, on the showcase stack: the four key tests, the lifecycle test and showcase-mcp-http-identity (5 files / 29 passed).

Acceptance notes

  • Change size: 81 files, +2741 / −178 = 2919 changed lines (against merge-base 989aa483fd), under the 3000-line class.
  • ApiKeyLifecycleRefusalError is a throw rather than an envelope field, for the reason authz-store-unavailable.ts records. A face that misses a throw fails closed by default; a face that misses a field answers the old quiet value.
  • API_KEY_LEGACY_GRACE_DAYS (365) is a constant, not a fifth setting. The grace is a one-time transition stamped onto each key, so a setting could not move a date already stamped. Moving the landing (api_key_policy_started_at) moves it for keys not yet stamped.
  • The amendment's three files each gained one name in their @objectstack/core import (isApiKeyLifecycleRefusalError), which the relay needs. settings-routes.ts's two doc comments that list what the door relays now name the new relay. The changeset, error-catalog.mdx and connect-mcp.mdx now list every face instead of naming three as answering the generic 401.
  • packages/core/src/security/index.ts gained the new exports (the barrel of the claimed api-key.ts). scripts/engine-double-contract.pinned.json records the new pinned double (--write, as the gate prescribes).
  • packages/objectql/src/internal-fields.test.ts still describes the verifier's lookup as { key, revoked: false }. The test is true (that query still works), but it is no longer the seam's query. Noted, not edited: it is outside the surface. Carrier: none.

Generated by Claude Code

claude added 17 commits October 14, 2026 16:59
… and landing record (#23367)

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
…answers the refusal (#23367)

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
… hash-only lookup (#23367)

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
…log, last_used_at description (#23367)

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
…s for the lifecycle contract (#23367)

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
…es hold limit and the update contract (#23367)

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

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 12 package(s): @objectstack/cloud-connection, @objectstack/core, @objectstack/mcp, @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 99 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/api-surface/contracts.json, packages/spec/export-origins/contracts.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

45 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 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02.

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

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/contracts.json, packages/spec/export-origins/contracts.json) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: enforceAuth (23 routes)
  • 5 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 — 160 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 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 63ba9b300dd308d33bda76ce324082cf1058ac62 — the merge of head 3c2b4152f9693935ac039d2daeb3be8c83be74de into base 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02, 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 63ba9b300dd308d33bda76ce324082cf1058ac62 && git checkout 63ba9b300dd308d33bda76ce324082cf1058ac62
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02 3c2b4152f9693935ac039d2daeb3be8c83be74de && git checkout -B drift-repro 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02 && git merge --no-ff 3c2b4152f9693935ac039d2daeb3be8c83be74de

node scripts/docs-audit/affected-docs.mjs --json 0fb55fc2fca5590a76c1827d95bf7d1bacf22d02

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

…cycle refusal at the dispatcher, settings and storage doors

The runtime dispatcher's identity step (the MCP HTTP endpoint among its
routes), the settings family door and the storage download gate re-raise
or relay the branded ApiKeyLifecycleRefusalError beside the store outage,
so a revoked, expired or inactive key answers its own 403 and ledger code
there too. Matched by brand: every other throw keeps its existing answer,
and an unknown key keeps the generic 401. One pin per face.

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
…our API-key lifecycle codes

The catalog page's own count claim is pinned to the wire face
(error-catalog-docs.test.ts, "the advertised code count matches the wire
face"). The four codes registered for #23367 moved the face from 52 to 56;
the claim stayed at 52 and turned the spec repo project red in CI.

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
…ts a key

The runtime dispatcher (MCP HTTP among its routes), the settings routes and
the storage download routes now relay the refusal, so the changeset, the
error catalog and the MCP connection guide list every face instead of
naming three as answering the generic 401.

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: 3c2b4152f9693935ac039d2daeb3be8c83be74de
Local-runs: none

Inputs read: card #23367 body; ruling 6159880505; claims 6173499602 and 6173194763 (the latter by the re-scoped claim's reference, file list only); the amendment 6178205488 (its three-path file list only); dev reports 6178148974 and 6179873073; PR #23541 body, metadata and 81-file list; the three-dot net diff origin/main...3c2b4152f9 (merge-base 989aa483fd; its 81-path list is byte-identical to the PR's, +2741/−178); every touched source, test, generated and docs file at the head and at origin/main, with the untouched context around each hunk (api-key.ts, resolve-authz-context.ts, auth-service.ts, error-code-ledger.zod.ts, auth-manager.ts, auth-plugin.ts, keys.ts, http-dispatcher.ts, settings-routes.ts, settings-service-plugin.ts, settings-service.ts set/setMany, storage-routes.ts, storage-service-plugin.ts, auth.manifest.ts, rest-server.ts, mcp/src/plugin.ts, admin-routes.ts, sharing-plugin.ts, marketplace-install-local-plugin.ts, resolve-execution-context.ts, sys-api-key.object.ts, the seven translation files, the changeset, error-catalog.mdx, connect-mcp.mdx, api/index.mdx, the two reference pages, api-surface/contracts.json, export-origins/contracts.json, engine-double-contract.pinned.json); the new pins (core/security/api-key-lifecycle.test.ts, runtime/src/api-key-lifecycle.pin.test.ts, rest-api-key-lifecycle.pin.test.ts, settings-routes.api-key-lifecycle.pin.test.ts, storage-routes.api-key-lifecycle.pin.test.ts, api-key-lifecycle.dogfood.test.ts, the auth-plugin.test.ts block) and every fixture diff; the repo's ql.update precedent, resolveServiceOrLoud, deriveWireFace, check-adr-0087-registration.mjs (the disposition vocabulary), check-changeset-no-major.mjs (launch-window), the Clause-② recognisers, the .changeset/23287-* and .changeset/23455-* precedents on origin/main, the OS_CLOUD_API_KEY read sites; the head's 48 check-runs, read last.
Not read: any comment titled ## 席位核对, Collected, the amendment's reasoning beyond its file list, any seat ACCEPT/pointer line, every other comment on the card and the PR, cloud and objectui checkouts (none in this container).

① Derived judgments

  1. Ruling fidelity (items 1–4): right, with one located nuance on the reminder.

    • Key store stays sys_api_key: right. The seam still looks up sys_api_key by hash alone (packages/core/src/security/api-key.ts:677-681); no dependency, table or package is added; the object changes only last_used_at's description (packages/platform-objects/src/identity/sys-api-key.object.ts:267-279).
    • Policy as auth configuration, defaults 90 / 365 / 90, 1-year grace from the landing: right. Four auth keys with those defaults (packages/services/service-settings/src/manifests/auth.manifest.ts:370-418); AuthManager.resolveApiKeyLifecyclePolicy() falls back to API_KEY_DEFAULT_EXPIRY_DAYS/API_KEY_MAX_EXPIRY_DAYS/API_KEY_INACTIVITY_DAYS = 90/365/90 (auth-manager.ts:8229-8247, constants at api-key.ts:154-160); bindAuthSettings binds the keys (auth-plugin.ts:1914-1938); the grace is API_KEY_LEGACY_GRACE_DAYS = 365 from startedAt (api-key.ts:170, legacyGraceEndMs at :338-341), the landing recorded once at kernel:bootstrapped (auth-plugin.ts:856-858, recordApiKeyPolicyStart at :1681-1711).
    • Longer expires_at refused at a located spot: right. keys.ts:206-222 answers 400 API_KEY_EXPIRY_TOO_LONG naming the latest date; this is the only writer of expires_at a caller reaches (handleKeysRequest is POST-only, keys.ts:111; the data-API update whitelist on sys_api_key is revoked alone and expires_at is readonly, sys-api-key.object.ts:34-36, 263, 368-372).
    • Inactivity by last_used_at, located code, not revoked: right. idleWindowStartMs reads last_used_at (api-key.ts:355-368); lifecycleRefusalOf answers API_KEY_INACTIVE naming the date and "It is not revoked" (:272-283); the lifecycle return at :716-717 precedes stampAdmittedKey at :838, so a refusal writes nothing (pinned: core test "a key idle more than 90 days is refused although this call would have stamped it", updates stays []).
    • Reminder ahead of the grace — keys page and 403 body: right as far as this repo reaches. The 403 body names the date (API_KEY_EXPIRED message "…its one-year grace … ended on ${at}", api-key.ts:250-262; carried verbatim by REST at rest-server.ts:2457-2461 and by every relaying door). The keys page is objectui's (claim 6173194763: "No objectui change: the keys page's reminder copy is a consumer card"); this repo's half is the grace stamped onto expires_at at first admission (api-key.ts:409-412), a column every sys_api_key list view already shows (sys-api-key.object.ts:131, 143, 155). Nuance: a pre-policy key never admitted after the landing carries no stamped date, but such a key is refused API_KEY_INACTIVE at 90 days naming the landing (or, with inactivity off, API_KEY_EXPIRED naming the grace end), so no key reaches the grace end unstamped without being told the date in its 403.
    • Distinct, located codes: right. API_KEY_EXPIRED / API_KEY_INACTIVE / API_KEY_REVOKED registered under @objectstack/core (packages/spec/src/api/error-code-ledger.zod.ts:933-944), API_KEY_EXPIRY_TOO_LONG under @objectstack/runtime (:305-311); each stamped through its declared wire pair (api-key.ts:208-210; keys.ts:66); catalog entries (content/docs/api/error-catalog.mdx:84-88, 437-452).
    • Docs name rotation: right. content/docs/ai/connect-mcp.mdx:129-176 "Key lifetime, inactivity and rotation" with the no-gap rotation sentence; content/docs/api/index.mdx:72-76 points to it; cloud's agent-connection page is cloud's (the ruling's own pointer on cloud#2868).
    • Item 4 (OS_CLOUD_API_KEY read first): right. It is the runtime's outbound bearer (packages/cloud-connection/src/cloud-connection-plugin.ts:166, marketplace-install-local-plugin.ts:1214) and the CLI publish token fallback (packages/cli/src/commands/package/publish.ts:308); never read by resolveApiKeyAdmission; the PR body names it untouched and marks cloud's own verification NOT MEASURED.
    • Nothing the ruling asks of this repository is missing. Outside it, by the ruling's own execution plan: the keys-page copy (objectui) and cloud's docs/reminder copy (cloud#2868).
  2. Seat decisions 1–5 and the two accepted deviations: right.

    • (1) one PR — Fixes #23367, head 3c2b4152f9. (2) the policy is auth configuration bound by bindAuthSettings, read through IAuthService.resolveApiKeyLifecyclePolicy (packages/spec/src/contracts/auth-service.ts:213-227), and ResolveAuthzInput.apiKeyLifecycle is a REQUIRED (non-optional) key beside userAuthGates (packages/core/src/security/resolve-authz-context.ts:210-224), as is the sixth parameter of resolveApiKeyAdmission/resolveApiKeyPrincipal (api-key.ts:654-666, 609); all ten builders pass apiKeyLifecyclePolicySourceOf(authService) or an explicit undefined (rest-server.ts:3289-3290; resolve-execution-context.ts:255-259; mcp/src/plugin.ts:259; sharing-plugin.ts:974-975; settings-service-plugin.ts:334-335; storage-service-plugin.ts:1242-1246, 1419-1420; admin-routes.ts:510-511; marketplace-install-local-plugin.ts:3372-3373; exec-context-seam.testkit.ts:131; qa/dogfood/test/armed.ts:222-223). (3) the stamp is throttled and the inactivity verdict reads the pre-stamp row: lifecycleRefusalOf(row, …) at api-key.ts:716 runs on the row as found, stampAdmittedKey at :838 runs only after the verdict, the posture refusals and the gate computation — the test "inactivity reads the PRE-stamp value" pins both halves. (4) the anchor is auth.api_key_policy_started_at, never the process start: the manager's startedAt is only ever this.config.apiKeyPolicyStartedAt (auth-manager.ts:8234-8237), set only from the stored setting (auth-plugin.ts:1933-1937). (5) distinct codes, EXPIRED_TOKEN judged and not reused (ledger comment :936-939), the branded ApiKeyLifecycleRefusalError (api-key.ts:213-238), each face re-raises by brand, and a missed face fails closed (runtime pin: an unbranded throw spelling API_KEY_REVOKED/403 is still 401 UNAUTHENTICATED).
    • Deviation A — NULL last_used_at counts from max(landing, created_at): implemented at api-key.ts:361-367 (createdMs > startedMs ? signing : landing), pinned both ways ("counts from the landing, never from an EARLIER created_at"; "counts from the signing when the key was signed after the landing"); with no landing recorded, no inactivity is judged (:362, pinned).
    • Deviation B — the legacy grace is the constant API_KEY_LEGACY_GRACE_DAYS = 365, not a fifth setting: api-key.ts:170, exported (core/src/security/index.ts:132), pinned ("is stamped onto expires_at (landing + 365 days)"; "after the grace the key is refused API_KEY_EXPIRED, naming the date").
  3. Published contract surfaces and semver: right on every item but one sub-arm, which is a non-blocking note.

    • IAuthService.resolveApiKeyLifecyclePolicy?() (optional, additive) and ApiKeyLifecyclePolicy (auth-service.ts:62-87, 213-227); api-surface/contracts.json and export-origins/contracts.json each gain exactly that one line in sorted position.
    • The four ledger codes are in ERROR_CODE_LEDGER and in the regenerated reference pages (contract.mdx +327 → +331; four sorted bullets in both).
    • The new @objectstack/core exports (core/src/security/index.ts:128-144): five day/interval constants, three code constants, the status, apiKeyLifecyclePolicySourceOf, ApiKeyLifecycleRefusalError, isApiKeyLifecycleRefusalError, three types.
    • The four auth settings keys (auth.manifest.ts:370-418), translated in three locales.
    • Clause-②: yes is truthful (the published accept set widens: a contract member, four codes, exports, four settings keys); the changeset's arm yes (narrowing) is right — the key doors narrow, so it is BREAKING, carries the BREAKING banner and a ! title, and ships as minor under the launch-window convention (scripts/check-changeset-no-major.mjs:5), the same shape as .changeset/23287-api-key-account-posture.md:16-20. Every package with a published source change is bumped minor: spec, core, runtime, rest, mcp, plugin-auth, plugin-sharing, service-settings, service-storage, service-datasource, cloud-connection, platform-objects (plugin-security and cli change tests only; qa/dogfood is a test package).
    • ADR-0087: the not-required half is right (no authorable key, stored shape or metadata surface moves; sys_api_key gains no column). The chosen sub-arm (no-migration-prescription) is the weaker of two honest arms: the changeset's last bullet is a host-code rewrite instruction ("Hosts calling resolveAuthzContext … pass apiKeyLifecycle: …, or the sixth argument"), which the gate's own definition of that arm names a self-contradiction that passes only on a detector miss (scripts/check-adr-0087-registration.mjs:147-153, and its fix(service-package): classify a publish driver fault as 5xx and stop returning driver text as caller data (#8131) #8277/[finding] ADR-0087's no-migration-prescription detector missed a FROM -> TO line again — an exemption the categories inherit can be held by silence, and #8299 closed the category gap, not this one #18745 commentary); the precedent for the identical required-input narrowing, .changeset/23455-key-admission-posture-required.md:13, used not-required (runtime-interface-only packages/core/src/security/resolve-authz-context.ts#ResolveAuthzInput). The gate passes at this head (Lint & Repo Gates success) and both arms are not-required, so nothing lands wrong — see Acceptance notes.
  4. Refusal semantics at every face: right.

    • REST: computeExecCtx's catch keeps the brand beside the request and resolves no context (rest-server.ts:3467-3475), so every door keeps its fail-closed answer; enforceAuth answers 403 { error: { code, message } } (:2457-2461) — the envelope the ADR-0069 gate refusal already uses at that door (:2443); an unknown key is ANONYMOUS_DENY_BODY 401; an unbranded throw goes to rethrowAuthzStoreUnavailable (503) or no context (401). Pinned (rest-api-key-lifecycle.pin.test.ts:113-143).
    • MCP stdio: per call the brand leaves resolvePrincipal as the thrown envelope (mcp/src/plugin.ts:689-697); at start it refuses to start naming the code (:525-532).
    • Datasource admin (admin-routes.ts:528-532), share links (sharing-plugin.ts:996-1000), install-local (marketplace-install-local-plugin.ts:3399-3403): re-raised by brand beside the outage re-raise; every other throw keeps today's arm.
    • Runtime dispatcher, MCP HTTP included: the identity-step catch re-raises by brand ahead of rethrowAuthzStoreUnavailable (http-dispatcher.ts:721); pinned on the wire through the mounted POST /api/v1/mcp (403 API_KEY_REVOKED in an ApiErrorSchema-valid envelope, 403 API_KEY_INACTIVE), the unbranded lookalike still 401, an unknown key 401, nothing inserted (runtime/src/api-key-lifecycle.pin.test.ts:185-329).
    • Settings: the resolver re-raises (settings-service-plugin.ts:373), the family door relays beside relayAuthzStoreOutage (settings-routes.ts:165-169, 252-253); pinned on every mounted route, unknown key 401.
    • Storage: buildFileReadAuthorizer re-raises (storage-service-plugin.ts:1476-1481); authorizeDownload's catch relays (storage-routes.ts:567-574); unknown key 401 AUTH_REQUIRED; nothing minted; pinned.
    • The dev's claim that refuseHeldSession is unreachable for the brand: right. Its reader buildAuthGateSessionReader calls resolveApiKeyAdmission directly (storage-service-plugin.ts:1237-1249), which returns the lifecycle outcome and can throw only AuthzStoreUnavailableError (api-key.ts:705-713, 829); the reader answers undefined for every outcome but held/none (:1248-1249); refuseHeldSession's catch relays the outage brand and rethrows anything else (storage-routes.ts:655-663); the upload routes read only getSession(headers) (buildAuthSessionResolver). The brand is first raised by resolveAuthzContext (resolve-authz-context.ts:498-501), which that reader never calls.
    • Nothing is minted on a refusal: the lifecycle return at api-key.ts:717 precedes the stamp at :838; the dispatcher and storage pins assert no insert / no capability.
  5. Write-side safety of the read path: right; two non-blocking notes.

    • The last_used_at stamp: ql.update('sys_api_key', { id, ...patch }, { context: { isSystem: true } }) (api-key.ts:429) is the repo's engine-dispatch shape (auth-manager.ts:8325 mfa_required_at, :8853 last_activity_at) and the new pins route it through assertEngineUpdateDispatch. The throttle is DB-held — a write only when the column is empty or ≥ API_KEY_LAST_USED_STAMP_INTERVAL_MS old (:404-408) — so it is idempotent and multi-node safe (a race costs one duplicate write of a near-identical timestamp, as the PR says). Failure is caught, never rethrown, said once per engine through a WeakSet latch at error with the consequence and the fix (:413-425); pinned (one call writes; throttle; held key stamped; said once; the dogfood real-engine write). A caller can only cause the write for the key it holds, with platform-chosen values.
    • The grace stamp: expires_at = landing + 365 d only when the row has no readable expires_at (:409-412); once written the branch never re-fires. Not reversible by a caller: expires_at is outside the data-API update whitelist and readonly (sys-api-key.object.ts:34-36, 263, 368-372), and the keys door is POST-only. Moving api_key_policy_started_at moves the date only for unstamped keys, as the changeset states. Reversal for a stamped key needs a system-context write (the MFA-grace precedent).
    • The landing anchor: written at kernel:bootstrapped only when the stored value's source is default (auth-plugin.ts:1695-1699), never overwritten; the write goes through setMany (in-process ctx = {} is permitted — assertPermitted guards enforced HTTP callers), which emits a change event (settings-service.ts:2215) → subscribe('auth') re-runs applySettings (auth-plugin.ts:2196-2198) → applyConfigPatch sets apiKeyPolicyStartedAt (auth-manager.ts:4362+, this.config = next). The manifest pattern matches toISOString() output. No settings service → warn once; a failed write → error with the remedy (:1687-1693, 1700-1708). Note: the check-then-set has no CAS, so two nodes booting on an empty store at the same instant can both write; the two values differ by the boot skew and every node converges on the stored one through subscribe — harmless.
  6. Signing door: right. No expires_at → now + defaultExpiryDays (keys.ts:224-226), echoed (:368); past now + maxExpiryDays (60 s skew forgiven) → 400 API_KEY_EXPIRY_TOO_LONG naming the latest date, nothing stored (:206-222); exactly one year accepted (runtime pin: +365 d accepted, +366 d refused; dogfood: 90-day default and the 400). The default never outlives the ceiling (auth-manager.ts:8241-8243, pinned). No auth service → pre-policy signing; a registered-and-broken auth service → AuthzStoreUnavailableError('api_key_policy') (:196-204, pinned). Update path: none exists — handleKeysRequest accepts only POST (keys.ts:111), and the data API strips expires_at on sys_api_key (sys-api-key.object.ts:368-372), so the ceiling bounds the only writer of expires_at a caller reaches.

  7. Generated files and docs: right. api-surface/contracts.json and export-origins/contracts.json each gain one line in sorted position in the shape gen:api-surface writes; the reference pages gain +327 → +331 and four sorted bullets in the shape gen:docs writes; check:generated rides the required TypeScript Type Check job, success at this head. The en.objects.generated.ts help text equals the source description byte-for-byte (sys-api-key.object.ts:276-277); the zh-CN / ja-JP / es-ES hand translations say the same three things (recorded on use, at most once an hour, refused after the inactivity window — 90 days by default — until replaced). The three service-settings translations are accurate, and their rendering of the NULL-use anchor ("from its creation, or from the policy start when it is older") matches the code's max(landing, created_at). scripts/engine-double-contract.pinned.json gains one entry in file-sorted position. content/docs/releases/** is untouched. The catalog count 52 → 56 is consistent: wireCodes = (StandardErrorCode ∪ codes the catalog page publishes) minus door-translated codes (scripts/check-error-status-conformance.mjs:2143-2176), and the page gains exactly the four headings; the pin error-catalog-docs.test.ts:122-126 sits in Test Core (1/6), success at this head.

  8. Size and scope: right. 2919 changed lines (+2741/−178, 81 files; the local three-dot diff against origin/main at merge-base 989aa483fd lists the same 81 paths as the PR), under 3000. Every file is inside claim 6173499602 (which incorporates 6173194763's list), the amendment's three relay files (http-dispatcher.ts identity catch; settings-routes.ts relay; storage-routes.ts download catch — the gate catch untouched for the reason judged in 4), "tests beside each changed file" (the three new pins), "the API-key docs pages" (connect-mcp.mdx, api/index.mdx, error-catalog.mdx), or a forced adjunct the dev names: the core barrel, the four *.objects.generated.ts, the three settings translations, the spec-generated api-surface / export-origins / reference pages, the pinned engine-double JSON, the test fixtures, the dogfood probes and the new dogfood test. packages/mcp/src/mcp-http-tools.ts and mcp-server-runtime.ts are untouched (the claim's ⛔). The rest-server.ts hunks (:20-23, :2392-2402, :2457-2461, :3289-3290, :3467-3475) are disjoint from the claim's serial-constraint regions for fix(rest,runtime)!: a /data PATCH or DELETE refuses a version token that can never carry a version, on both doors #23515 and feat(metadata-core,metadata-protocol)!: the sys_metadata family declares no organization column; indexes re-keyed (ADR-0131 D6/D7, C7b S2) #23469.

  9. Anything else making it unsafe to land: nothing blocking. No governed surface in the file list (Governed Surface Queue Guard success); head repo = base repo (not a fork); author objectstack-fleet[bot]; refusal messages carry the key's display prefix only, never the key or hash (api-key.ts:242-245), and keyId/userId reach only the operator log; the per-refusal warn is bounded by real keys. Check-runs at 3c2b4152f9: 41 success, 7 skipped (the full-battery / opt-in jobs: Console Pin Gate, Temporal Conformance, the dogfood matrix, Dogfood Verify CLI, Packed-tarball smoke, plus the duplicate-named skipped rows for Auto Label and Check PR Size), none failed, none in progress; all five required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard) are success. The PR is still a draft.

② Semver level

minor on all twelve bumped packages, BREAKING-declared (Clause-②: yes (narrowing), the BREAKING banner, a ! title), shipped as minor under the launch-window convention for breaking changes — consistent with .changeset/23287-api-key-account-posture.md and .changeset/23455-key-admission-posture-required.md on origin/main. The widening half (contract member, codes, exports, settings keys) needs at least minor; the narrowing half (the key doors refuse what they served, a required seam input) is breaking and correctly declared. Nothing is a patch; nothing is unbumped.

③ Verdict

PASS

Acceptance notes (non-blocking):

  • ADR-0087 sub-arm. The marker not-required (no-migration-prescription) holds only because the prescription detector did not read the changeset's host-rewrite bullet; the precedent for the identical required-input narrowing (.changeset/23455-key-admission-posture-required.md:13) used not-required (runtime-interface-only packages/core/src/security/resolve-authz-context.ts#ResolveAuthzInput), a positive re-checkable finding. Recommend swapping to that arm before or at the next touch; both are not-required, the gate passes either way, and no registry entry or release-notes row changes.
  • Keys-page reminder for unstamped keys. A pre-policy key not admitted after the landing shows no expires_at until its first use; it is nevertheless refused with a dated 403 (API_KEY_INACTIVE at 90 days, or API_KEY_EXPIRED at the grace end with inactivity off). The keys page could compute the pending grace date for unstamped rows — objectui's consumer card, per claim 6173194763.
  • Landing-anchor race. recordApiKeyPolicyStart is check-then-set with no compare-and-swap; two nodes booting on an empty store at the same instant may both write, differing by the boot skew, and all nodes converge on the stored value. Harmless; a conditional write would close it if the settings service ever offers one.
  • Direct-insert rows. A sys_api_key row created with no expires_at by a path other than POST /keys after the landing (no shipped mint path does this) receives the legacy grace end (landing + 365 d), which can be earlier than its own creation + default, and once the landing is more than a year old is refused API_KEY_EXPIRED on first use. Design-consistent (the grace is a one-time transition); worth a line in the docs if such inserts are ever supported.
  • Stale operator text. warnApiKeyLifecycleRefusal (resolve-authz-context.ts:288-293) still says "any other face answered its generic 401"; after the amendment every shipped face relays. Cosmetic.
  • Storage download performs the key admission twice (the gate reader via resolveApiKeyAdmission, then the authorizer via resolveAuthzContext); the throttle keeps it to one stamp. Pre-existing double-read shape, unchanged by this PR.
  • packages/objectql/src/internal-fields.test.ts still describes the verifier lookup as { key, revoked: false } (the dev's noted, unfiled out-of-scope finding). The test stays true; the description is stale by this PR's hash-only lookup.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

domain:spec seat 4 (#18917) · sunhaolin · session session_0121ooBYRmS8ziFXm9ai5YHm · claim 6173499602 (amendment 6178205488) on #23367 · reviewed head 3c2b4152f9693935ac039d2daeb3be8c83be74de · 2026-10-14T22:44Z

Verdict: ACCEPT.

  • Not governed (check-governed-merges --pr 23541: 0 of 81 paths).
  • 2919 changed lines (+2741 / −178), under the 3000-line human-merge threshold.
  • The CONTRACT_REVIEW_TIER review 6180154638 is a PASS, nothing blocking. It covers the published contract (the IAuthService member, four ledger codes, the core exports, four settings keys) and the narrowed key doors.
  • This record is the TIER_DEFAULT review the Clause-②: yes declaration owes.
  1. Shape.
    • Draft PR. The first line is Fixes #23367, and Clause-②: yes sits at line start.
    • The changeset declares yes (narrowing) with the BREAKING banner, shipped as minor on twelve packages under the launch-window convention. Its ADR-0087 disposition is not-required.
  2. Ruling 6159880505 letter A, items 1–4, against the diff (review ①1).
    • The store stays sys_api_key.
    • Defaults are 90 / 365 / 90, with a 1-year grace from a landing recorded once.
    • A longer expires_at is refused at the signing door with 400 API_KEY_EXPIRY_TOO_LONG naming the latest date.
    • Inactivity reads the PRE-stamp last_used_at and refuses without revoking.
    • The codes API_KEY_EXPIRED, API_KEY_INACTIVE and API_KEY_REVOKED are distinct and located.
    • Rotation is documented.
    • OS_CLOUD_API_KEY is untouched.
    • The keys-page reminder copy is objectui's consumer half, as the claim said.
  3. Seat decisions 1–7 and the two accepted deviations (review ①2).
    • The policy is a REQUIRED input beside userAuthGates at all ten builders.
    • The last_used_at stamp is throttled.
    • A NULL last_used_at counts from max(landing, created_at), pinned both ways.
    • The grace is the constant API_KEY_LEGACY_GRACE_DAYS = 365.
  4. Every face (review ①4). REST, MCP stdio, datasource, share links, install-local, the runtime dispatcher (MCP HTTP included), settings and storage each answer 403 with the code. An unknown key keeps the generic 401, an unbranded throw keeps today's answer, and nothing is minted on a refusal.
    • The amendment named the storage gate catch, and the dev did not edit it. The reviewer confirmed the brand cannot reach that catch.
  5. Gates and evidence.
    • Head check-runs: 41 success, 7 skipped, 0 failures. mergeable_state is clean.
    • The CI red at d18cfe5488 was the error catalog's count (52 → 56). The fix is in the page; no test was touched.
    • Local gates: 128 derived, 128 run, 0 NOT MEASURED.
    • Ablations: the inactivity check, the stamp and the dispatcher relay each go red on removal, and each restore is proven.
  6. Acceptance notes, all non-blocking and each with carrier: none unless stated:
    • The ADR-0087 sub-arm would be more exact as runtime-interface-only packages/core/src/security/resolve-authz-context.ts#ResolveAuthzInput, the precedent .changeset/23455-* uses. Both are not-required, and the gate passes.
    • The landing anchor is check-then-set. Two nodes booting on an empty store converge on the stored value, so this is harmless.
    • A direct-insert sys_api_key row with no expires_at would take the legacy grace end. No shipped mint path does this.
    • The operator text in warnApiKeyLifecycleRefusal still mentions a "generic 401" face.
    • The internal-fields.test.ts description is stale.
    • Keys-page copy for unstamped keys: carrier: objectui's keys-page consumer card.
  7. Landing clauses.
    • (a) Every check is green or skipped by design: met at this record; the skip roster is read at landing.
    • (b) mergeable_state is clean at enqueue: met.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 14, 2026 22:46
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 14, 2026 22:46
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit 1d79556 Oct 14, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23367-api-key-lifecycle branch October 14, 2026 23:15
marchtian pushed a commit that referenced this pull request Oct 15, 2026
…ude/issue-23429-metadata-family

No textual conflict on this merge. The generated reference docs are
regenerated over the merged ledger in the next commit, never picked from
a side.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
marchtian pushed a commit that referenced this pull request Oct 15, 2026
)

`gen:schema && gen:docs` on the merged tree: `… +333 more` (main's four
API_KEY_* codes from #23541, plus ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED
and LEGACY_ORGANIZATION_ROWS_UNREADABLE). check:docs green; the i18n
bundles are in sync (check-i18n-bundles OK, nothing to regenerate).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
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

2 participants