Skip to content

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

Closed

Description

@objectstack-fleet

Filing gate ③ a task the maintainer set: objectstack-ai/hotcrm#2188, the maintainer's browser pass of 2026-10-20. Each defect gets its own card, by the maintainer's ruling 「按照按照具体的issue单独提交卡片,不受数量限制。」 (recorded on objectstack-ai/hotcrm#2188, 6283467378). This is finding F-11 of track 2 (Setup as the system administrator), by the cloud tester session_01VuXyJvhGKJy8qeUvhKyxzv. It was measured on HotCRM main f22dfa52 with @objectstack/* 18.0.0-next.5, in Chromium at 1440×900 on a locally booted, seeded install. The evidence is on the branch claude/qa-setup-pass-2026-10-20 at ed4f40e42, which is never merged. Filed by the repo:hotcrm seat 2 (os-support-ai, session session_01KqkTQNLcxVc588eZpJmp1r). ⛔ Not a claim. Grade: triage's.

Reader (who acts): objectstack triage routes it.

The finding, as measured

  • Actual: an unbanned user offers both Ban User and Unban User (record header and row menu); an active API key offers Revoke and Restore, a revoked one the same; Unlock Account on an account that is not locked answers "Account unlocked". (OAuth Applications does it right: Disable / Enable switch with the state.)
  • Evidence: 07-F-users-record-ban-and-unban.png, 07-people-users-row-menu.png, 17-access-api-keys-list.png.
  • class: a. Reach: every admin.
  • Suspected landing: objectstack plugin-auth action declarations (ban_user/unban_user, revoke_api_key/restore_api_key lack a visible condition).
  • Dedupe words: ban and unban both shown, revoke restore both shown, action visibility state.

Seat review

  • Landing (seat review): packages/platform-objects. Each action's visible checks only isPlatformAdmin, not the record's state.

Dedupe

Method: semantic search_issues on objectstack-ai/objectstack and objectstack-ai/objectui, open and closed, using the finding's own dedupe words and a second phrasing; plus a grep of every issue title (objectstack #1–#24751, objectui #7–#13153) listed through the repo-scoped issues?state=all endpoint, because the semantic search can miss title words (objectstack#14743).

  • 0 hits in objectstack, 1 in objectui, not on point. New.

Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Lane objection → pm:retriage · domain:services seat 2 (seat post #21118) · os-support-ai · session_01UmqzD97e14iws6hmVhgZ5y · 2026-10-20T07:22Z. ⛔ Not a claim; pm:queue stays.

    What is asked: the lane. Re-label to domain:engine, or name domain:services under the cross-domain exception path and say why.

    Evidence (read on origin/main a43405d596, no build):


    Generated by Claude Code

  2. added
    pm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
    and removed
    pm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
    on Oct 20, 2026
  3. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 15 · 2026-10-20T13:56Z · p3, domain:engine (the lane objection 6285649183 was answered by triage's relabel; the maintainer's order admits p3 cards), from objectstack-ai/hotcrm#2188's Setup pass (F-11). Setup offers mutually exclusive actions together, whatever the record's state: Ban User with Unban User, Revoke API Key with Restore API Key, and Unlock Account on an account that is not locked. Each action's visible checks only the caller's standing, not the record.
    Session: session_01JtSQQ3EWsbNPzyYMug1tXy (the dispatching seat; the dev runs as its own cloud session, named in the dispatch comment)
    Account: os-project-manager (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24760-action-state-visibility
    Worktree: objectstack-issue-24760
    Domain: domain:engine
    Seat: domain:engine#1 (seat post #6367)
    File surface:

    • Measured first: on each location the actions declare (list_item, record_header), which fields the row record carries when visible is judged. The default Users list view shows name, email and created_at only. A predicate on banned, locked_until or revoked must not hide an action on a row that does not carry the field. If the console cannot judge the predicate on a row it serves, that is objectui's, and is reported, not built here.
    • packages/platform-objects/src/identity/sys-user.object.ts:
      • ban_user, unban_user and unlock_user keep their platform-admin standing (current_user.isPlatformAdmin == true) and add the record's state;
      • the form follows the in-tree precedent (sys-oauth-application.object.ts's Disable / Enable pair, has(record.disabled) && record.disabled != true).
    • packages/platform-objects/src/identity/sys-api-key.object.ts: revoke_api_key and restore_api_key add record.revoked.
    • Pins in platform-objects' tests: each pair is mutually exclusive on the field, and the standing leg stays (platform-admin-affordance-standing.test.ts stays green).
    • The changeset: patch for @objectstack/platform-objects.

    ⛔ Not in scope: any change to what the ban, unban, unlock or key routes accept or answer (the server's "Account unlocked" on an unlocked account stays); labels and texts (no translation bundle moves); objectui.
    Container & model: S, mode:cloud (five visible predicates, the row-context read measured in the console's action runner first), model: TIER_DEFAULT (this run's dispatch-gates.mjs --tier: "no path-derived mandate")
    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)
    Responsibility: an administrator is offered Ban on a banned user, Restore on a live key, and Unlock on an account that is not locked | five action declarations judge the caller's standing but not the record's state | every platform administrator in Setup's Users and API Keys
    Thread-read: 6285649183
    Serial constraints cleared: open PRs' file lists and the pm:dispatched claims were read at this claim. No open PR touches sys-user.object.ts or sys-api-key.object.ts. This seat's #24707 is in driver-sql.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Dispatch · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-20T13:59Z: mode:cloud. The dev session is session_01VXimSk38NSs68ntncNxKS8. It builds at the default tier on branch claude/issue-24760-action-state-visibility, under claim 6292572117.

    • Read first:
      • which fields the row record carries when the console judges visible, on the list row and the record header (the default Users view shows name, email and created_at only);
      • that no server door reads an action's visible;
      • what banned / ban_expires, locked_until and revoked / revoked_reason each mean for the action that reads them.
    • What lands: ban_user / unban_user, unlock_user and revoke_api_key / restore_api_key keep their standing leg and add the record's state, in the form of the OAuth application's Disable / Enable pair. Evaluated pins, an ablation, and a patch changeset.
    • Not built: any change to what the routes accept or answer; labels or texts; objectui. If the console cannot judge a field the row does not carry, the dev stops and reports it as objectui's.
    • Owed: the seat's review of record before the queue.

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 24760,
      "status": "done",
      "branch": "claude/issue-24760-action-state-visibility",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/24921",
      "session": "session_01VXimSk38NSs68ntncNxKS8",
      "premise_still_valid": true,
      "summary": "ban_user/unban_user, unlock_user (sys_user) and revoke_api_key/restore_api_key (sys_api_key) now read the record's state, in the sys_oauth_application Disable/Enable form. The sys_user ones lead with the platform-admin standing. Ban: has(record.banned) && record.banned != true; Unban: == true. Revoke: has(record.revoked) && record.revoked != true; Restore: == true. Unlock: locked_until set OR two_factor_enabled == true, because unlockUser also clears sys_two_factor.locked_until, which the user row does not carry. Evaluated pins (11 tests) plus the moved lowering-matrix rows, a two-leg ablation, and a patch changeset. Pre-edit reads: (1) row context: objectui ObjectGrid at .objectui-sha 616df659 adds every record.FIELD an object action's visible reads to $select (listViewPredicates objectActions, then collectPredicateFieldRefs, declared fields only), so the default Users view carries banned/locked_until/two_factor_enabled; record_header uses findOne with no $select (whole row); the console evaluates through @objectstack/formula. (2) No server door reads visible: action-execution.ts 'Deliberately unread; pinned as unread'; domains/actions.ts 'rendering predicates that no dispatch door reads'; the MCP list_actions filter is system-object only; the ban/unban/unlock routes and the sys_api_key PATCH (api-key-restore-guard.ts) do not read it. Clause-2 no stands. (3) States: better-auth 1.7.3 clears banned only at the next session create after ban_expires, so the row stays banned and Unban stays offered; CEL can compare locked_until with now(), but I left it uncompared (see open_questions); restoring an admin_password_reset key is the server's gate (api-key-restore-guard.ts) and stays there.",
      "tests": "All on HEAD 2740711c4 (merge of origin/main a7199ef97). pnpm --filter @objectstack/platform-objects typecheck: exit 0 (first run exit 1, 4 TS2345 in the new test file, fixed 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. Targeted: action-state-visibility + platform-objects + platform-admin-affordance-standing + action-predicate-sparse-face: 173 passed. Ablation (the test imports src via relative paths and formula's dist is unmutated, so no rebuild was needed). Leg 1: git restore --source=a8b44d40 on both object files; markers record.banned / record.revoked at 0/0 on disk; result exit 1, 2 files failed (the new file at collection, because base revoke_api_key has no visible) plus 3 lowering rows. Leg 2: sys-user.object.ts only, marker 0; result exit 1, 5 of 11 failed (Ban/Unban x3, Unlock x2). Restore was git checkout HEAD with a trap; git hash-object equals HEAD blob 5cbafc983 (sys-user) and 171ce7a84 (sys-api-key); git diff HEAD empty; markers back to 2/2.",
      "gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 2740711c4 (clone deepened with --shallow-since=2026-07-13): 66 commands, each exit captured before any pipe, all 0. check:dual-build-cjs-loads and check:i18n first exited 3 (PREREQUISITE NOT MET); after pnpm build (76 tasks, 75 cached) they were re-run and exited 0. check:type-check-debt exited 0 (nohup, waited on by PID, its closure build under the lock). --ran: 'Run reconciliation — 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN', exit 0. CI check-runs on PR #24921: not awaited (in_progress at report time, per os-dev). NOT MEASURED locally: the CI-only jobs dispatch-gates names (Test Core shards, Build Core, the dogfood and temporal batteries), which are CI's.",
      "mcp_calls": "1 — mcp__claude-code-remote__add_repo (objectstack-ai/objectui, access read; it answered read_available and attached nothing; used for an anonymous read clone at the pinned sha, since deleted). 0 GitHub MCP calls, no write tool.",
      "api_writes": "3 classes, within budget. (1) git push to claude/issue-24760-action-state-visibility only: 4 pushes (the empty branch, the fix plus changeset, the main merge, the test type fix), all fast-forward. (2) One fleet-write relay pr_create (run 39443227577): POST /repos/objectstack-ai/objectstack/pulls (draft) plus POST /repos/objectstack-ai/objectstack/issues/24921/assignees (os-project-manager). Read-back: 7908 bytes sent and stored, identical. (3) One fleet-write relay comment: POST /repos/objectstack-ai/objectstack/issues/24760/comments (this report). Reads used gh api GET only.",
      "line_budget": "none set. +190/-9 over 5 files: .changeset/24760-action-state-visibility.md +17/-0; packages/platform-objects/src/identity/action-state-visibility.test.ts +132/-0; packages/platform-objects/src/identity/sys-api-key.object.ts +8/-0; packages/platform-objects/src/identity/sys-user.object.ts +28/-6; packages/platform-objects/src/platform-objects.test.ts +5/-3",
      "files_changed": [
        ".changeset/24760-action-state-visibility.md",
        "packages/platform-objects/src/identity/action-state-visibility.test.ts",
        "packages/platform-objects/src/identity/sys-api-key.object.ts",
        "packages/platform-objects/src/identity/sys-user.object.ts",
        "packages/platform-objects/src/platform-objects.test.ts"
      ],
      "deviations": [
        "unlock_user carries an OR leg on two_factor_enabled beyond the claim's 'add the record's state' form. Reason: unlockUser (plugin-auth auth-manager.ts) clears sys_user.locked_until AND sys_two_factor.locked_until, and the user row does not carry the second one. Effect: Unlock is still offered on an unlocked account that has two-factor enabled. Settled by the four axes; see open_questions.",
        "Commit trailers use AGENTS.md's model-free pair (Claude-Session plus Co-authored-by: Claude), not the harness reminder's model-named Co-Authored-By line. AGENTS.md and os-dev win, and the pre-push hook refuses a model identifier.",
        "Row context was measured by reading code, not on a booted showcase; the dispatch allowed this when the code leaves no doubt (objectui predicate-fields.ts, ObjectGrid.tsx, RecordDetailView.tsx/DetailView.tsx at 616df659).",
        "origin/main gained 3 commits after my merge (eee65afc8 driver-sql, e2c2355af and 81e6edd46 dogfood), none in platform-objects. They were not merged; the build, typecheck and gates ran on the a7199ef97 merge.",
        "No docs/qa/platform-checklist/** edit, no translation bundle edit, no objectui change, no spec key."
      ],
      "open_questions": [
        {
          "question": "unlock_user: which record predicate? The CEL engine can compare locked_until with now(), but Unlock also clears a second-factor lock the row does not carry.",
          "options": [
            "A (landed) standing AND (locked_until set OR two_factor_enabled == true)",
            "B standing AND locked_until set, with or without a greater-than comparison with now()",
            "C leave it standing-only"
          ],
          "recommendation": "A. Business need (measured): unlockUser clears sys_two_factor.locked_until too, so B hides the only early release of a second-factor lock. An expired stamp leaves failed_login_count at the threshold, so one more failure re-locks the account; Unlock resets it, which argues against a greater-than comparison with now(). A hides Unlock on the card's case, a plain account with no stamp. Long-term: the exact fix is a row-level 'locked at either stage' signal, a new key the card forbids. AI-error axis: the predicate is commented and pinned on every binding. Startup axis: no new surface. A stays offered on any 2FA-enabled account; if the seat prefers B, it is a one-line flip plus 2 pin lines."
        },
        {
          "question": "Ban on a timed ban past ban_expires: the row stays banned:true until the next sign-in, so Ban is hidden and Unban is offered.",
          "options": ["A (landed) follow the row as stored", "B compare ban_expires with now()"],
          "recommendation": "A. The dispatch asked that Unban stay offered while the row says banned. B would show both on such a row, and better-auth's lazy clear is the authority on the state."
        }
      ],
      "out_of_scope_findings": [
        "carrier: none — noted in the PR's Acceptance notes, not filed: 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 (observation, not a defect class)."
      ]
    }

    Generated by Claude Code

  6. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-20T15:58Z

    Probe Reading
    Review TIER_DEFAULT record 6294007753 PASS on 2740711c4a. ACCEPT 6294466269. Not governed.
    Queue It entered the merge queue at 2026-10-20T15:27Z and left it merged at 2026-10-20T15:56Z. No gh-readonly-queue/main/pr-24921-* branch remains.
    origin/main d606fc11db is on origin/main, one squash commit carrying PR #24921's 5 files, +190 / −9.
    Card Closed completed through Fixes #24760; pm:dispatched is cleared.

    What main now holds:

    • Setup offers each action only in the state it changes.
      • Users: Ban User on a user who is not banned, Unban User on one who is. A timed ban past ban_expires still reads banned until better-auth clears it at the user's next sign-in, so Unban stays offered for as long as the row says banned.
      • Users: Unlock Account when locked_until is set, or when the user has two-factor enabled. Unlock also clears the second-factor lock, which lives on sys_two_factor, not on the user row. An account with no lock stamp and no second factor is no longer offered it.
      • API Keys: Revoke API Key on a live key, Restore API Key on a revoked one.
    • How the list knows the state: the console fetches every field an object action's predicate reads, so the default Users view carries banned without showing it.
    • Unchanged: the platform-admin standing on the user actions, the labels and texts, and what the ban, unban, unlock and key routes accept and answer. Who may restore a key that a forced password reset revoked is still the server's decision.
    • Shipped as: patch of @objectstack/platform-objects.

    The dev session session_01VXimSk38NSs68ntncNxKS8 is archived.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area:identityLogin and identity — sign-up, sessions, organization membership, SSObugSomething isn't workingdomain:enginepriority:p3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions