Skip to content

fix(platform-objects): Setup offers Ban/Unban, Revoke/Restore and Unlock by the record's state - #24921

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24760-action-state-visibility
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24760-action-state-visibility

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24760

Clause-②: no (an action's offering: visible is 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 the domain:engine seat 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 != true and == true):

Action visible (authored)
sys_user.ban_user current_user.isPlatformAdmin == true && has(record.banned) && record.banned != true
sys_user.unban_user current_user.isPlatformAdmin == true && has(record.banned) && record.banned == true
sys_user.unlock_user current_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_key has(record.revoked) && record.revoked != true
sys_api_key.restore_api_key has(record.revoked) && record.revoked == true

requiresFeature: 'admin' still AND-composes onto the three sys_user predicates 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's ObjectGrid builds $select from the view's columns plus every record.FIELD that any of the object's actions reads in visible / disabled. The read is listViewPredicates({ objectActions: resolvedSchema.actions }) then collectPredicateFieldRefs, in packages/core/src/utils/predicate-fields.ts and packages/plugin-grid/src/ObjectGrid.tsx at the pinned .objectui-sha 616df659. Only fields the object declares are kept. So the default Users view (name, email, created_at) still carries banned, locked_until and two_factor_enabled, and the API Keys views carry revoked (also a column there). On the record header (record_header), RecordDetailView / DetailView call findOne with no $select, which returns the whole row. The console judges visible through @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.ts says so for declarative actions ("Deliberately unread; pinned as unread"), and domains/actions.ts says it for the dispatch door ("rendering predicates that no dispatch door reads"). The MCP list_actions filter is a system-object filter, not an action's visible. Five other sites were read too, and none of them reads visible: the ban, unban and unlock routes (admin-ban-endpoints.ts, unlockUser, the plugin-auth routes), and the sys_api_key PATCH (api-key-restore-guard.ts, the identity write guard). Clause-②: no stands.

States.

  • banned / ban_expires: better-auth 1.7.3 (plugins/admin/admin.mjs, the session-create hook) clears banned only when a session is next created after ban_expires. Until the user signs in again, the row still says banned: true, so Unban stays offered for as long as the row says banned.
  • locked_until (ADR-0069 D2): the CEL engine can compare it with now() (measured: an ISO string compared with now() gives the right boolean). I chose not to compare it, because unlockUser clears two stages. One is this row's locked_until; the other is sys_two_factor.locked_until, which the user row does not carry. Gating on locked_until alone would take away the only early release of a second-factor lock. An expired stamp also leaves failed_login_count at the threshold, so one more failure re-locks the account, and Unlock is what resets the counter. So Unlock is offered when locked_until is 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, a beforeUpdate hook), and it stays there. The predicate offers Restore on every revoked key.

Verification

All on 2740711c4 (main a7199ef97 merged in), unless noted.

  • Evaluated pins: packages/platform-objects/src/identity/action-state-visibility.test.ts, 11 tests. They evaluate each parsed predicate through celEngine, 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. The platform-objects.test.ts lowering matrix rows for the three sys_user actions move to the new sources.
  • Ablation, run twice. Each time the restored blob was proven equal to HEAD with git hash-object and git diff HEAD came back empty.
    • Leg 1: both object files restored to base a8b44d40. Result: 2 files failed. The new pin file fails at collection, because base revoke_api_key has no visible. The 3 lowering rows also fail.
    • Leg 2: only sys-user.object.ts reverted. 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.ts and action-predicate-sparse-face.test.ts: green. The standing leg stays, and every new record.* read opens with has().
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 2740711c4: 66 commands, all exit 0. --ran: "66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN".
    • check:dual-build-cjs-loads and check:i18n first exited 3 (PREREQUISITE NOT MET). After pnpm build they were re-run and exited 0.
    • check:type-check-debt ran in a nohup slice waited on by PID, and exited 0.
    • The clone was deepened with --shallow-since=2026-07-13 before the run.

Pin sweep

The five action names and their visible strings were swept across every package, qa/, docs/qa/platform-checklist/ and content/docs/. The only pins that move are the three platform-objects.test.ts lowering rows. Everything else is unaffected:

  • the dogfood API-key lifecycle tests, api-key-lifecycle.test.ts, api-methods-batch-conformance.test.ts and the identity-auth.json checklist items: they issue the PATCH directly and never read visible;
  • content/docs/protocol/objectui/actions.mdx: its ban_user example shows only requiresFeature;
  • the confirm-dialog pins and action-execution-destructive.test.ts: they do not read visible.

No docs/qa/platform-checklist/** edit.

Acceptance notes

  • origin/main moved 3 commits after the merge (eee65afc8, e2c2355af, 81e6edd46: driver-sql and dogfood), none touching platform-objects. They were not merged.
  • A row can show an expired timed ban as banned until the user's next sign-in. Ban is then hidden; Unban and then Ban re-bans. This follows better-auth's lazy clear and is not changed here.
  • The console judges visible on 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

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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): node scripts/docs-audit/affected-docs.mjs --json 81e6edd4637dcac8fb3df84e4b031221f19dea5a → packageMentionDocs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 2740711c4a00966520c663ede66baeb2fc58d089
Local-runs: none

Scope: the net diff against the merge base a7199ef974 (5 files, +190/−9): the changeset, sys-user.object.ts, sys-api-key.object.ts, the new identity/action-state-visibility.test.ts and the moved rows of platform-objects.test.ts's lowering matrix. Read with them: objectui main's listViewPredicates and collectPredicateFieldRefs (packages/core/src/utils/predicate-fields.ts) and their use in ObjectGrid.tsx; runtime's action-execution.ts ("Deliberately unread; pinned as unread") and domains/actions.ts ("rendering predicates that no dispatch door reads"); sys-oauth-application.object.ts's Disable / Enable pair. Also the card #24760 with the claim 6292572117 and the dispatch 6292631088, the dev's os-dev-report 6293820076 (read as claims), and the PR body.

① Derived judgments

  • The three pairs: right. Each is mutually exclusive on its field, in the precedent's form (has(record.F) && record.F != true against == true):

    • ban_user / unban_user on banned;
    • revoke_api_key / restore_api_key on revoked.

    A null value reads as not banned or live, so one of the pair is offered. A row that does not carry the field offers neither, never both.

  • The row context: as the dev read it. objectui's listViewPredicates folds the object's WHOLE action set (objectActions) into the predicate harvest. ObjectGrid passes the harvested record. operands to $select, through its field-level read filter. So the default Users view (name, email, created_at) still fetches banned, locked_until and two_factor_enabled for a platform administrator. The record header reads the whole row.

  • unlock_user: accepted as built (option A). (locked_until set) || two_factor_enabled == true, after the standing.

    • The OR leg keeps the only early release of a second-factor lock. That lock lives on sys_two_factor, which the row does not carry.
    • The card's case, a plain account with no stamp and no second factor, no longer offers Unlock.
    • Not comparing with now() is right: an expired stamp leaves failed_login_count at the threshold, and Unlock is what resets it.
    • The cost is named: Unlock stays offered on any account with two-factor enabled. An exact answer needs a row-level "locked at either stage" signal, which the card forbids.
  • The standing legs stay. The three sys_user predicates still lead with current_user.isPlatformAdmin == true. The lowering matrix's three rows now carry the composed predicate under features.admin. The two sys_api_key actions had no standing leg, and none is added.

  • No server door moves. The action runtime and the actions domain both document visible as unread by dispatch. The ban, unban and unlock routes and the sys_api_key PATCH (with api-key-restore-guard.ts) are untouched. What each route accepts and answers is unchanged.

  • The pins have teeth. The 11 tests EVALUATE each predicate through @objectstack/formula's celEngine on the parsed object, and a fault fails loudly. They cover the field true, false and null, the field absent, a member without standing, a timed ban past its expiry, and a restore after admin_password_reset. The two-leg ablation's red sets match these assertions as read.

② Semver level

  • patch for @objectstack/platform-objects: right for an offering change. No accept set, served body, export, key or error code moves.
  • Clause-②: no (…) is at a line start in the changeset (line 7) and in the PR body.
  • check-governed-merges matched 0 of 5 paths; 199 changed lines.

③ Boundary flags

  • Deviation 1 (the OR leg on two_factor_enabled): accepted, as ① says.
  • Deviation 2 (the model-free trailer pair): accepted; that is AGENTS.md's form.
  • Deviation 3 (the row context read in code, not on a boot): accepted; the harvest is explicit in objectui main.
  • Deviation 4 (three later main commits not merged): accepted. git merge-tree against today's origin/main is clean.
  • Deviation 5 (none of the forbidden surfaces): confirmed. No checklist, translation, objectui or spec edit.
  • Open questions: both taken as the dev settled them. Unlock is A. Ban follows the row as stored, so a timed ban past ban_expires keeps Unban offered until better-auth's lazy clear.
  • Out-of-scope note: a lock that expires while the page is open is not re-judged until the row is re-read. That is an observation and meets no filing gate.
  • Check-runs on 2740711c4a: not concluded at this read (ci-failure UNDETERMINED, 6 still running). The ACCEPT waits for green.

Implemented-by: claude/issue-24760-action-state-visibility
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-20T15:24Z · ACCEPT

Head: 2740711c4a. It carries the five predicates, the evaluated pins, the moved lowering-matrix rows, the changeset, a merge of main and the pins' type fix.

Card #24760 (p3, bug, area:identity; from objectstack-ai/hotcrm#2188's Setup pass, F-11): claim 6292572117, dispatch 6292631088. Dev report: 6293820076.

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 20, 2026 15:26
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 20, 2026 15:26
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit d606fc1 Oct 20, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24760-action-state-visibility branch October 20, 2026 15:56
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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Setup: mutually exclusive user actions are offered together whatever the record's state (Ban with Unban, Revoke with Restore)

2 participants