Skip to content

fix(core): a revoked API key's refusal names the restore road instead of claiming permanence - #23991

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-23944-revoked-key-refusal-text
Oct 16, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-23944-revoked-key-refusal-text

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23944

Clause-②: no

What changed

A call made with a revoked API key is refused 403 API_KEY_REVOKED, and the message said "a revoked key never works again". sys_api_key declares a restore_api_key row action whose PATCH { revoked: false } brings the same key back at once, so the product's error text contradicted its own action. The revoked branch of lifecycleRefusalOf now reads:

This API key (PREFIX...) was revoked. Its owner or a platform administrator can restore it (the Restore API Key action on the API Keys list); otherwise create a new key (POST /api/v1/keys, or the API Keys page) and replace this one wherever it is configured.

  • packages/core/src/security/api-key.ts: the revoked message only. Code, status, order and the carried keyId, userId, at are untouched; the expired and inactive messages (and the shared API_KEY_REMEDY they use) are unchanged.
  • packages/core/src/security/api-key-lifecycle.test.ts: one pin (see Evidence).
  • content/docs/api/error-catalog.mdx: the API_KEY_REVOKED Fix line only (declared to domain:devx in the claim).
  • .changeset/23944-revoked-key-refusal-text.md: @objectstack/core patch.

Which edit, and why

The issue offered two: reword the refusal, or retire restore_api_key. The lifecycle ruling on #23367 (letter A) binds expiry, inactivity and distinct located codes and is silent on restore; restore_api_key predates it and its execution kept it, so nothing asks for a removal. The product states the same intent twice more: docs/NORTH-STAR.md line 105 ("能撤也能恢复") and the platform checklist item identity-auth.api-key-ui-lifecycle ("the lifecycle is reversible"). The declared action stands and the text is made to agree with it. sys-api-key.object.ts is not touched.

Who can restore (the wording is this set, measured)

restore_api_key is PATCH /api/v1/data/sys_api_key/{id} with { revoked: false }, the same door as revoke. Measured at base 2054a0888 with the real PermissionEvaluator over defaultPermissionSets after applyManagedWriteDenies (the kernel:ready state), plus the sets' own row policies:

principal update on sys_api_key row scope may restore
member (member_default) true sys_api_key_self, operation all, user_id == current_user.id own key only
organization admin (organization_admin + member_default) true as the union with member_default (the set's own entry for the table is the managed-table deny, with neither super-user bit) same self policy own key only; not another user's
platform admin (admin_full_access) true wildcard with viewAllRecords and modifyAllRecords, no row policy; deliberately not a managed-deny target any key
read-only (viewer_readonly) false select-only self policy none

The column is bounded by ADR-0092 D2's write whitelist for this table (revoked alone). Setup's API Keys page, where an administrator sees every key, is gated on manage_platform_settings, which organization_admin withholds. So the set is the key's owner and a platform administrator, and the text says exactly that: "an administrator" would have invited an organization admin to a PATCH their row policy refuses (a member gets 404 on another user's key in the dogfood pin). The Cause line of the same catalog entry still says "an administrator" (loose usage, outside the claimed one-line surface).

Exercised in CI by the existing dogfood files: api-key-owner-revoke.dogfood.test.ts (owner restores own key; cross-owner PATCH is 404) and api-key-revoke-lifecycle.dogfood.test.ts (admin revoke and restore).

Evidence

  • Red then green. The pin fails on origin/main's text and passes on this one.
    • Red: node scripts/ablation-replace.mjs put origin/main's message line back on disk (anchor hit 1 time, blob bf7d10c8 to fbb1c846), ran vitest run --project local src/security/api-key-lifecycle.test.ts: 1 failed | 20 passed (21), expected 'This API key (osk_lifecycl…) was revo…' to contain 'Restore API Key'. Restore proven: blob after restore equals blob at HEAD (bf7d10c8), git diff HEAD empty. Direction observed: red, the normal one. The subject is 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. The whole @objectstack/core local project: 102 files, 2504 tests passed.
  • The pin asserts, through resolveAuthzContext, code: API_KEY_REVOKED and status: 403 (the envelope), then that the message names the key's prefix, Restore API Key, who can restore it and POST /api/v1/keys, and matches none of never, permanent, forever, for good.
  • Typecheck: pnpm --filter @objectstack/core typecheck exit 0 (tsc, the examples program, check:test-typecheck).
  • Gates: dispatch-gates --commands --repo objectstack-ai/objectstack derived 94 families on this head; each was run with its exit code captured before any pipe and reconciled with --ran: 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0. Six first answered 3 (PREREQUISITE NOT MET, packages unbuilt) and two timed out under box contention; all eight were re-run after turbo run build --filter='./packages/*' --filter='./packages/*/*' and with a longer timeout, and exit 0. The four roster families keyed on this card's directories (check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity) and check:error-code-provenance also exit 0. Families CI runs are cited from this PR's check-runs, not re-run.
  • No other copy of the sentence. Re-grep of tracked files outside content/docs/releases/ for "never works again": before the edit, 2 hits (api-key.ts and the catalog Fix line), control "was revoked, and a revoked key" 1 hit in api-key.ts; a wrapped-line variant search found no third. After the edit the only hits quote it on purpose (the changeset's FROM text and a comment in the pin). Nothing in the repository parses the message: API_KEY_REVOKED consumers match on the code, and the dogfood probes assert the code.

Acceptance notes

Not filed; noted for the reader.

  • 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") are consistent with rotation, which works for all three codes; they simply do not mention restore for the revoked one. Outside the claimed surface, left alone.
  • The checklist item identity-auth.api-key-ui-lifecycle still expects "401" after revoke, while the runtime answers 403 API_KEY_REVOKED since the lifecycle ruling; its sibling item for the lifecycle policy has the 403. Test-spec drift outside the claimed surface.
  • 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 message cannot know the reason and states only the capability. Whether a compromise-response revocation should be owner-restorable is a product question this card does not decide.
  • An owner whose only resolved set is viewer_readonly cannot restore (update is false for that set); the text states the general capability.
  • Live boot of an administrator restoring another user's key was not run: that row of the table is the evaluator's reading of the sets and is not exercised by an existing dogfood file.

Deviations

  • Commit trailers are the model-free pair AGENTS.md requires; the harness attribution reminder asked for a model-named trailer, which the pre-push hook refuses, so it was not used.
  • Base is 2054a0888. origin/main moved four commits during the run (cli/runtime mkdir, objectui pin, a dogfood pin, platform-objects menu); none touches this diff's four paths. dispatch-gates reports three derivation inputs stale against origin/main (check-cli-test-child-env.mjs, spec-shape-retirements.json, sdui-manifest.record.json), none of them in this card's derived families.

Generated by Claude Code

… of claiming permanence

The 403 API_KEY_REVOKED message said "a revoked key never works again", while
sys_api_key's own restore_api_key action brings the same key back. The message
now says the key was revoked, that its owner or a platform administrator can
restore it, and otherwise to create a new key. Code, status, carried fields and
the expired and inactive messages are unchanged; the error catalog's Fix line
says the same.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/core, touching 2 documentable anchor(s).

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

  • content/docs/ai/agents.mdx (via /api/v1/keys (route, a path literal in lifecycleRefusalOf))
  • content/docs/ai/connect-mcp.mdx (via /api/v1/keys (route, a path literal in lifecycleRefusalOf))
  • content/docs/api/error-catalog.mdx (via /api/v1/keys (route, a path literal in lifecycleRefusalOf))
  • content/docs/api/index.mdx (via /api/v1/keys (route, a path literal in lifecycleRefusalOf))
  • content/docs/deployment/environment-variables.mdx (via /api/v1/keys (route, a path literal in lifecycleRefusalOf))
  • content/docs/getting-started/build-with-claude-code.mdx (via /api/v1/keys (route, a path literal in lifecycleRefusalOf))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via /api/v1/keys (route, a path literal in lifecycleRefusalOf))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 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 — 27 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 2e1562091cf850d7f3d1057f5ebb82e9ff8cb0be → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5527571e7d6266647be1a5dff52a6cb4a666d610 — the merge of head 07bc7cf89878dca355c3e4159c67576e00288f9d into base 2e1562091cf850d7f3d1057f5ebb82e9ff8cb0be, 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 5527571e7d6266647be1a5dff52a6cb4a666d610 && git checkout 5527571e7d6266647be1a5dff52a6cb4a666d610
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2e1562091cf850d7f3d1057f5ebb82e9ff8cb0be 07bc7cf89878dca355c3e4159c67576e00288f9d && git checkout -B drift-repro 2e1562091cf850d7f3d1057f5ebb82e9ff8cb0be && git merge --no-ff 07bc7cf89878dca355c3e4159c67576e00288f9d

node scripts/docs-audit/affected-docs.mjs --json 2e1562091cf850d7f3d1057f5ebb82e9ff8cb0be

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ✅ ACCEPT: PR #23991 at 07bc7cf898 · domain:engine seat 2 · session_01TQLy3Yq6vF4SDpTXdr3Uwi · 2026-10-16T17:29Z

Basis:

Checklist

  • Shape. Draft, base main, Fixes #23944. Clause-②: no is on its own line in the body and the changeset. No other number carries a closing verb.
  • Clause-②: no is truthful. One message string and one docs line change. No code, status, refusal order or carried field (keyId, userId, at) moves; the expired and inactive messages are untouched. sys-api-key.object.ts is not in the diff, so restore_api_key stays as triage's branch requires.
  • The wording is measured, not assumed.
    • The dev read the permission sets as the kernel arms them, with PermissionEvaluator.checkObjectPermission over defaultPermissionSets after applyManagedWriteDenies, plus each set's own RLS:
      • member_default can update its own sys_api_key rows only (sys_api_key_self);
      • organization_admin adds nothing beyond its own rows;
      • admin_full_access can update any row;
      • viewer_readonly cannot update.
    • So "its owner or a platform administrator can restore it" is exact. The seat's suggested "an administrator" would have over-promised to an organization admin.
    • The scratch probe file was deleted before any commit.
  • No other copy. Outside content/docs/releases/**, the old sentence lived at api-key.ts:309 and error-catalog.mdx:449; both are edited, and a control grep found no third copy.
  • Evidence.
    • The pin in api-key-lifecycle.test.ts asserts that the message names "Restore API Key" and does not claim permanence. With origin/main's line put back by ablation-replace.mjs it is red, 1 of 21, receiving "a revoked key never works again". The restore was proven: blob equal to HEAD, empty git diff HEAD.
    • @objectstack/core: 2504 of 2504, and typecheck exit 0.
  • Gates. dispatch-gates --commands: 94 derived, 94 run, 0 NOT MEASURED. Eight first-pass prerequisite refusals or timeouts each re-ran to exit 0. check:doc-authoring exits 0.
  • Changeset, sentence by sentence. What the caller heard, what the message says now, and the unchanged code, status, fields and sibling messages all match the diff. @objectstack/core patch.
  • Landing checks. check-governed-merges --pr 23991: NOT governed, 54 changed lines. mergeable_state is clean, and git merge-tree against current origin/main (5fb8d158b) is clean.

Out of scope, one line each:

Gate on the head: ci-failure.mjs --pr 23991: GREEN, 41 of 41. The five skips (Console Pin Gate, Temporal Conformance (live PG + MySQL), Dogfood Verify CLI, the Dogfood Regression Gate matrix template, Packed-tarball smoke (opt-in)) are on check-expected-skips.mjs's roster, and each gate is unmet on this diff. Build Docs ran, because the error catalog changed, and passed.

Next: ready, then auto-merge through the queue.


Generated by Claude Code

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/s tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants