Repository navigation
fix(platform-objects): Setup offers Ban/Unban, Revoke/Restore and Unlock by the record's state - #24921
Conversation
…ock by the record's state Claude-Session: https://claude.ai/code/session_01VXimSk38NSs68ntncNxKS8 Co-authored-by: Claude <noreply@anthropic.com>
…bility Claude-Session: https://claude.ai/code/session_01VXimSk38NSs68ntncNxKS8 Co-authored-by: Claude <noreply@anthropic.com>
…tion-state-visibility
…e pins Claude-Session: https://claude.ai/code/session_01VXimSk38NSs68ntncNxKS8 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
Contract reviewServed-tier: Scope: the net diff against the merge base ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 ·
|
| Item | Reading |
|---|---|
| Shape | Draft PR → main. The body opens Fixes #24760. 5 files, +190/−9. Not governed: check-governed-merges matched 0 of 5 paths. |
| Scope | Ban / Unban switch on banned, Revoke / Restore on revoked, and Unlock is offered when locked_until is set or two-factor is on. The sys_user actions keep their platform-admin standing first. Labels, texts, targets, params and every route's accept set are unchanged. |
| Seat's own read | objectui main's listViewPredicates harvests the record. operands of the object's whole action set into the grid's $select, so the default Users view fetches banned without showing it. runtime's action dispatch documents visible as unread. git merge-tree against today's origin/main is clean. |
| Review | TIER_DEFAULT, in-seat. Record 6294007753 is PASS on this head. |
| Declaration | One changeset, @objectstack/platform-objects patch, Clause-②: no (an action's offering: …) at a line start, as on the claim and the PR body. No accept set, served shape, export or key moves. |
| Cross-lane | None: packages/platform-objects is this lane's. objectui and plugin-auth were read only. No objection on the card or the seat posts. |
| Gates | Dev at 2740711c4: 66 derived, 66 run, all exit 0. platform-objects 1232 passed, plugin-auth 3277 passed, core test:repo 82 passed. Ablation: two legs, each red, restored to HEAD. |
| CI | At this read on 2740711c4a: ci-failure GREEN, all 41 check-runs completed, none failed, the six Test Core shards included. |
Disposition:
open_questions: Unlock A, with the cost named in the record: Unlock stays offered on a two-factor account. Ban A: it follows the row as stored, so Unban stays offered on a timed ban until better-auth's lazy clear.deviations: all accepted (record ③).out_of_scope_findings: a lock that expires while the page is open is not re-judged until the row is re-read; it meets no filing gate.
Landing: ordinary queue (pr_ready + automerge_enable through the relay). Fixes #24760 then closes the card.
Generated by Claude Code
Fixes #24760
Clause-②: no (an action's offering:
visibleis judged in the console, and no server door reads it, so no accept set or served shape moves; if the dev measures a door that does, the dev stops, and the seat re-declares)Dev session
session_01VXimSk38NSs68ntncNxKS8, dispatched by thedomain:engineseat 1 (session_01JtSQQ3EWsbNPzyYMug1tXy) under claim 6292572117.What changes
Five Setup actions judged only the caller's standing, so both halves of each pair were offered on every row. Each now also reads the record's state, in the form of
sys_oauth_application's Disable / Enable pair (has(record.X) && record.X != trueand== true):visible(authored)sys_user.ban_usercurrent_user.isPlatformAdmin == true && has(record.banned) && record.banned != truesys_user.unban_usercurrent_user.isPlatformAdmin == true && has(record.banned) && record.banned == truesys_user.unlock_usercurrent_user.isPlatformAdmin == true && ((has(record.locked_until) && record.locked_until != null) || (has(record.two_factor_enabled) && record.two_factor_enabled == true))sys_api_key.revoke_api_keyhas(record.revoked) && record.revoked != truesys_api_key.restore_api_keyhas(record.revoked) && record.revoked == truerequiresFeature: 'admin'still AND-composes onto the threesys_userpredicates at parse time. Labels, texts, targets, params and every other key are unchanged, so no translation bundle moves.Read before the edit
Row context. On a list row (
list_item), objectui'sObjectGridbuilds$selectfrom the view's columns plus everyrecord.FIELDthat any of the object's actions reads invisible/disabled. The read islistViewPredicates({ objectActions: resolvedSchema.actions })thencollectPredicateFieldRefs, inpackages/core/src/utils/predicate-fields.tsandpackages/plugin-grid/src/ObjectGrid.tsxat the pinned.objectui-sha616df659. Only fields the object declares are kept. So the default Users view (name,email,created_at) still carriesbanned,locked_untilandtwo_factor_enabled, and the API Keys views carryrevoked(also a column there). On the record header (record_header),RecordDetailView/DetailViewcallfindOnewith no$select, which returns the whole row. The console judgesvisiblethrough@objectstack/formula's engine (fieldRules.ts,ExpressionEvaluator). No objectui change is needed. This is a code reading; I did not boot a showcase, because the code left no doubt.Who reads
visible. No server door reads it.packages/runtime/src/action-execution.tssays so for declarative actions ("Deliberately unread; pinned as unread"), anddomains/actions.tssays it for the dispatch door ("rendering predicates that no dispatch door reads"). The MCPlist_actionsfilter is a system-object filter, not an action'svisible. Five other sites were read too, and none of them readsvisible: the ban, unban and unlock routes (admin-ban-endpoints.ts,unlockUser, the plugin-auth routes), and thesys_api_keyPATCH(api-key-restore-guard.ts, the identity write guard).Clause-②: nostands.States.
banned/ban_expires: better-auth 1.7.3 (plugins/admin/admin.mjs, the session-create hook) clearsbannedonly when a session is next created afterban_expires. Until the user signs in again, the row still saysbanned: true, so Unban stays offered for as long as the row says banned.locked_until(ADR-0069 D2): the CEL engine can compare it withnow()(measured: an ISO string compared withnow()gives the right boolean). I chose not to compare it, becauseunlockUserclears two stages. One is this row'slocked_until; the other issys_two_factor.locked_until, which the user row does not carry. Gating onlocked_untilalone would take away the only early release of a second-factor lock. An expired stamp also leavesfailed_login_countat the threshold, so one more failure re-locks the account, and Unlock is what resets the counter. So Unlock is offered whenlocked_untilis set or when two-factor is enabled. It is hidden on a plain account with neither, which is the case the card reports.revoked/revoked_reason: only a platform administrator may restore a key that a forced password reset revoked. That gate is the server's (api-key-restore-guard.ts, abeforeUpdatehook), and it stays there. The predicate offers Restore on every revoked key.Verification
All on
2740711c4(maina7199ef97merged in), unless noted.packages/platform-objects/src/identity/action-state-visibility.test.ts, 11 tests. They evaluate each parsed predicate throughcelEngine, on a banned, unbanned and null row, a row without the field, and a non-admin caller. The same is done for Unlock and the API key pair. Theplatform-objects.test.tslowering matrix rows for the threesys_useractions move to the new sources.HEADwithgit hash-objectandgit diff HEADcame back empty.a8b44d40. Result: 2 files failed. The new pin file fails at collection, because baserevoke_api_keyhas novisible. The 3 lowering rows also fail.sys-user.object.tsreverted. Result: 5 of 11 tests failed: Ban/Unban not-banned, banned, unprojected; Unlock no-lock; Unlock unprojected.pnpm --filter @objectstack/platform-objects typecheck: exit 0. The first run exited 1 on 4 TS2345 errors in the new test file; I fixed them by typing the principal.pnpm --filter @objectstack/platform-objects test: exit 0, 82 files, 1232 tests.pnpm --filter @objectstack/plugin-auth test: exit 0, 171 files, 3277 passed, 10 skipped.pnpm --filter @objectstack/core test:repo: exit 0, 6 files, 82 tests.platform-admin-affordance-standing.test.tsandaction-predicate-sparse-face.test.ts: green. The standing leg stays, and every newrecord.*read opens withhas().node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon2740711c4: 66 commands, all exit 0.--ran: "66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN".check:dual-build-cjs-loadsandcheck:i18nfirst exited 3 (PREREQUISITE NOT MET). Afterpnpm buildthey were re-run and exited 0.check:type-check-debtran in anohupslice waited on by PID, and exited 0.--shallow-since=2026-07-13before the run.Pin sweep
The five action names and their
visiblestrings were swept across every package,qa/,docs/qa/platform-checklist/andcontent/docs/. The only pins that move are the threeplatform-objects.test.tslowering rows. Everything else is unaffected:api-key-lifecycle.test.ts,api-methods-batch-conformance.test.tsand theidentity-auth.jsonchecklist items: they issue the PATCH directly and never readvisible;content/docs/protocol/objectui/actions.mdx: itsban_userexample shows onlyrequiresFeature;action-execution-destructive.test.ts: they do not readvisible.No
docs/qa/platform-checklist/**edit.Acceptance notes
origin/mainmoved 3 commits after the merge (eee65afc8,e2c2355af,81e6edd46: driver-sql and dogfood), none touchingplatform-objects. They were not merged.visibleon the row it holds, so a lock that expires while the page is open is not re-judged until the row is re-read.Generated by Claude Code