Repository navigation
API key refusal API_KEY_REVOKED says "a revoked key never works again", but the declared restore_api_key action brings the same key back to 200 #23944
Description
Filing gate ①: a product defect with a named landing point. The runtime refusal text is wrong, which is one of the filing-gate exceptions ("发版固化的错误文字"). Reach: public HTTP, measured on a live boot: an x-api-key call against a revoked key returns this message, and the same key answers 200 after the declared restore.
Reader who acts: the domain:identity (security/api-key) execution seat. One of two edits closes it:
- reword the refusal in
packages/core/src/security/api-key.ts; or - if revocation is meant to be terminal, retire
restore_api_keyfrompackages/platform-objects/src/identity/sys-api-key.object.ts.
The choice depends on what the API-key lifecycle decision (#23367) intended for restore.
What happens
The refusal for a revoked key reads:
<label> was revoked, and a revoked key never works again. Create a new key (POST /api/v1/keys, or the API Keys page) and replace this one wherever it is configured.
The same object declares a restore_api_key list-item action: PATCH /api/v1/data/sys_api_key/{id} with { revoked: false }. Its confirm text says "Existing clients holding the key will regain access", and that is exactly what happens. A caller who reads the refusal is told to rotate a key that an admin can simply restore, and an operator who restores is contradicted by the product's own error text.
Reproduction
Run on a stock showcase boot (objectstack dev --ui --seed-admin) at 2034975a. Reproduced on three revoke/restore cycles:
- Sign in as a member.
POST /api/v1/keys→ 201, with the rawkeyreturned once. GET /api/v1/data/showcase_taskwith headerx-api-key: KEY→ 200.- As the key owner,
PATCH /api/v1/data/sys_api_key/IDwith{"revoked": true}(therevoke_api_keyaction) → 200. - Repeat step 2 → 403
API_KEY_REVOKEDwith the message above ("…never works again…"). PATCH /api/v1/data/sys_api_key/IDwith{"revoked": false}(therestore_api_keyaction) → 200.- Repeat step 2 → 200: the same key works again.
Expected: the refusal and the lifecycle agree. Either the message says the key is revoked and can be restored by its owner or an admin, or rotated; or restore does not exist.
Actual: the message asserts permanence that the declared action contradicts.
Mechanism
packages/core/src/security/api-key.ts,lifecycleRefusalOf()(about line 309): the revoked branch hard-codesa revoked key never works again.packages/platform-objects/src/identity/sys-api-key.object.ts(about line 105):restore_api_keydeclaresbodyExtra: { revoked: false }. The auth path re-readsrevokedper call, so restore re-admits the key immediately.
Dedupe
- MCP
search_issueson objectstack-ai/objectstack, closed included:API_KEY_REVOKED revoked key never works again restore_api_key message→ 3 hits: [Decision] API key 的生命周期与存放:到期上限与不活动失效取多长,key 继续放自有 sys_api_key 还是迁到 @better-auth/api-key(#23287 裁决 C′ 的留项) #23367 (the lifecycle decision), api-key-ui-lifecycle: API keys cannot be revoked through any product route (405) — object declares revoke/restore as PATCH but disables the PATCH method #7727 and api-key-ui-lifecycle: after #7727 an ordinary member still cannot revoke their OWN API key — 403 PERMISSION_DENIED, and the key keeps authenticating #8053 (revoke route reachability, both closed). None is about the refusal text contradicting restore. - Dedupe words: API_KEY_REVOKED message; revoked key never works again; restore_api_key.
QA-source: #23943 · identity-auth.api-key-ui-lifecycle · acceptance 2
Activity
objectstack-fleet commentedon Oct 16, 2026
Triage: first grade,
bug·priority:p2·target:v18·domain:engine·area:access·pm:queue· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-16T14:02ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
origin/main98ba1bf1d2:packages/core/src/security/api-key.ts:309still says "a revoked key never works again".packages/platform-objects/src/identity/sys-api-key.object.ts:105still declaresrestore_api_key. The filed reading holds.domain:engine: both landing points (packages/core,packages/platform-objects) aredomain:engine. The retireddomain:identitylabel maps to services only for the plugin packages.target:v18: the refusal text ships in 18 and contradicts the product's own action.restore_api_key, which is a declared-action removal that the claim declares asClause-②. If the ruling says neither, returnneeds_decision.objectstack-fleet commentedon Oct 16, 2026
Claim: PM loop round 1 · 2026-10-16T15:50Z · on triage's first grade 6217184876, which reads the #23367 lifecycle ruling 6159880505 as leaving restore in
Session:
session_01TQLy3Yq6vF4SDpTXdr3UwiAccount:
os-tesla(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-23944-revoked-key-refusal-textWorktree:
objectstack-issue-23944Domain:
domain:engineSeat:
domain:engine#2(seat post #20966)File surface:
packages/core/src/security/api-key.ts(the revoked branch oflifecycleRefusalOf, its message text only); tests beside it inpackages/core;content/docs/api/error-catalog.mdx, theAPI_KEY_REVOKEDentry's Fix line only (declared todomain:devx); one.changeset/23944-revoked-key-refusal-text.md. ⛔ Notsys-api-key.object.ts:restore_api_keystays. ⛔ No change to the code, status or order of any refusal. Stop on a breach and explain it in the report.Container & model:
S,mode:subagent,model: sonnet(dispatch-gates --tierat0b6956490: no path-derived mandate; a message-text card, at the tier floor)Clause-②: no
Responsibility:
core's API-key admission (this repository's code) states that a revoked key never works again | the object declaresrestore_api_keysince July (#2841-era), and the #23367 ruling 6159880505 (letter A) retired nothing; its execution #23541 added the sentence | every caller holding a revoked key is told to rotate a key its owner or an administrator can restore, and an operator who restores is contradicted by the product's own error textThread-read: 6217184876
Serial constraints cleared: no open PR touches
api-key.ts,error-catalog.mdxorsys-api-key.object.ts. No test pins the sentence. No same-day churn on either path. Read 2026-10-16T15:50Z.Which edit (triage's branch): reword the refusal. Ruling 6159880505 binds expiry, inactivity and distinct codes, and says nothing about restore. Restore predates it and its execution kept it, so the declared action stands and the message is made to agree with it. Retiring
restore_api_keywould be a declared-action removal that no ruling asks for.Generated by Claude Code
objectstack-fleet commentedon Oct 16, 2026
os-dev-report
{ "issue": 23944, "status": "done", "branch": "claude/issue-23944-revoked-key-refusal-text", "pr": "https://github.com/objectstack-ai/objectstack/pull/23991", "session": "session_01TQLy3Yq6vF4SDpTXdr3Uwi", "premise_still_valid": true, "summary": "Reworded, did not retire: the 403 API_KEY_REVOKED message no longer says a revoked key never works again. It now says the key was revoked, that its owner or a platform administrator can restore it (the Restore API Key action on the API Keys list), and otherwise to create a new key (POST /api/v1/keys, or the API Keys page). Code, status, order, carried fields and the expired and inactive messages are unchanged; sys-api-key.object.ts is untouched. The error-catalog Fix line carries the same sentence. Draft PR 23991, +51/-3 over the 4 claimed files, base 2054a0888, head 07bc7cf89, assignee os-tesla.", "assumptions": { "1_who_can_restore": "CONFIRMED, NARROWED: the set is the key's owner and a platform administrator, so the text says 'a platform administrator', not the PM example 'an administrator'. See restore_permission_reading.", "2_pins_and_red_green": "CONFIRMED: no test pinned the old sentence (grep of tracked test files for the old wording and for 'replace this one wherever': none; API_KEY_REVOKED consumers match the code, dogfood probes assert the code). One pin added; red on origin/main text, green on this one (red_green).", "3_no_other_copy": "CONFIRMED: outside content/docs/releases the old sentence existed twice (api-key.ts:309 and error-catalog.mdx:449), both edited. Control 'was revoked, and a revoked key' hit once in api-key.ts before the edit; a wrapped-line variant search found no third copy. After the edit the only hits quote the old sentence on purpose (the changeset's FROM text and a comment in the pin)." }, "restore_permission_reading": { "route": "restore_api_key = PATCH /api/v1/data/sys_api_key/{id} with revoked false, the same door as revoke_api_key; column bounded by ADR-0092 D2 whitelist (revoked alone).", "method": "At base 2054a0888: real PermissionEvaluator.checkObjectPermission over defaultPermissionSets after applyManagedWriteDenies (the kernel:ready state), plus each set's own rowLevelSecurity and object entry, via a scratch vitest file in plugin-security (run under os-verify-lock, VERDICT command-exit 0, deleted, never committed; tree clean).", "member (member_default)": "find true, update true, insert false, delete false; RLS sys_api_key_self operation all, user_id == current_user.id => own key only", "organization admin (organization_admin + member_default)": "update true as the union with member_default; the set's own table entry is explicit with viewAll false and modifyAll false; same self RLS => own key only, not another user's. Setup API Keys page needs manage_platform_settings, which organization_admin withholds.", "platform admin (admin_full_access)": "update true (and insert/delete true); wildcard entry with viewAllRecords and modifyAllRecords true, no row policy; documented non-target of the managed denies => any key", "read-only (viewer_readonly)": "update false; select-only self policy => cannot restore", "live_evidence": "CI dogfood files api-key-owner-revoke (owner restore; cross-owner PATCH 404) and api-key-revoke-lifecycle (admin revoke and restore). NOT MEASURED live: an administrator restoring ANOTHER user's key (no existing dogfood file drives it, and no booted stack was run here); that row is the evaluator reading of the sets.", "wording_consequence": "'Its owner or a platform administrator can restore it'. An owner whose only set is viewer_readonly cannot; the text states the general capability." }, "red_green": { "red": "node scripts/ablation-replace.mjs WRAP mode put origin/main's message line back on disk: anchor hit 1 time, anchor 1 -> 0, blob bf7d10c87be9 -> fbb1c8466839; vitest run --project local src/security/api-key-lifecycle.test.ts => 1 failed | 20 passed (21), AssertionError: expected 'This API key (osk_lifecycl...) was revo...' to contain 'Restore API Key' (received text carried 'a revoked key never works again'); command exit 1. Restore proven on disk: blob after restore == blob at HEAD (bf7d10c87be9), git diff HEAD empty. Direction: red, the normal one. Subject imported by relative path inside the package, so no dist/ leg applies.", "green": "same file on this head with api-key.test.ts and api-key-account-posture.test.ts: 3 files, 60 tests passed (VERDICT command-exit 0). Whole @objectstack/core local project: 102 files, 2504 tests passed (VERDICT command-exit 0)." }, "tests": "All through scripts/pm/os-verify-lock.sh, output redirected to files, VERDICT command-exit read: (1) pnpm --filter @objectstack/core exec vitest run --project local --maxWorkers=2 src/security/api-key-lifecycle.test.ts src/security/api-key.test.ts src/security/api-key-account-posture.test.ts => exit 0, Test Files 3 passed, Tests 60 passed. (2) red leg as in red_green => exit 1 on the pin only, restore proven. (3) pnpm --filter @objectstack/core test => exit 0, 102 files, 2504 tests. (4) pnpm --filter @objectstack/core typecheck => exit 0 (tsc, examples program, check:test-typecheck OK, 4 pinned). Dependency closure built first: pnpm --workspace-concurrency=2 --filter '@objectstack/core^...' build => exit 0 (2m30s held). test:repo not run locally: this diff touches no repo-project test and no derived list names it; it is CI's Test Core. All on head 07bc7cf89.", "gates": "dispatch-gates --commands --repo objectstack-ai/objectstack (no paths) on this worktree at 07bc7cf89 derived 94 families; each run with exit captured before any pipe (rungates.sh, recorded as 'cmd :: exit N'); dispatch-gates --ran => 'Run reconciliation: 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN', all 94 exit 0. First pass: 86 exit 0, 6 exit 3 (check:doc-formula-expressions, check:doc-security-posture, check:skill-examples, check:docs-transcript-drift, check:dual-build-cjs-loads, check:lean-entry-closure: PREREQUISITE NOT MET, packages unbuilt; NOT a pass, NOT a failure) and 2 exit 124 (check:query-options-erasure, check:type-check-debt: 240s timeout under box contention). After pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2 (74 tasks, 63 cached, under the lock) all eight re-run with longer timeouts and exit 0, including check:type-check-debt (26 raw tsc errors, none above its recorded number) and check:query-options-erasure (65 unswept sites, none new). Unfiltered run read: artifact-roster block 51 families, of which 4 keyed on this card's directories (check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity) plus spec check:error-code-provenance were run: all exit 0; the other 46 roster families (15 of them self-test-only, which judge the checker's own fixtures) judge no path of this diff and were not run. Wide-population block 11 families (init-service-contract, live-db-isolation, meta-type-normalized, optional-error-sink, resume-authority-declared, route-envelope, runner-env-posture, settings-bind-window, startup-registry-verdict, verify-stand-in, wildcard-fallthrough): not run locally, this diff changes a string literal, a comment and a test; CI runs them on every PR. 6 value-bearing families (shard attestation, test-completeness, issue-citations --census) cannot run locally. pnpm check:doc-authoring exit 0 (self-test 17 batteries / 206 cases at or above floor; 421 doc files, 53 skill files and 18916 spec strings clean). Families CI runs: PR 23991 check-runs at head 07bc7cf89 read once right after open: all in_progress (Lint ESLint, Lint repo and tooling gates, Lint docs and code guards, Lint PM dispatch-gates, Lint CI release and engine gates, Lint scoped slot lookup and corpus, Lint scoped query-options erasure, Lint scoped tooling self-tests, Type Check workspace, Type Check source gates, Type Check consumer gates, Type Check debt ledger, Check Changeset, Check Documentation Links, Spec property liveness, Governed Surface Queue Guard, Part-of PR must not also close its card, The card this PR closes must claim this branch, No other open PR may claim the same single-writer path, Check PR Size, Flag docs affected by code changes, filter) except 'No other open PR may claim the same issue' success and 'Packed-tarball smoke (opt-in)' skipped. in_progress is the honest value; Test Core, Build Core and the Dogfood gates were not yet listed.", "line_budget": "+51/-3 = 54 changed lines over 4 files (human-merge threshold 3000: under). No skills/** touched.", "files_changed": [ "packages/core/src/security/api-key.ts (revoked message + 6-line comment)", "packages/core/src/security/api-key-lifecycle.test.ts (one pin + header bullet)", "content/docs/api/error-catalog.mdx (API_KEY_REVOKED Fix line only)", ".changeset/23944-revoked-key-refusal-text.md (@objectstack/core patch; Clause-② no on its own line)" ], "deviations": [ "Wording: 'a platform administrator', not the PM example 'an administrator' (measurement won: an organization admin can restore only their own key). The catalog Cause line and connect-mcp table still say 'an administrator' (outside the claimed one-line surface).", "Commit trailers are the model-free pair (Claude-Session + Co-authored-by: Claude) that AGENTS.md and the pre-push hook require; the harness attribution reminder asked for a model-named trailer, not used.", "Gates: 8 of 94 did not exit 0 on the first pass (6 unbuilt-prerequisite exit 3, 2 timeouts); all re-run to exit 0 as stated in gates.", "dispatch-gates reports a STALE TREE: base 2054a0888 is 4 commits behind origin/main (2e1562091); none of them touches this diff's four paths; the 3 stale derivation inputs (check-cli-test-child-env.mjs, spec-shape-retirements.json, sdui-manifest.record.json) feed no family derived for this card. origin/main was NOT merged in (no conflict, no main-caused red at report time).", "The third gate chunk (lines 63-94) hit the harness's 590s foreground cap and was moved to the background by the harness; it ran to completion, I read its output file and recorded exits per command; nothing waited on a wake.", "Lock waits: 445s (behind another agent's turbo build) before the first test run, 224s before the scratch matrix run. The first closure build joined command and echo with ';' so its VERDICT read batch-last-exit; build-exit=0 was echoed from the build command itself and the log tail confirmed Done.", "A scratch test file (packages/plugins/plugin-security/src/zz-scratch-23944.test.ts) existed for one run for the permission matrix; deleted before any commit; git status clean; never pushed.", "Reads used gh api GET only (issue, comments, ruling comment, PR, check-runs); no MCP calls." ], "mcp_calls": "0 — no MCP GitHub calls", "api_writes": "3 REST writes in 2 relay strokes (fleet-write dispatch, each a repository_dispatch POST to the board repo): stroke 1 pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft, forced) + POST /repos/objectstack-ai/objectstack/issues/23991/assignees [os-tesla] (read-back: body 8260 bytes sent, 8260 stored, identical; assignees os-tesla, none missing); stroke 2 = this os-dev-report comment, POST /repos/objectstack-ai/objectstack/issues/23944/comments. Plus git push x2 (empty-branch probe, then 07bc7cf89), which is not a REST write. No label writes, no assignee write on the card, no PATCH of the PR body.", "open_questions": [], "out_of_scope_findings": [ "carrier: none (noted, not filed) · class: none of a/b/c · content/docs/ai/connect-mcp.mdx ('The remedy is the same for all three: create a new key') and content/docs/api/index.mdx ('the remedy is a new key') agree with rotation, which works for all three codes, but do not mention restore for API_KEY_REVOKED; consistent and partial, outside the claimed surface.", "carrier: none (noted, not filed) · class: none of a/b/c · docs/qa/platform-checklist/areas/identity-auth.json item identity-auth.api-key-ui-lifecycle still has the revoke step capture '401', while the runtime answers 403 API_KEY_REVOKED since the lifecycle ruling (its sibling item for the lifecycle policy already says 403); test-spec drift, outside the claimed surface.", "carrier: none (noted, not filed; for the PM or domain:identity to route) · product question, not a defect · a key revoked by an administrator's forced password reset (revokeSessionsAndKeys) is restorable by its owner through the same row action, because restore is not conditioned on why the flag was set; the plugin-auth comment says 'Revoked keys stay revoked after the password change' and the 23287 changeset says an administrator can restore a routine-reset key. Whether a compromise-response revocation should be owner-restorable is undecided and is not this card's." ] }Generated by Claude Code
objectstack-fleet commentedon Oct 16, 2026
ACCEPT → PR #23991 comment 6221055912 ·
domain:engineseat 2 ·session_01TQLy3Yq6vF4SDpTXdr3Uwi· 2026-10-16T17:29Z. Findings filed as #23992 (checklist oracle) and #23993 (owner restore of a reset-revoked key). Landing next: ready, then auto-merge through the queue.Generated by Claude Code
objectstack-fleet commentedon Oct 16, 2026
Landing record: PR #23991 →
4ffdebe182·domain:engineseat 2 ·session_01TQLy3Yq6vF4SDpTXdr3Uwi· 2026-10-16T18:04Zmerged: true, merge commit4ffdebe182, through the merge queueorigin/main4ffdebe18is an ancestor oforigin/main. "never works again" no longer appears inapi-key.tsorerror-catalog.mdx. The revoked message names "Restore API Key"..changeset/23944-revoked-key-refusal-text.mdis presentgh-readonly-queue/main/*pr-23991*head remainsState change: this card closed
completedbyFixes #23944;pm:dispatchedremoved in this act. No other card was closed by this PR. #23950 closed in the same second, by its own PR #24000 (Fixes #23950), merged in the same queue batch.Carried:
401on revoke.Generated by Claude Code