Repository navigation
[Decision] API key 的生命周期与存放:到期上限与不活动失效取多长,key 继续放自有 sys_api_key 还是迁到 @better-auth/api-key(#23287 裁决 C′ 的留项) #23367
Description
Ruled: 6159880505 · letter A · 2026-10-14T03:03Z
Filing gate: ③ a question left by a ruling. Ruling C′ on #23287 (6159450584, maintainer 「同意」) names two items outside that card and asks for one decision card: the API key's lifecycle policy, and where the key store lives. Filed by the director seat (summon #37, session_01LqNgdPZB2Jnr5HpGbaqLeB) under 「立卡你决定,不需要我同意」. ⛔ The seat does not rule this. Reader: the maintainer.
一句话问题
个人 API key 今天签了就永久有效:签发口只收 name 和可选的 expires_at,不填就没有到期;不用的 key 也不会失效,只有手动吊销一条路。要定两件事:key 要不要有到期上限和不活动失效、数字取多长;以及 key 继续放平台自有的 sys_api_key,还是迁到 better-auth 官方的 @better-auth/api-key 包。
背景(本席实测,origin/main 6d96721fa;官方包读 @better-auth/api-key@1.7.3 源码)
- 平台今天:
sys_api_key(packages/platform-objects/src/identity/sys-api-key.object.ts)有 owner、active organization、scopes、expires_at(可选)、last_used_at(每次调用更新)、revoked、哈希。签发口 runtime/keys(packages/runtime/src/domains/keys.ts:94–101:正文只允许name和可选expires_at)。验证口 coreresolveApiKeyAdmission(packages/core/src/security/api-key.ts:271,REST 与裸挂面共用)按哈希查行、拒绝已吊销与已到期。 - 官方包:
@better-auth/api-key是独立包(核心包 1.7.3 不含它)。key 有keyExpiration { defaultExpiresIn, minExpiresIn, maxExpiresIn }、rateLimit、remaining与refill、enabled、permissions、metadata。开enableSessionForAPIKeys后,带 key 的请求由一个前置钩子验证 key 自身生命周期、取出 owner、拼出「owner 的会话替身」并在/get-session上直接返回;better-auth 的调度器对前置钩子的返回值立即返回(better-auth@1.7.3dist/api/dispatch.mjs:218–223),后置钩子不跑,平台挂在customSession上的authGate因此不会盖到 key 请求上。references: 'organization'的 key 不能当会话用(钩子里INVALID_REFERENCE_ID_FROM_API_KEY)。官方包的哈希与前缀方案是它自己的,平台现有 key 只存哈希、原文不可得,迁表意味着所有集成重新签 key。 - 主流: GitHub 细粒度 PAT 必须设到期(上限一年),经典 PAT 一年不用自动失效;Okta API 令牌 30 天不活动失效;AWS 访问密钥不到期,但有密钥年龄报告和轮换策略;Stripe key 不到期,支持滚动。
Governing text
- ADR-0036 Phase 1a:"verify the key (hash match, not revoked, not expired), resolve it to its principal"。
- auth-gate parity: a request carrying an API key beside a session cookie held by ADR-0069 is refused at REST but admitted by the five raw-host route families — one cross-seam answer is owed (session versus user) #23287 裁决 C′(6159450584)第 6 条:生命周期与存放另立一张决策卡。
packages/core/src/security/api-key.tsdocblock:"better-auth 1.6.x ships no apiKey plugin, so ObjectStack owns the full lifecycle"(对核心包成立,官方插件在独立包)。- ADR-0093 第 378 行:机器/服务账号「Out of scope now」。
协议声明,以及是否改协议
- A 不改 spec 形状:auth 配置加两个键(到期上限、不活动天数),签发口给默认到期,验证口多读一个
last_used_at,存量无到期的 key 给一个带日期的宽限。 - B 改存放:表从
sys_api_key换到官方apikey,scopes 映射到permissions、active organization 进metadata,验证口改读新表;是迁移,不是协议改动。 - C 不改。
前提(各附 re-check 命令)
- 签发口不强制到期:
git show origin/main:packages/runtime/src/domains/keys.ts | sed -n '94,101p'。 expires_at与last_used_at都是可选字段:git show origin/main:packages/platform-objects/src/identity/sys-api-key.object.ts | grep -n -E "expires_at|last_used_at|required"。- 官方包的会话替身短路后置钩子:
npm pack @better-auth/api-key@1.7.3 && tar xzf better-auth-api-key-1.7.3.tgz && grep -n '/get-session' package/dist/index.mjs,再看better-auth@1.7.3的dist/api/dispatch.mjs:218–223。
具体问题
永久有效的个人 key 是泄露后的长期旁路(#23287 裁决 C′ 已让账号状态扣 key,但一把没人记得的 key 仍然永远有效)。要补的是 key 自己的生命周期;而官方包虽然带这些字段,它的会话路径绕过平台的闸门,且迁表要让所有集成重签。
选项 × 真实代价 × 工作量
| 做什么 | 客户可感知后果 | 工作量 | |
|---|---|---|---|
| A 自有存放,加生命周期策略 | 保留 sys_api_key;auth 配置加到期上限与不活动天数;签发口给默认到期(席位建议默认 90 天、上限 1 年);验证口按 last_used_at 拒绝超过不活动期(席位建议 90 天)的 key,拒绝码定位到原因;存量无到期的 key 自策略落地起给一年宽限并在到期前提醒 |
集成 key 会到期、长期不用会失效,需要轮换;响亮拒绝带指引;现有 key 一年内不受影响 | spec 车道 · 1 个 PR · 先例:验证口家族 PR #23200(+1305/−71);策略本身无先例,量小 |
| B 迁到官方包 | 以 @better-auth/api-key 为存放,用它的到期与限流字段,平台验证口改读新表(不用它的会话替身) |
所有客户集成重新签 key(哈希不可迁移);换来官方维护的限流与余量字段 | spec 车道 · 多 PR 迁移 + 客户通知 · 无先例 |
| C 维持现状 | 不加策略 | 永久 key 继续存在 | 零 |
业务含义直译: A 像「门禁卡设有效期,长期不刷自动停用,旧卡给一年换卡期」;B 像「换一家门禁供应商,所有人重新办卡」;C 像「卡永远有效」。
四轴(业务立场)
- ① 项目长远合理性: 主流对个人令牌都设寿命(GitHub 上限一年、经典 PAT 一年不用失效,Okta 30 天不活动)。A 把这条写进平台自己的口;B 换存放换来的字段 A 也能有,而官方会话路径与平台闸门不兼容(裁决 C′ 的判定口必须是平台的);C 留着永久旁路。
- ② 实际业务拉动: 今天零事故;但 cloud 的 MCP 与 CI 文档教的是个人 key,永久 key 的数量随客户增长。B 的代价由每个客户承担。
- ③ 防 AI 犯错: A 的拒绝定位到原因(到期 / 不活动),AI 作者看到的是「签新 key」的处方;C 下泄露的 key 无人发现;B 的迁移期两套表并存最易错。
- ④ 创业阶段不扩散: A 加两个配置键;B 加一个依赖和一次全客户迁移;C 零。
推荐 A,回退 C
- 只看①选 A:两年后的平台样子是「个人 key 有寿命,集成走服务主体」(后者归 ADR-0093 的服务账号卡)。②③④ 是否翻转:否。
- 要您定的数字:默认到期(席位建议 90 天)、上限(建议 1 年)、不活动失效(建议 90 天)、存量宽限(建议 1 年)。
- 置信缺口:cloud 平台自己的服务 key(
OS_CLOUD_API_KEY)是否走sys_api_key未测,A 的 PR 先读它;客户集成里有多少永久 key 看不到。
裁后执行
- 裁 A: 本席把数字写进派发令,席位 2(
domain:spec#2)认领,一个 PR(配置键、签发默认、验证口不活动判定、存量宽限与提醒、文档),契约复审后入队;cloud 席位收一条指针(文档与提醒文案)。 - 裁 B: 另起迁移专题,先出客户通知方案再派。
- 裁 C: 关卡,记录「永久 key 是已知旁路」。
os-decision-facets
- ① 项目长远合理性:A 给个人 key 寿命,与 GitHub/Okta 同向,判定口仍是平台的;B 换存放但官方会话路径绕过闸门;C 留永久旁路。
- ② 实际业务拉动:今天零事故,永久 key 随 cloud 的 MCP/CI 接入增长;B 的代价落在每个客户。
- ③ 防 AI 犯错:A 拒绝定位到原因并给处方;C 泄露无人发现;B 迁移期两套表最易错。
- ④ 创业阶段不扩散:A 两个配置键;B 一个依赖加全客户迁移;C 零。
Prior rulings read: api key, expiry, inactivity, rotation, sys_api_key → ADR-0036 Phase 1a; ADR-0069 D3 and auth-gate parity: a request carrying an API key beside a session cookie held by ADR-0069 is refused at REST but admitted by the five raw-host route families — one cross-seam answer is owed (session versus user) #23287 ruling C′ (6159450584); ADR-0093:378; thread: none
Related: #23287 · ADR-0036 · ADR-0069 · ADR-0093 · @better-auth/api-key@1.7.3.
Activity
wip(plugin-auth,service-settings): API-key lifecycle settings, policy…
wip(faces): every key-admitting face passes the lifecycle source and …
wip(runtime): signing door applies the lifecycle policy; dispatcher f…
wip: storage auth type declares the policy member; plugin-auth test d…
wip: keys policy read after the data-service check; fixtures read the…
wip(rest): lifecycle relay pin (#23367)
wip(dogfood): key probes read the distinct revoked code (#23367)
wip(docs,platform-objects): API-key lifecycle docs, codes in the cata…
wip: changeset for the API-key lifecycle (#23367)
chore(spec): regenerate api-surface, export-origins and reference doc…
fix(tests,core,runtime): refusal pairs declare code and status; doubl…
fix(tests): lifecycle doubles refuse operators they do not implement (#…
test(dogfood): the API-key lifecycle end to end on the showcase stack (…
fix(core): lifecycle refusals stamp their code through the declared w…
objectstack-fleet commentedon Oct 14, 2026
os-dev-report
{ "issue": 23367, "status": "done", "branch": "claude/issue-23367-api-key-lifecycle", "pr": "https://github.com/objectstack-ai/objectstack/pull/23541", "session": "session_0121ooBYRmS8ziFXm9ai5YHm", "premise_still_valid": true, "summary": "Draft PR #23541 (head d18cfe5488, Part of #23367, assignee sunhaolin) executes ruling 6159880505 letter A on claim 6173499602, as option A in one PR. (1) Policy: four auth settings keys (api_key_default_expiry_days 90, api_key_max_expiry_days 365, api_key_inactivity_days 90, api_key_policy_started_at). bindAuthSettings binds them into AuthManager, which answers the new IAuthService.resolveApiKeyLifecyclePolicy (spec contract, ApiKeyLifecyclePolicy). The landing is recorded once in auth settings at kernel:bootstrapped (a kernel:ready write is refused SETTINGS_ENGINE_NOT_BOUND) and never overwritten. (2) Signing (keys.ts) reads the same source: no expires_at gets now+90d, past the ceiling is refused 400 API_KEY_EXPIRY_TOO_LONG naming the latest date, exactly 1 year is accepted. (3) Seam (resolveApiKeyAdmission): hash-only lookup and a new lifecycle outcome with three codes, API_KEY_REVOKED, API_KEY_EXPIRED and API_KEY_INACTIVE; resolveAuthzContext raises them as the branded ApiKeyLifecycleRefusalError (403). apiKeyLifecycle is a REQUIRED input beside userAuthGates at all 10 call sites. A throttled last_used_at stamp (one write per key per hour, the system context, failure said once per engine at error, never failing the request) fixes the dev finding that the field had no writer. The inactivity verdict reads the pre-stamp row, and NULL last_used_at counts from the landing (or a later signing). A no-expiry key gets expires_at = landing + 365d stamped on first admission. (4) Delivery: REST, MCP stdio, datasource, share links and install-local answer 403 with the code. The runtime dispatcher (MCP HTTP included), settings and storage fail CLOSED to their generic 401: their relay lives in http-dispatcher.ts, settings-routes.ts and storage-routes.ts, outside the claim, hence Part of (see open_questions). (5) OS_CLOUD_API_KEY: not a sys_api_key row in this repo, untouched; cloud's own verification NOT MEASURED. Size: 76 files, +2205/-172 = 2377 changed lines, under 3000.", "tests": "At d18cfe5488 (merged origin/main 989aa483fd): core full 105 files / 2544 passed, including the new api-key-lifecycle.test.ts (20). plugin-auth full 140 / 2819. mcp 37 / 420, service-settings 46 / 826, service-datasource 45 / 838, plugin-sharing 46 / 1061, service-storage 56 / 987, cloud-connection 46 / 555 (full suites, run after the face changes; the merge did not touch them). runtime: 49 key/resolver test files, 1129 passed / 18 skipped, including api-key-lifecycle.pin.test.ts (signing door plus the dispatcher fail-closed pin on POST /keys). rest: 30 key/resolver/enforceAuth files, 474 passed, including rest-api-key-lifecycle.pin.test.ts. dogfood: 6 existing key tests plus the new api-key-lifecycle.dogfood.test.ts, 61 passed; the showcase stack proves the landing recorded, the 90-day default, the 400 code, the real-engine last_used_at write and the grace stamp. cli: the touched integration file, --project integration, 9 passed. Ablations via scripts/ablation-replace.mjs at committed HEAD 204fb33318; the core suite imports ./api-key.js from source, so no build is needed. Inactivity check: anchor x1 to x0, blob 2c7db74f5e18 to e3f45c2db200, 5 failed / 15 passed. Stamp (ql.update line replaced by void patch): blob to 48112ae9cdca, 5 failed / 15 passed. Both restored with blob == HEAD 2c7db74f5e18 and git diff HEAD empty. The final commit d18cfe5488 changed only the code-stamp spelling (wire pairs), and the core suite is green there.", "gates": "Local, at d18cfe5488: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 128 families, all 128 run with exit codes captured before any pipe, all exit 0. --ran: 128 derived, 128 run, 0 NOT-MEASURED, 0 UNRUN (exit 0). Red on earlier heads and fixed: check:rest-log-spy-declared, check:engine-double-contract (pinned ledger --write), check:error-status-conformance, check:objectql-double-limit, check:where-matcher, check:dispatcher-error-vocabulary. Exit 3 before a build, then exit 0: check:skill-examples, check:dual-build-cjs-loads. Local-only: check:dev-prereqs exit 0, check-nul-bytes exit 0. spec check:generated --fix regenerated api-surface, export-origins and the reference docs, then re-checked all three green. Typecheck exit 0 for spec, core, runtime, rest, mcp, plugin-auth, plugin-sharing, service-settings, service-storage, service-datasource, cloud-connection, plugin-security, cli and dogfood. CI on PR #23541 at report time: every check-run in_progress (Lint · repo & tooling gates, Type Check · workspace, Check Changeset, Part-of PR must not also close its card, and others); Packed-tarball smoke skipped. Not waited on.", "mcp_calls": "0", "api_writes": "3 relay dispatches as objectstack-fleet[bot], all read back byte-identical. This round: (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls (draft) with its assignees leg POST /issues/23541/assignees (sunhaolin), relay run 38634697171; (2) this report, POST /repos/objectstack-ai/objectstack/issues/23367/comments. Earlier round: (3) the blocked os-dev-report, comment 6173450176. git push is not a REST write. No label write: the dispatch budget names none.", "files_changed": [".changeset/23367-api-key-lifecycle.md", "content/docs/ai/connect-mcp.mdx", "content/docs/api/error-catalog.mdx", "content/docs/api/index.mdx", "content/docs/references/api/contract.mdx", "content/docs/references/api/error-code-ledger.mdx", "packages/cli/test/security-catalog-echo-env-kernel.integration.test.ts", "packages/cloud-connection/src/marketplace-install-local-plugin.ts", "packages/core/src/security/admin-standing-surface.test.ts", "packages/core/src/security/api-key-account-posture.test.ts", "packages/core/src/security/api-key-lifecycle.test.ts", "packages/core/src/security/api-key.test.ts", "packages/core/src/security/api-key.ts", "packages/core/src/security/authz-store-unavailable.test.ts", "packages/core/src/security/index.ts", "packages/core/src/security/resolve-authz-context.guest-grants.test.ts", "packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts", "packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts", "packages/core/src/security/resolve-authz-context.test.ts", "packages/core/src/security/resolve-authz-context.ts", "packages/mcp/src/plugin.ts", "packages/platform-objects/src/apps/translations/en.objects.generated.ts", "packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts", "packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts", "packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts", "packages/platform-objects/src/identity/sys-api-key.object.ts", "packages/plugins/plugin-auth/src/auth-manager.ts", "packages/plugins/plugin-auth/src/auth-plugin.test.ts", "packages/plugins/plugin-auth/src/auth-plugin.ts", "packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts", "packages/plugins/plugin-security/src/explain-enforce-parity.test.ts", "packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts", "packages/plugins/plugin-sharing/src/exec-context-seam.testkit.ts", "packages/plugins/plugin-sharing/src/sharing-plugin.ts", "packages/qa/dogfood/test/api-key-hash-not-serialized.dogfood.test.ts", "packages/qa/dogfood/test/api-key-lifecycle.dogfood.test.ts", "packages/qa/dogfood/test/api-key-owner-revoke.dogfood.test.ts", "packages/qa/dogfood/test/api-key-revoke-lifecycle.dogfood.test.ts", "packages/qa/dogfood/test/armed.ts", "packages/qa/dogfood/test/(12 dogfood tests, mechanical apiKeyLifecycle: undefined)", "packages/rest/src/execctx-authz-input-seam-reachability.test.ts", "packages/rest/src/external-datasource-routes-auth-guard.test.ts", "packages/rest/src/meta-type-read-capability.test.ts", "packages/rest/src/meta-type-write-capability.test.ts", "packages/rest/src/remote-tables-twin.equivalence.test.ts", "packages/rest/src/rest-api-key-lifecycle.pin.test.ts", "packages/rest/src/rest-server.ts", "packages/runtime/src/api-key-lifecycle.pin.test.ts", "packages/runtime/src/domains/keys.ts", "packages/runtime/src/http-dispatcher.keys.test.ts", "packages/runtime/src/http-dispatcher.mcp-oauth.test.ts", "packages/runtime/src/security/resolve-execution-context.test.ts", "packages/runtime/src/security/resolve-execution-context.ts", "packages/services/service-datasource/src/admin-routes.ts", "packages/services/service-settings/src/manifests/auth.manifest.ts", "packages/services/service-settings/src/settings-service-plugin.ts", "packages/services/service-settings/src/translations/es-ES.ts", "packages/services/service-settings/src/translations/ja-JP.ts", "packages/services/service-settings/src/translations/zh-CN.ts", "packages/services/service-storage/src/storage-service-plugin.ts", "packages/spec/api-surface/contracts.json", "packages/spec/export-origins/contracts.json", "packages/spec/src/api/error-code-ledger.zod.ts", "packages/spec/src/contracts/auth-service.ts", "scripts/engine-double-contract.pinned.json"], "deviations": [ "Part of #23367, not Fixes. Ruling item 3's distinct codes reach five faces. The runtime dispatcher (MCP HTTP included), settings and storage answer the generic 401, fail-closed and never an admission. Their relay lives in http-dispatcher.ts (identity-step catch, near :700), settings-routes.ts (relayAuthzStoreOutage) and storage-routes.ts (download and gate catches), none in claim 6173499602's list (resolve-execution-context.ts holds no refusal answer). Stopped at that boundary; see open_questions.", "Adjuncts written outside the listed paths, each forced by a listed edit: core/security/index.ts (the barrel of api-key.ts); the platform-objects *.objects.generated.ts bundles (regenerated from the sys-api-key description; zh-CN, ja-JP and es-ES help text translated by hand); the service-settings translations zh-CN, ja-JP and es-ES (the coverage test demands labels for new manifest keys); the spec-generated api-surface, export-origins and references docs; scripts/engine-double-contract.pinned.json (the gate-prescribed --write for a new pinned double); four test fixtures that pinned the old revoked:false lookup or the old revoked/expired fall-through; and the dogfood key probes plus one new dogfood test. rest-server.ts edits sit in the enforceAuth region, the resolveAuthzContext call and the catch of that same try, plus a three-name addition to the top import list (disjoint from #23469's and #23515's hunks).", "NULL last_used_at counts from max(landing, created_at), not from the landing alone. The seat said never created_at; that holds for every pre-policy key, where the landing wins. A key signed after the landing counting from the landing would be refused on its very first call once the landing is 90 days old. Pinned both ways.", "The legacy grace is a constant (API_KEY_LEGACY_GRACE_DAYS = 365, the ruling's number), not a fifth setting. It is stamped onto each key, so a setting could not move a stamped date. Moving api_key_policy_started_at moves it for unstamped keys.", "The changeset declares Clause-②: yes (narrowing), with the ADR-0087 not-required (no-migration-prescription) marker; check-adr-0087-registration passes and reads the arm there. The PR body carries the claim's line verbatim: Clause-②: yes.", "Lifecycle refusals answer 403 (the ruling's own wording, and the held-key precedent), not 401. Unknown keys keep the generic 401.", "The runtime and REST suites were narrowed to the 49 and 30 key/resolver test files: the full runtime project exceeded the 10-minute foreground limit (exit 124). The narrowing is a git grep over the packages' own tests and is blind to tests that reach these paths without naming them; CI runs both in full.", "Ablations ran at 204fb33318. The final head d18cfe5488 adds only the code-stamp spelling change in api-key.ts (codes now stamped through declared wire pairs for check:dispatcher-error-vocabulary); the core suite re-ran green at d18cfe5488.", "The verify lock was held by issue-23180's measure scripts for two waits (exit 99, about 9 minutes each). Lock-free gates ran in the interval, and both rebuilds completed on re-acquire." ], "open_questions": [ { "question": "For the PM: how to close ruling item 3 on the three faces whose relay is outside claim 6173499602: the runtime dispatcher (MCP HTTP included), settings and storage.", "options": [ "A: widen the claim by three files on this PR (a patch round before the contract review). In http-dispatcher.ts's identity catch, re-raise isApiKeyLifecycleRefusalError(err) ahead of rethrowAuthzStoreUnavailable; dispatcher-plugin's errorResponseBase already renders the declared status and the registered code. In settings-routes.ts and storage-routes.ts, relay the brand beside the store outage and re-raise it from the two plugin resolvers. Add one 403-with-code pin per face; the dispatcher pin flips from 401 to 403. Cost: about 3 source lines plus 3 pins; the PR then becomes Fixes #23367 and stays under 3000 lines.", "B: land #23541 as is (Part of) and file a sub-issue of #23367 for the three relays. Cost: a second claim, review and changeset for about 3 lines, and until then MCP HTTP callers (the key-heaviest face) see a generic 401 with the code only in the server log." ], "four_axes": { "actual_business_need": "Measured: cloud's MCP and CI docs teach personal keys over MCP HTTP, which is the runtime dispatcher face, the one that answers generic 401 today. The ruling's located code matters most there. Settings and storage key traffic is rare.", "long_term_soundness": "A finishes one design in one place: one brand relayed at every door. B leaves three doors one release behind the brand they already fail closed on, the drift the store-outage relay was built to avoid.", "ai_error_prevention": "An AI client branching on API_KEY_INACTIVE versus API_KEY_REVOKED sees it on REST and not on MCP HTTP under B, so an agent sees two dialects for one key state. A gives one answer.", "startup_focus": "A is three lines and three pins on a PR already in review. B adds a claim, a review and a changeset for the same three lines. No new gate either way." }, "recommendation": "A, because the dispatcher face carries the MCP HTTP endpoint the ruling's codes are most for, and the change is three relay lines on a PR already open; B only if a file collision forbids widening (none of the three appears in the claim's serial-constraint list)." } ], "out_of_scope_findings": [ "carrier: #23367 (this card, Part of) · the three-face relay above · noted, not filed", "carrier: none · noted, not filed: packages/objectql/src/internal-fields.test.ts still describes the verifier lookup as { key, revoked: false }. The test stays true (the query works); only its description of the seam's query is stale. In PR #23541 Acceptance notes." ] }Generated by Claude Code
objectstack-fleet commentedon Oct 14, 2026
Claim amendment to
6173499602·domain:specseat 4 ·session_0121ooBYRmS8ziFXm9ai5YHm· 2026-10-14T20:51Z · Thread-read: 6178148974The dev's open question in report
6178148974asks how ruling item 3 reaches the three key doors whose relay sits outside this claim. The seat answers A: widen this claim on PR #23541, and do not file a sub-issue.6159880505item 3 reads "The refusal codes are distinct and located". Today the runtime dispatcher answers MCP HTTP's lifecycle refusals with the generic 401, and MCP HTTP is the face cloud's docs teach keys on. The fix is a relay of an existing brand, about 3 source lines and one pin per face, on a PR already open. This is ordering and file surface, which is the seat's call.packages/runtime/src/http-dispatcher.ts: the identity-step catch only;packages/services/service-settings/src/settings-routes.ts: the store-outage relay only;packages/services/service-storage/src/storage-routes.ts: the download and gate catches only;pm:dispatchedcard's newestClaim:was read, and none names them.Fixes #23367, and the size stays under 3000 changed lines.Review owed before the queue, unchanged: one
CONTRACT_REVIEW_TIERreview from an isolated subagent. It covers the newIAuthServicemember and codes, which are a published contract, and the narrowed key doors. The seat's own record covers theClause-②: yesdeclaration.docs(api): the error catalog advertises 56 wire codes, counting the f…
objectstack-fleet commentedon Oct 14, 2026
os-dev-report
{ "issue": 23367, "status": "done", "branch": "claude/issue-23367-api-key-lifecycle", "pr": "https://github.com/objectstack-ai/objectstack/pull/23541", "session": "session_0121ooBYRmS8ziFXm9ai5YHm", "premise_still_valid": true, "summary": "Patch round on claim amendment 6178205488, plus the CI red at d18cfe5488. PR #23541 is now Fixes #23367 (head 3c2b4152f9, draft, assignee sunhaolin, body line 2 Clause-②: yes). (1) Relays, each matched by the brand isApiKeyLifecycleRefusalError, with every other throw keeping today's answer. In http-dispatcher.ts the identity-step catch re-raises the brand ahead of rethrowAuthzStoreUnavailable, and dispatcher-plugin's errorResponseBase renders its 403 and code; this covers MCP HTTP (/api/v1/mcp) and the declarative-endpoint fallback. In settings, relayApiKeyLifecycleRefusal sits beside relayAuthzStoreOutage in the family door's catch, and the plugin resolver re-raises the brand instead of returning { enforced: true }. In storage, the download gate's catch relays the brand beside the outage, and buildFileReadAuthorizer re-raises it instead of returning 'unauthenticated'. Unknown keys keep the generic 401. (2) The storage gate catch (refuseHeldSession) is not edited, because no brand can reach it: its reader gets a lifecycle outcome from resolveApiKeyAdmission, which never throws, and answers it with no posture. A download then reaches the authorizer, which relays; the upload routes read only the session cookie and never admit a key. (3) CI red: Test Core (1/6), packages/spec test:repo. Reproduced as error-catalog-docs.test.ts › 'the advertised code count matches the wire face', expected 52 to be 56. The catalog page claimed 52 wire codes, and the four ledger codes moved deriveWireFace() to 56. Fixed in 469acd6a41 (the page says 56); no test touched. (4) The changeset, error-catalog.mdx, connect-mcp.mdx and the PR body list every face, and the Part-of text is gone. Size: 81 files, +2741/-178 = 2919 changed lines, under 3000. Both accepted deviations stay pinned.", "tests": "CI red reproduced and fixed. At d18cfe5488, `pnpm --filter @objectstack/spec run test:repo` (lock, exit 1) gave 1 failed / 1032 passed across 61 files. The failing test was src/api/error-catalog-docs.test.ts › 'error-catalog.mdx ↔ the published wire face › the advertised code count matches the wire face', AssertionError expected 52 to be 56. Cause: content/docs/api/error-catalog.mdx states **52 error codes reachable on the wire**, and that number is pinned to deriveWireFace(REPO_ROOT).wireCodes.length. This PR's four registered codes (API_KEY_EXPIRED, API_KEY_EXPIRY_TOO_LONG, API_KEY_INACTIVE, API_KEY_REVOKED) made it 56. The PR's earlier local runs used spec's local project and never the repo project, which is why the red was not seen. After-run at 3c2b4152f9: the single file passed 5/5, and the full `test:repo` exited 0 with 61 files / 1033 tests passed (VERDICT command-exit 0, 14m09s under the lock). CI Test Core (1/6) at 3c2b4152f9 is success. Relay pins: runtime api-key-lifecycle.pin.test.ts passed 14/14. It covers the signing door (6); dispatch() raising 403 API_KEY_REVOKED and 403 API_KEY_EXPIRED with a cookie beside the key, nothing minted; an unbranded throw at the identity step spelling code API_KEY_REVOKED with status 403, which still answers 401 UNAUTHENTICATED (the generic-401 pin); 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 an ApiErrorSchema-valid envelope and 403 API_KEY_INACTIVE. An unknown key on /mcp is not refused as a key (the fixture's MCP door answers 501), and POST /api/v1/keys answers it 401 UNAUTHENTICATED. settings-routes.api-key-lifecycle.pin.test.ts passed 5/5: revoked gives 403 API_KEY_REVOKED on every mounted route, expired gives 403 API_KEY_EXPIRED, inactive gives 403 API_KEY_INACTIVE, an unknown key gives 401 UNAUTHENTICATED on every route, and a live control gives 200. storage-routes.api-key-lifecycle.pin.test.ts passed 4/4: revoked and expired give 403 with the code and nothing minted, an unknown key gives 401 AUTH_REQUIRED with nothing minted, and a live key gets 200 with one mint. Ablation (scripts/ablation-replace.mjs, committed HEAD e50fb94b57; the pin imports ./http-dispatcher.js and ./dispatcher-plugin.js from source, so no build was needed): 'if (isApiKeyLifecycleRefusalError(err)) throw err;' was replaced by 'void 0;'. Anchor x1 to x0, blob 22d1fe62b338 to e0459ffb1954, giving 4 failed / 10 passed: both dispatch() codes and both MCP wire codes went red, and the unbranded-throw, unknown-key and live controls stayed green. Restored with blob == HEAD 22d1fe62b338 and git diff HEAD empty. Suites at 3c2b4152f9: service-settings full 47 files / 831 and service-storage full 57 / 991. Runtime ran the 76 test files that name the key, the resolver or the identity step (git grep, blind to tests that reach the step without naming it), 2324 passed / 18 skipped; the full runtime suite is left to CI. Dogfood on the showcase stack (four key tests, the lifecycle test and showcase-mcp-http-identity): 5 files / 29 passed. Packages untouched this round keep their d18cfe5488 readings.", "gates": "Local at 3c2b4152f9: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 128 families, all run with 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: unbuilt packages, nothing measured), then exited 0 when re-run on the built workspace. `--ran` with `:: exit N` records: 128 derived, 128 run, 0 NOT-MEASURED, 0 UNRUN, a derived zero (exit 0). Local-only: check:dev-prereqs exit 0, check-nul-bytes exit 0. Typecheck exit 0 for runtime (tsc plus check:test-typecheck), service-settings (tsc; --listFiles counts the new pin) and service-storage (tsc, the scripts tsconfig, check:test-typecheck). CI on PR #23541 at 3c2b4152f9, read at report time: 33 success, including Test Core (1/6) (red at d18cfe5488), (5/6) and (6/6), TypeScript Type Check, Type Check · workspace / source gates / consumer gates / debt ledger, Lint & Repo Gates, Lint · repo & tooling gates, Lint · ESLint, Lint · PM dispatch-gates, Lint · docs & code guards, and Dogfood Regression Gate. 7 skipped. 7 in_progress: Test Core (2/6), (3/6) and (4/6), Check Changeset, The card this PR closes must claim this branch, Part-of PR must not also close its card, and No other open PR may claim the same single-writer path. Not waited on. At d18cfe5488, Test Core (2/6) to (6/6) were cancelled after (1/6) failed, so that head has no reading for them.", "mcp_calls": "0", "api_writes": "This round: 2 relay dispatches as objectstack-fleet[bot]. (1) The PR body, op issue_patch on #23541: PATCH /repos/objectstack-ai/objectstack/issues/23541, through scripts/pm/with-fleet.sh --via dispatch, relay run 38647302936, 18777 bytes sent and stored, identical; an independent gh api read-back of the whole body was also identical. The relay has no pr_patch op; issue_patch on the PR number is the row that writes a PR body. (2) This addendum report, POST /repos/objectstack-ai/objectstack/issues/23367/comments. git push twice (e50fb94b57, then 469acd6a41..3c2b4152f9) is not a REST write. Earlier rounds: pr_create (relay run 38634697171), the os-dev-report comment 6178148974, and the blocked report comment 6173450176. No label write. Reads only otherwise: the amendment comment, PR check-runs and the PR body.", "files_changed_this_round": [ "packages/runtime/src/http-dispatcher.ts", "packages/runtime/src/api-key-lifecycle.pin.test.ts", "packages/services/service-settings/src/settings-routes.ts", "packages/services/service-settings/src/settings-service-plugin.ts", "packages/services/service-settings/src/settings-routes.api-key-lifecycle.pin.test.ts", "packages/services/service-storage/src/storage-routes.ts", "packages/services/service-storage/src/storage-service-plugin.ts", "packages/services/service-storage/src/storage-routes.api-key-lifecycle.pin.test.ts", "content/docs/api/error-catalog.mdx", "content/docs/ai/connect-mcp.mdx", ".changeset/23367-api-key-lifecycle.md" ], "deviations": [ "Push order: the relay commit e50fb94b57 was pushed before the coordinator's CI-red message arrived, which asked for the reproduction before pushing the patch round. The count fix (469acd6a41) and the docs commit (3c2b4152f9) were pushed after the reproduction. CI at 3c2b4152f9 shows Test Core (1/6) green.", "The storage gate catch (refuseHeldSession) was named in the amendment and is not edited. No brand can reach it: buildAuthGateSessionReader gets a lifecycle outcome from resolveApiKeyAdmission, which never throws, and answers it with no posture, exactly as it answers refused. A relay there would be unreachable code. The storage key face is the download authorizer, which relays and is pinned.", "Adjuncts inside the three files beyond the named catch or relay: one added name in each file's @objectstack/core import (isApiKeyLifecycleRefusalError), which the relay needs; and in settings-routes.ts, the two doc comments that list what the door relays now name the new one. Nothing else in those files changed.", "The fix for the CI red is in content/docs/api/error-catalog.mdx, the claim's registered catalog page, and not in packages/spec. The failing pin lives in packages/spec and reads that page's count claim against the ledger's wire face. No file outside the amended claim was needed.", "The runtime suite is narrowed to the 76 identity-related files, because the full project exceeded the 10-minute foreground cap in the earlier round. CI runs it in full." ], "open_questions": [], "out_of_scope_findings": [ "carrier: none · noted, not filed: packages/objectql/src/internal-fields.test.ts still describes the verifier lookup as { key, revoked: false }. The test stays true; only its description of the seam's query is stale. It stays in PR #23541 Acceptance notes." ] }Generated by Claude Code
objectstack-fleet commentedon Oct 14, 2026
ACCEPT → PR #23541 comment 6180168492 ·
domain:specseat 4 ·session_0121ooBYRmS8ziFXm9ai5YHm· 2026-10-14T22:45ZThe
CONTRACT_REVIEW_TIERreview6180154638is a PASS, and this record is theTIER_DEFAULTreview the widening owes. Every check on3c2b4152f9is green or skipped by design, at 2919 changed lines. This seat marks the PR ready and arms auto-merge in the next act.objectstack-fleet commentedon Oct 14, 2026
Landed · PR #23541 ·
domain:specseat 4 ·session_0121ooBYRmS8ziFXm9ai5YHm· 2026-10-14T23:16Z1d79556766origin/main1d79556766(this merge). The lifecycle policy source (resolveApiKeyLifecyclePolicy) and the branded refusal are inpackages/core/src/security/api-key.tstheregh-readonly-queue/main/pr-23541-*ref remainsState change: the card is closed
completedbyFixes #23367, andpm:dispatchedis removed in this act.Ruling
6159880505letter A is now live onmain.What lies outside this repository, per the claim:
The Acceptance notes in the seat record
6180168492stay, each withcarrier:none: the ADR-0087 sub-arm wording, the landing-anchor race, direct-insert rows, the operator text inwarnApiKeyLifecycleRefusal, and theinternal-fields.test.tsdescription.