Skip to content

feat(plugin-auth,platform-objects,core)!: a key a forced password reset revoked is restored by a platform administrator only - #24044

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23993-reset-revoked-key-restore
Oct 16, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23993-reset-revoked-key-restore

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23993

Clause-②: yes (narrowing)

An API key that an administrator's forced password reset revoked can now be restored only by a platform administrator. Its owner is refused 403 PERMISSION_DENIED, and the refusal names both roads: ask a platform administrator, or create a new key. This follows the changeset of #23287 ruling C′ (.changeset/pre/23287-api-key-account-posture.md): "an administrator can restore a key a routine reset revoked …, or the owner mints a replacement". The field widens sys_api_key; the owner's restore of such a key narrows.

What changed

file change
packages/platform-objects/src/identity/sys-api-key.object.ts revoked_reason: a readonly select (ADR-0092 D4) whose one option is admin_password_reset, described for an administrator
packages/platform-objects/src/apps/translations/*.objects.generated.ts en regenerated (pnpm i18n:extract); the zh-CN / ja-JP / es-ES leaves are hand-written, and the re-run extract kept them and dropped their source-fill hashes
packages/plugins/plugin-auth/src/forced-reset-revocation.ts the stamp: revoked_reason: admin_password_reset is written beside revoked: true. This is the only place the reason is set. A key already revoked before the reset is not written, so it keeps no reason
packages/plugins/plugin-auth/src/api-key-restore-guard.ts (new) ONE beforeUpdate hook on sys_api_key, at priority 15, after the ADR-0092 D2 guard (10). It enforces the rules listed below
packages/plugins/plugin-auth/src/auth-plugin.ts only the hook's registration in kernel:ready, beside registerIdentityWriteGuard
packages/plugins/plugin-auth/src/managed-extension-fields.ts revoked_reason added to MANAGED_EXTENSION_FIELDS.sys_api_key, and NOT to the editable map
packages/core/src/security/api-key.ts only the API_KEY_REVOKED message, which now branches on row.revoked_reason. Code, status, order and carried fields are unchanged
content/docs/api/error-catalog.mdx only API_KEY_REVOKED
content/docs/permissions/system-context.mdx one row (26b), outside the claimed surface. See Deviation below
.changeset/23993-reset-revoked-key-restore.md minor for platform-objects, plugin-auth and core; Clause-②: yes (narrowing); ADR-0087 not-required (no-migration-prescription)

The hook's rules:

  1. A user-context un-revoke (any revoked value that is not true) is refused when the row's stored revoked_reason is admin_password_reset (or no pre-image is in hand), unless the caller passes hasPlatformAdminStanding(engine, session.userId). That is the ADR-0068 D2 PLATFORM_ADMIN rung, and it fails closed.
  2. Every un-revoke the hook admits clears revoked_reason, including an administrator's.
  3. A revoke, or a write that leaves revoked alone, never touches the reason. So { revoked: true } on an already-revoked key keeps it. A caller-sent revoked_reason is withheld by the engine's readonly strip and by D2, so it cannot clear the reason either.
  4. A system-context write is never refused: the mint, the last_used_at stamp and the forced reset all pass.

Measured before building

Both measurements used a throwaway probe on a real ObjectQL engine over SQLite, a beforeUpdate hook at priority 15, and a user context. The probe was deleted and never committed.

  • A hook's write of a readonly column on a user-context update persists.
    • By id, the hook writing 'a' persisted 'a'.
    • When the caller forged a value and the hook wrote null, null persisted; the forgery was not handed back.
  • A multi-row update judges each row by its own pre-image.
    • Two rows stored b and a; the per-row previous the hook saw was r1=b, r2=a.
    • The hook's in-place null landed on both rows.

Neither reading falsifies the hook route.

Pins and their red legs

All pins run on the real write path: real ObjectQL over SQLite, the real SecurityPlugin with the shipped permission sets (the owner resolves member_default and its sys_api_key_self row scope; the administrator holds the unscoped admin_full_access grant that bootstrapPlatformAdmin writes), the D2 guard with the whitelists auth-plugin.ts registers, the real forced-reset revocation and the real key door.

Each red leg was made with node scripts/ablation-replace.mjs in wrap mode against the committed fix (39730a73e): the anchor hit 1 → 0, the blob changed, and the restore was proven blob == HEAD with git diff HEAD empty. An outer trap restored from HEAD by absolute path. The CONTROL stayed green on every leg.

# pin (file) green red leg
1 the owner's restore of a reset-revoked key is refused 403 PERMISSION_DENIED, the message names the platform administrator and POST /api/v1/keys, and the row is unchanged (api-key-restore-guard.test.ts) ✓ L1, drop throw restoreRefused(): red
2 a platform-admin restore lands, the reason clears, and the key door admits the key (same file) ✓ L2, drop the clear: red
3 CONTROL: the owner revokes and restores a plain key, and no reason is ever recorded (same file) ✓ green on every leg
4 the owner re-revoking a reset-revoked key keeps the reason, a forged revoked_reason: null is withheld, and the restore after it is still refused (same file) ✓ L1: red
5 the forced reset stamps admin_password_reset, and a key revoked before it keeps no reason (same file, and the DEFAULT ON case of admin-user-endpoints.test.ts) ✓ L3, drop the stamp: red in both files
6 the message names the restorer per reason (core api-key-lifecycle.test.ts; end to end through the key door in api-key-restore-guard.test.ts) ✓ L4, message branch forced to false: red
+ multi-row: an owner batch that includes a reset-revoked key is refused whole and neither row moves; an owner batch of plain keys is admitted; an administrator batch clears the reason on every row ✓ L1: owner batch red; L2: administrator batch red
+ system context: an un-revoke lands and clears the reason, and a last_used_at stamp leaves it ✓ L2: red
+ auth-plugin.ts registers exactly one beforeUpdate hook on sys_api_key at a priority after the D2 guard ✓ L5, registration removed: red

Red-leg counts: L1 3 failed of 11; L2 3 of 11; L3 7 of 68 (the two files together); L4 1 of 22; L5 1 of 11 (that run used -t kernel:ready, so 10 were skipped).

Tests

  • plugin-auth, full suite, two shards (vitest run --maxWorkers=2 --shard=1/2 and 2/2): 76 + 75 files, 1753 + 1202 tests passed (10 skipped). Lock verdicts were command-exit 0.
  • platform-objects, full suite: 73 files, 1136 tests passed.
  • core: api-key-lifecycle, api-key and api-key-account-posture tests, 61 passed.
  • Typecheck of core, platform-objects and plugin-auth: exit 0, with check:test-typecheck OK on each. The plugin-auth ledger is unchanged at 10 files / 88 errors / 23 signatures.
  • Re-run on the merged head 17ca3f000 (after origin/main up to 2b274600b): the plugin-auth pins and neighbouring suites (125 tests), core api-key (61) and platform-objects sys_api_key and translations (144) all passed.
  • test:repo was not run locally; it is CI's.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) derived 102 commands at the final head f782520bf. Every command was run with its exit captured before any pipe. --ran reconciliation: 102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN, and every command exited 0. That includes check:auth-mount-ledger, check:durability-log-level, check:route-envelope, check:i18n, check:i18n-stale-fill, check:system-context-census, check:changeset-no-major, check:adr-0087-registration and check:empty-changeset.

The artifact-roster block marks four families whose roster sits under one of these paths: check-changeset-fixed, check:authz-resolver, check:error-code-casing and check:filter-alias-parity. The wide-population block declares ten: init-service-contract, live-db-isolation, meta-type-normalized, optional-error-sink, resume-authority-declared, runner-env-posture, settings-bind-window, startup-registry-verdict, verify-stand-in and wildcard-fallthrough. All fourteen were also run locally, and each exited 0. CI's check-runs are the verdict for the rest.

Deviation: one row outside the amended surface

check:system-context-census failed on the new hook: [file-without-a-row] … api-key-restore-guard.ts holds 1 elevation read site(s) … (isUserContextWrite). The hook must read isSystem (rule 4), and that page holds every such read site. So the page gains row 26b, anchored packages/plugins/plugin-auth/src/api-key-restore-guard.ts#isUserContextWrite. Without it the required Lint & Repo Gates goes red. This page is not in the claim's surface and needs the seat's declaration (domain:devx, as error-catalog.mdx was).

Acceptance notes

  • Rows revoked before this lands. Keys a forced reset revoked before this change carry no reason and stay owner-restorable. That population exists only on @objectstack/plugin-auth@18.0.0-next.2 (dist-tag next) and on main builds since 2026-10-14. There is no backfill: the reset's audit row records counts, not key ids.
  • A key the owner revoked before a forced reset. The reset revokes only keys that are not yet revoked, so such a key gets no reason, and its owner can restore it afterwards. This keeps the reason truthful (the reset did not revoke that key). Whether the reset should also stamp keys that were already revoked is not decided here.
  • A duplicated predicate. The hook's user-context predicate repeats the D2 guard's one line (identity-write-guard.ts, module-private and outside the surface). The two judge the same writes.
  • No UI courtesy. The Restore API Key action still renders for the owner on a reset-revoked key, and the server refuses it with the road. A visible predicate on the action would be a follow-up.

Generated by Claude Code

…he forced reset's stamp and the restore guard

Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi
Co-authored-by: Claude <noreply@anthropic.com>
…ecific revoked message, translations, changeset

Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi
Co-authored-by: Claude <noreply@anthropic.com>
…e guard's isSystem read

Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l 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 3 package(s): @objectstack/core, @objectstack/platform-objects, @objectstack/plugin-auth, touching 23 documentable anchor(s).

37 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 b74434c5816520fddfb531e530df15f281992ba7.

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

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: PERMISSION_DENIED (literal, 37 pages)
  • 2 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 — 37 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 b74434c5816520fddfb531e530df15f281992ba7 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 b74434c5816520fddfb531e530df15f281992ba7 → 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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f782520bf2b07070a46eba75a2d7bdd11a952a08
Local-runs: none
Scope: packages/plugins/plugin-auth/src/api-key-restore-guard.ts, packages/plugins/plugin-auth/src/api-key-restore-guard.test.ts, packages/plugins/plugin-auth/src/forced-reset-revocation.ts, packages/plugins/plugin-auth/src/forced-reset-revoke-reason-vocabulary.test.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-auth/src/managed-extension-fields.ts, packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts, packages/platform-objects/src/identity/sys-api-key.object.ts, packages/platform-objects/src/identity/sys-api-key-single-declaration.test.ts, packages/platform-objects/src/apps/translations/en.objects.generated.ts, packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts, packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts, packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts, packages/core/src/security/api-key.ts, packages/core/src/security/api-key-lifecycle.test.ts + packages/plugins/plugin-auth/src/identity-write-guard.ts, packages/core/src/security/resolve-authz-context.ts (hasPlatformAdminStanding, resolveUserAuthzGrants 6b / 6b-config), packages/objectql/src/engine.ts (registerHook sort, triggerHooks, buildSession, the update() by-id branch: pre-hook readonly hide, the #14088 recorder, the unconditional readUpdatePriorRow, the post-hook hand-back; dispatchPerRowBeforeHooks), packages/objectql/src/validation/rule-validator.ts (stripReadonlyFields, isPreservableUnderAudit), packages/plugins/plugin-security/src/objects/default-permission-sets.ts, packages/runtime/src/domains/keys.ts, packages/plugins/plugin-auth/src/admin-user-endpoints.ts, packages/plugins/plugin-auth/src/platform-admin-gate.ts, packages/plugins/plugin-auth/src/index.ts, packages/rest/src/rest-server.ts (PATCH data route), packages/rest/src/data-action-routes.ts (import routes), packages/rest/src/batch-routes.ts, packages/core/src/utils/import-runner.ts, packages/mcp/src/mcp-http-tools.ts, packages/types/src/data-error-classification.ts (mapDataError), docs/adr/0068-unified-user-context-and-built-in-identity-roles.md (D2 and its 2026-10-06 note), docs/adr/0092-sys-user-profile-field-delegation.md (D2, D4), .changeset/pre/23287-api-key-account-posture.md, scripts/pm/clause2-line.mjs; the diff's three prose paths (.changeset/23993-reset-revoked-key-restore.md, content/docs/api/error-catalog.mdx, content/docs/permissions/system-context.mdx row 26b) were read too

① Derived judgments

Inputs read. Card #23993 (body; triage grade 6221482817; claim 6222506660; blocked report 6222928785; amendment 6222961246, route A; the dev's done report 6224544673; amendment 2 6224565245), #23287 ruling C′ (comment 6159450584) and its pre-changeset line "an administrator can restore a key a routine reset revoked …, or the owner mints a replacement", PR #24044's body and 18-file list, the net diff origin/main...f782520bf (merge base 2b274600b; 654+/16−), and the head's check-runs. The --paths recogniser decides no face from the paths alone and defers to the changeset's Clause-②: yes (narrowing) line, so this is the narrowing face at CONTRACT_REVIEW_TIER.

Accept-set and public-surface changes, each judged:

  1. sys_api_key.revoked_reason — a widening of the published object (platform-objects). A select with one option (admin_password_reset), required: false, readonly: true, group: 'Lifecycle'. RIGHT. readonly is the ADR-0092 D4 form-rendering rule for a column outside the D2 whitelist, and the object already declares managedBy: 'better-auth' with apiMethods: ['get', 'list', 'update'] (no bulk). Declared in MANAGED_EXTENSION_FIELDS.sys_api_key (the D7 collision declaration, where every column of this hand-rolled table belongs) and NOT in MANAGED_EXTENSION_EDITABLE_FIELDS, which stays {revoked}; the vocabulary test pins both halves and the single-declaration pin gains the column. Four bundles carry label, help and the option label (en regenerated, three hand-written; check:i18n and check:i18n-stale-fill green in Lint & Repo Gates).

  2. The stamp (plugin-auth forced-reset-revocation.ts) — a system-context write that now carries the reason. RIGHT and the only producer: the field is new in this diff, so its writers are exactly the stamp and the guard's clear. SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; the scan is where: { user_id, revoked: false }, so a key already revoked is not written and keeps no reason, which is the truthful reading (see ③-1). admin-user-endpoints.ts is untouched; its unit test's DEFAULT ON expectation gains the reason on both rows, as the amendment foresaw.

  3. The restore guard (plugin-auth api-key-restore-guard.ts) — the narrowing. One beforeUpdate hook, object: sys_api_key, priority 15. Read against the amendment's five rules:

    • Rule 1, refuse. A user-context write whose payload owns a revoked key with any value that is not true/1/'true'/'1' is an un-revoke; if ctx.previous is absent or previous.revoked_reason === 'admin_password_reset' and hasPlatformAdminStanding(engine, session.userId) is false, it throws { code: 'PERMISSION_DENIED', status: 403, object: sys_api_key } naming both roads. RIGHT. Absent-pre-image fails closed, and on the by-id branch readUpdatePriorRow is unconditional (engine.ts: "the read is now UNCONDITIONAL", not-found gate throws 404 before triggerHooks('beforeUpdate')), so a legitimate owner's plain restore is never refused on a missing previous; on the predicate branch dispatchPerRowBeforeHooks binds each row's own previous.
    • Rule 2, stamp only on the reset. Holds (item 2).
    • Rule 3, clear on every admitted un-revoke. data.revoked_reason = null is written after the refusal check for every context. RIGHT, and it persists: the clear lands inside the stripReadonlyFields uses Object.is to tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null #14088 recording window as a hook write, and the post-hook hand-back of a hidden caller value (engine.ts 16740–16760) re-applies only when the key is absent from the payload or holds undefined; a hook-written null is neither, so a caller-forged revoked_reason is never handed back and hookWrittenKeys carries the key past the static readonly strip. Measured by the dev (m1) and pinned on the real chain (pin 2, the admin batch, the system un-revoke).
    • Rule 4, never clear on a re-revoke. isUnrevoke is false, the handler returns before any write, and the caller's revoked_reason never reaches the row: D2 at priority 10 deletes every non-whitelisted key from input.data on every user-context face, and on ordinary faces the engine's pre-hook hide withholds it even earlier. Pin 4 drives revoke, a forged revoked_reason: null beside revoke, then restore. RIGHT.
    • Rule 5, system never refused. isUserContextWrite is the D2 predicate verbatim (Boolean(session?.userId) && session?.isSystem !== true; buildSession propagates isSystem), so an isSystem write skips the refusal; a write that leaves revoked alone returns before anything. RIGHT. The skipAutomations flag an import sets skips only metadata-bound hooks (entry.meta), never this code-registered one.
  4. The registration (auth-plugin.ts). In the same kernel:ready block, after registerIdentityWriteGuard, with a distinct packageId. RIGHT: the engine sorts hook entries ascending by priority ("lower runs first"), so D2 (10) always precedes the guard (15) regardless of registration order; the pin boots start() and asserts one beforeUpdate on sys_api_key at a priority greater than D2's 10. No other code-registered beforeUpdate hook targets sys_api_key; the two global ones (approvals lifecycle at default 100, member-role canonicalization at 5 scoped to sys_member) write neither revoked nor revoked_reason.

  5. API_KEY_REVOKED message (core api-key.ts). Branches on row.revoked_reason; row is any from an unprojected system ql.find('sys_api_key', { where: { key }, limit: 1 }), so the column arrives. Code, status, keyId/userId and ordering unchanged; the reason-row text names only a platform administrator and POST /api/v1/keys. RIGHT; pinned in core (pin 6) and end to end through resolveApiKeyAdmission in the guard test. The error catalog's API_KEY_REVOKED entry moves with it.

  6. Public surface. registerApiKeyRestoreGuard and API_KEY_RESTORE_GUARD_PRIORITY are not re-exported from plugin-auth's index.ts; FORCED_RESET_REVOKE_REASON keeps its value; no packages/spec change, no new route, permission set, role or error code. The new census row 26b anchors the guard's one isSystem read site (check:system-context-census green).

Security — can a non-platform-admin user-context write clear revoked on a reset-revoked key, or clear or forge revoked_reason? No, on every face I could reach:

  • Direct PATCH. PATCH /api/v1/data/sys_api_key/{id} resolves the caller's context (resolveExecCtx, never isSystem), calls p.updateData by id, the engine binds previous, D2 runs, the guard throws; mapDataError's declared-status passthrough answers 403 as it does for D2's own refusals. Pin 1.
  • multi: true. No caller face carries it: the PATCH handler passes no multi/where; /data/:object/batch and /updateMany are gated on 'bulk', absent from apiMethods. At the engine, each matched row is judged on its own previous, a throw refuses the whole batch before any write, and the clear is keyed on the payload and assigned in place, so the A multi: true update applies one hook-mutated payload to every matched row, so a transition-stamping hook corrupts rows that did not transition #14099 key-set divergence cannot fire. Three pins.
  • Import update. POST /data/sys_api_key/import with writeMode: update passes the method gate (update is open), but runImport's writeCtx spreads the CALLER's context plus skipAutomations (and preserveAudit/skipStateMachine under treatAsHistorical) and writes through p.updateData({ id: target.id, … }), the same by-id path with D2 and the guard. One nuance the PR body overstates: under preserveAudit, stripReadonlyFields keeps any non-system readonly field the caller supplies (isPreservableUnderAudit: def.system !== true), so the engine's pre-hook hide does NOT withhold a caller-sent revoked_reason on a historical import; D2 removes it, and the guard reads the reason from previous, never from the payload. Two layers on ordinary faces, one (D2) on that face; no face clears or forges it.
  • revoked_reason sent alone. Hidden by the pre-hook strip, or deleted by D2, whose editableRemaining === 0 branch answers 403 "None of the submitted fields (revoked_reason) are editable"; the guard returns early (no revoked key).
  • Owner re-revoke then restore. Rule 4; pin 4.
  • Other faces. MCP's data tools call bridge.update(object, id, data) as the authenticated principal (no isSystem anywhere in packages/mcp); the revoke_api_key/restore_api_key actions are type: 'api' client-issued PATCHes with no server-side runner (grep for type === 'api'/bodyExtra in rest, runtime and core: none); there is no skipHooks/bypassHooks option anywhere in packages. An organization_admin (wildcard modifyAllRecords) is a user-context non-platform-admin and is refused like the owner, which is the ruled narrowing, not a hole.
  • Hook ordering. Refusal and clear live in one handler, refusal first; D2 precedes it; nothing after priority 15 writes the reason on this object.

Lockout — can a platform administrator be locked out of restoring? Not a platform administrator by ADR-0068 D2 as amended on 2026-10-06: hasPlatformAdminStanding is a fail-closed projection of resolveUserAuthzGrants, answering the PLATFORM_ADMIN rung (an active unscoped admin_full_access grant under the single posture, or the verified OS_PLATFORM_OWNER_EMAIL anchor under every posture), awaited with the registered engine and the session's user id; pin 2 and the admin batch drive the grant route on the real SecurityPlugin with bootstrapPlatformAdmin's grant. One asymmetry is real and is flagged in ③-3: the reset endpoint's gate (judgePlatformAdmin → isPlatformAdminUser) admits the rung OR the legacy user.role === 'admin' scalar, while the guard admits the rung only.

System context — can the mint, the last_used_at stamp or the reset be refused? No. The mint is a single ql.insert with isSystem: true (keys.ts:297), which beforeUpdate never sees; stampAdmittedKey writes last_used_at/expires_at only under isSystem: true, so the guard returns before the context test; the reset writes revoked: true, not an un-revoke. The only non-test writer of revoked: false in packages is the mint's insert, so row 26b's "no system writer restores a key" is measured, not assumed. Pinned by the system-context case.

Non-blocking observations (no action owed on this PR): (i) on a multi-row admin un-revoke, D2 at priority 10 sees the previous row's hook-written revoked_reason: null in the shared payload on rows 2..N, deletes it and logs a "stripped non-whitelisted field(s) … revoked_reason" warning before the guard re-adds it; behaviour is correct (pinned: every row clears) and the face is engine-only, so the diagnostic is noise, not a defect. (ii) ADR-0092 D2's prose says "import's engine calls … bypass the guard unchanged"; the import runner as it stands carries the caller's context, so the guard applies. That is a docs/ADR drift observation on a Tier H surface, not this PR's, named for the seat.

② Semver level

.changeset/23993-reset-revoked-key-restore.md: @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/core at minor, a ! title with a BREAKING banner, Clause-②: yes (narrowing), and one ADR-0087 marker not-required (no-migration-prescription); the PR body carries the same Clause-② line. By clause2-line.mjs's arm grammar yes (narrowing) is "a diff that widens one surface and narrows another"; by AGENTS.md yes takes at least minor and (narrowing) is BREAKING, shipped as minor under the launch-window convention (check:changeset-no-major, check:adr-0087-registration, check:empty-changeset green under Check Changeset / Lint & Repo Gates). The grades match what the diff publishes: platform-objects widens (one field, four bundles), plugin-auth narrows (the write door) and gains no public export, core changes one message's text with no signature change. No other released package publishes anything from this diff; @objectstack/rest, @objectstack/runtime and @objectstack/spec are untouched. The #23287 pre-changeset is not edited; its sentence stands and this changeset narrows "an administrator" to a platform administrator, consistent with triage's reading. The ADR-0087 disposition is right: nothing authorable is removed or renamed, and the refused call's remedy is a person or a new key, not a rewrite. The migration prose the convention asks of a breaking changeset is present ("Refused now / Unchanged / Keys revoked by a forced reset before this release"). Level and arm: RIGHT.

③ Boundary flags

  1. A key the owner revoked BEFORE the forced reset is not stamped (dev's acceptance note). Answer: correct as built and truthful by the stamp's own semantics (the reset did not revoke that key), and inside the ruled text, which speaks of keys "a routine reset revoked". The residual (an attacker holding the owner's later session could restore a key the owner had parked) is bounded exactly as triage bounded P1: the reset also ends every session, so only the legitimate owner's later sign-in reaches the door. Whether the reset should also stamp already-revoked keys is a product decision this card does not carry. ESCALATED to the domain:engine seat as a follow-up card candidate (security, not blocking), not to the maintainer: no ruling text is contradicted.

  2. The Restore API Key action still renders for the owner on a reset-revoked key (dev's acceptance note). Answer: accepted; the server refuses with the road, and the action's type: 'api' PATCH has no server-side runner, so nothing beyond courtesy is missing. A visible predicate would need the row's revoked_reason beside the caller's standing and is UI work. ESCALATED to the seat as a low-priority follow-up (platform-objects action predicate, with objectui if the console needs it).

  3. Reset gate vs restore gate. POST /api/v1/auth/admin/set-user-password is gated by judgePlatformAdmin, which admits isPlatformAdmin === true (the rung) OR the legacy role === 'admin' scalar; the restore guard admits the rung only. A legacy-scalar-only administrator can therefore stamp keys they cannot restore; such a user is not a platform administrator under ADR-0068 D2 (2026-10-06 note: gates judge by the rung), the restore side is the narrower of the two, and the owner's replacement road stays open, so this is not a hole and not a lockout of a platform administrator. It does make the 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 sentence "an administrator can restore" false for that one back-compat population. ESCALATED to the seat: either accept (the legacy fallback is documented back-compat and the claim named hasPlatformAdminStanding) or carry a follow-up to align the reset gate to the rung. Not blocking.

  4. Pre-existing rows (changeset and PR body): keys a forced reset revoked on @objectstack/plugin-auth@18.0.0-next.2 or a main build since 2026-10-14 carry no reason and stay owner-restorable; no backfill because the reset's audit row records counts, not ids. Answer: accepted; the population is prerelease-only and the changeset says so to consumers. No decision owed.

  5. The duplicated isUserContextWrite predicate (dev's note; D2's copy is module-private). Answer: accepted; the census row 26b anchors the new read site, and the two bodies are byte-equivalent. Exporting D2's copy would widen plugin-auth's surface for one caller; either way is fine.

  6. Deviation: content/docs/permissions/system-context.mdx row 26b outside the claimed surface. Answer: declared by amendment 2 to domain:devx; the row names the one elevation read and what is gained and lost, and check:system-context-census is green. Accepted.

  7. ADR-0092 D2 prose vs the import runner (①, observation ii). ESCALATED to the seat for the maintainer's attention at their discretion (Tier H surface); not this PR's.

CI on the head at read time. 26 check-runs success, 5 skipped, 8 still in_progress: Build Core; Test Core (1/6), (2/6), (3/6), (4/6), (5/6), (6/6); Type Check · workspace. Concluded green among the required contexts: Lint & Repo Gates, Governed Surface Queue Guard, Check Changeset, Type Check · source gates / debt ledger / consumer gates, Build Docs. The eight unconcluded checks are not judged here; their conclusions are the gate verdicts and the queue waits for them. The PR touches no governed surface (file list), so no Tier H word is owed.

Implemented-by: claude/issue-23993-reset-revoked-key-restore
Reviewed-by: session_01TQLy3Yq6vF4SDpTXdr3Uwi

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ✅ ACCEPT: PR #24044 at f782520bf2 · domain:engine seat 2 · session_01TQLy3Yq6vF4SDpTXdr3Uwi · 2026-10-16T21:27Z

Basis:

Checklist

  • Shape. Draft, base main, Fixes #23993 on line 1. Clause-②: yes (narrowing) is on its own line, once, in the body and in the changeset. No other number carries a closing verb.
  • Why yes (narrowing). sys_api_key gains a published field (revoked_reason, readonly select, one option), which widens. The update door refuses a non-platform-administrator's un-revoke of a key a forced reset revoked, which narrows. platform-objects, plugin-auth and core go minor under a BREAKING banner. No packages/spec change, no new route, permission set, role or error code. ADR-0087 not-required (no-migration-prescription): nothing authorable moves, and the refused call's remedy is a person or a new key.
  • Ruling C′ is honoured. Only a platform administrator (hasPlatformAdminStanding, the ADR-0068 D2 rung, fail-closed) restores a key the reset revoked. The owner's road is a new key through POST /api/v1/keys. The owner's own revocation stays owner-restorable. The API_KEY_REVOKED message names the road for the reason-row, and its code, status and fields are unchanged; the error catalog moves with it.
  • The guard fails safe. It is one beforeUpdate hook at priority 15, after D2 at 10 (the engine sorts by priority, so registration order cannot reorder them). An absent previous refuses. A system-context write is never refused (the D2 predicate verbatim; census row 26b anchors the one isSystem read). The reason clears on every admitted un-revoke and never on a revoke. The contract record traced every face that can reach the door and found no path that clears revoked or clears or forges revoked_reason from a non-administrator user context: PATCH, multi, import (including treatAsHistorical), reason-alone, re-revoke-then-restore, MCP, the two api actions, batch.
  • Evidence. Five ablation legs, each red on its own pins and restored to HEAD with proof: L1 drop the refusal (3 of 11), L2 drop the clear (3 of 11), L3 drop the stamp (7 of 68), L4 force the message branch (1 of 22), L5 drop the registration (1 of 11). The CONTROL (pin 3) stayed green on every leg. Full suites: plugin-auth 1753 + 1202, platform-objects 1136, core's api-key files 61, re-run on the merged head.
  • Gates. dispatch-gates --commands at the head: 102 derived, 102 run, every exit 0, 0 NOT MEASURED.
  • Landing checks. check-governed-merges --pr 24044: NOT governed, 670 changed lines. git merge-tree against current origin/main (605aa0172) is clean.

Correction to the PR body, for the record (not a code finding): item 3 says a caller-sent revoked_reason is withheld "by the engine's readonly strip and by D2". On a treatAsHistorical import, preserveAudit keeps a non-system readonly field, so only D2 removes it there. The guard reads the reason from previous, never from the payload, so no face clears or forges it.

Out of scope, one line each (carrier: none, noted, not filed):

  • A key the owner revoked before a forced reset is not stamped, so its owner can restore it after the reset. The stamp stays truthful (the reset did not revoke it), and whether the reset should stamp already-revoked keys is not decided here.
  • The Restore API Key action still renders for the owner on a reset-revoked key. The server refuses it and names the road; a visible predicate would be UI courtesy only.
  • The forced reset's gate (isPlatformAdminUser) also admits the legacy role === 'admin' scalar, kept deliberately so a pre-D2 deployment is not locked out. The restore guard admits the rung only. A scalar-only administrator can therefore stamp keys it cannot restore. The owner's new-key road stays open, and no platform administrator is locked out.
  • content/docs/permissions/authentication.mdx says revoked keys "stay revoked after the password change" without naming who restores one. That is a gap, not a false sentence.
  • ADR-0092 D2's prose says import's engine calls bypass the guard, while the import runner carries the caller's context. That is a drift on a governed surface, not this PR's → the round report.
  • Keys a forced reset revoked on 18.0.0-next.2 or a main build since 2026-10-14 carry no reason and stay owner-restorable (no backfill: the reset's audit row records counts, not ids). The changeset says so.

Gate on the head: ci-failure.mjs --pr 24044: 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 the standard roster, each gate unmet on this diff. Build Docs ran, because two docs pages changed, and passed.

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


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 16, 2026 21:28
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 16, 2026 21:28
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit e246f06 Oct 16, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23993-reset-revoked-key-restore branch October 16, 2026 22:00
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/l tests tooling

Projects

None yet

2 participants