Skip to content

Approvals delegation: the form lets an admin pick any delegator, the server refuses with 403, and the UI says nothing #24764

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-14 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

  • Persona / URL: dev admin, /sys_approval_delegation → New; delegator Priya Raman, delegate Tomas Okafor.
  • Expected: the empty state "Declare an out-of-office delegation so approvals route to a backup while you are away" plus a Delegator field listing every user.
  • Actual: POST /api/v1/data/sys_approval_delegation → 403 "you may only manage out-of-office delegations where you are the delegator"; no toast, no field error, the dialog just stays. With the admin as delegator it saves (201).
  • Evidence: 21-F-delegations-create-403.png.
  • class: a. Reach: admins covering an absent employee.
  • Suspected landing: objectstack plugin-approvals (delegation write rule) and console form (silent 403).
  • Dedupe words: delegation 403 delegator, OOO delegation admin, silent forbidden.

Seat review

  • Landing (seat review): the delegation form's delegator field (plugin-approvals metadata) offers people the server will refuse. The console's silent swallow of the 403 is objectui's half; triage may split it.

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).

  • 1 + 6 hits in objectstack, 0 in objectui, none on point. New.

Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-20T07:16Z
    Session: session_01UmqzD97e14iws6hmVhgZ5y
    Account: os-support-ai (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24764-delegation-delegator-field
    Worktree: objectstack-issue-24764
    Domain: domain:services
    Seat: domain:services#2 (seat post #21118)
    File surface: packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts (the delegator_id declaration and the object's form/create surface), its tests, one new .changeset/24764-*.md; packages/plugins/plugin-approvals/src/lifecycle-hooks.ts (bindDelegationWriteGuard) read-only unless the measured fix needs it. ⛔ Not approval-service.ts (seat 1's #24753), ⛔ not packages/spec/**, ⛔ no objectui edit, ⛔ the write guard's refusal is not widened (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus (TIER_DEFAULT; dispatch-gates --tier on this surface: "no path-derived mandate · floor sonnet · default opus · ceiling fable")
    Clause-②: no
    Responsibility: plugin-approvals' sys_approval_delegation.delegator_id is a free sys_user lookup, while bindDelegationWriteGuard admits only the caller | the guard already stamps the caller when delegator_id is omitted, and an admin acts for an absent approver through the audited override (reassign / recall / reject) | admins opening Approvals → Delegations → New; measured on a HotCRM install 2026-10-20 (card)
    Thread-read: none
    Serial constraints cleared: no open PR touches sys-approval-delegation.object.ts or lifecycle-hooks.ts (the file lists of all 18 open PRs read at this claim). Seat 1's #24753 edits plugin-approvals/src/approval-service.ts only (its claim's file surface), a different file. #23256 (epic, pm:blocked) names expandPositionUsers, not touched here. #24751 (p3, remind()) is not dispatched.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 24764,
      "status": "done",
      "branch": "claude/issue-24764-delegation-delegator-field",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/24834",
      "session": "session_01UmqzD97e14iws6hmVhgZ5y (mode:subagent, the dispatching seat's session; identity = the branch)",
      "premise_still_valid": true,
      "summary": "sys_approval_delegation.delegator_id now declares lookupFilters [{ field: 'id', operator: 'eq', value: '{current_user_id}' }]. The Delegator picker in the console at the pin (objectui 12dc7b47c3, LookupField via lookupFiltersToRecord) now offers only the signed-in user, which is the one delegator bindDelegationWriteGuard accepts; the engine resolves the placeholder on every read (resolveWhereTokens). The guard, required and what a write may store are unchanged, and there is no spec, objectui or consumer edit. Landing as expected: packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts. M3 (ii), defaultValue 'current_user', is honoured by the console. Measured on the real engine, it changes exactly one accept-set cell (a system-context insert that carries a userId and omits delegator_id: refused by required before, stored after). Per M4 and the claim's Clause-2 no, it was dropped from the net diff and is raised in open_questions (commit 2cdb9a975 added it, commit 49c6d958f removed it).",
      "tests": "All at final head 529f78b82 (after merging origin/main 307ce4c4b). (1) pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2: 'Test Files 87 passed (87) / Tests 1156 passed (1156)', TEST_EXIT=0 (os-verify-lock VERDICT command-exit 0). (2) pnpm --filter @objectstack/plugin-approvals typecheck: TYPECHECK_EXIT=0, 'check:test-typecheck: OK ... 8 file(s) / 324 error(s) / 27 pinned signature(s)' (ledger unchanged). (3) New sys-approval-delegation-delegator-picker.test.ts, verbose: 9/9 green; it runs a real ObjectQL over SqlDriver (better-sqlite3 in-memory) with the guard bound, and pins the declaration (object and registered body), the picker candidate query returning [caller] for each of 3 callers, the guard accepting every offered candidate, a create or update naming another delegator refused with code FORBIDDEN / statusCode 403 and nothing stored, and required staying. The sibling suites (display-title, object, approval-service, admin-exemption-retired): 5 files / 405 tests green. (4) Ablations, run through node scripts/ablation-replace.mjs (anchor x1 then x0, blob changed on disk, run, restored with blob == HEAD and git diff HEAD empty; the subject is imported from src by relative path, so no dist is involved). A1 delete the lookupFilters line: 'Tests 6 failed | 3 passed', with picker 'expected [ u_admin, u_priya, u_tomas ] to deeply equal [ u_admin ]' and declaration 'expected undefined to deeply equal [ { field: id ... } ]'. A2 guard 'if (false && has && supplied !== userId) deny();': 'Tests 2 failed | 7 passed', both refusal pins 'promise resolved ... instead of rejecting'. A3 required: false: 'Tests 2 failed | 7 passed'. Direction red as predicted in all three. (5) A measurement only, never committed: a scratch test inserting with delegator_id omitted. With the default, system+userId gave 'stored delegator_id=u_svc'; on the base object it gave 'refused code=VALIDATION_FAILED'. The member, anonymous and system-without-userId cells were identical on both.",
      "gates": "Local subset at head 529f78b82: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 68 commands, and all 68 exited 0, each exit captured before any pipe (log: scratchpad issue-24764/gates/summary.txt). --ran with 'command :: exit N' records: 'Run reconciliation — 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN' and '68 run, 0 NOT-MEASURED (a DERIVED zero — all 68 recorded an exit code and none of them is 3)'. The derivation warned STALE TREE (origin/main had moved to c3a8e07b6, 11 commits, 6 derivation-input files under scripts/pm and .github/workflows, none on this surface); re-derived in a throwaway worktree at origin/main c3a8e07b6 with the 3 files applied, the list is identical (SAME-LIST). The first battery at c0f9ccf68 had 4 non-zero: check-plugin-teardown-shape --self-test exit 3 (shallow clone, pinned fixture unreachable; fixed by a depth-1 fetch), check:dual-build-cjs-loads exit 3 and check:i18n exit 3 (PREREQUISITE NOT MET; built their closure), and check:query-options-erasure exit 1, a real finding in my own test (untyped option bags, 236 to 241 test sites; typed in 529f78b82, ratchet holds at 236). All four re-ran green at 529f78b82. Not run locally and left to CI: pnpm lint, test:repo, the cli integration tier (not touched) and the full farm. check:dev-prereqs and check:skill-frame-freshness do not apply (no packages/spec or framework file touched). CI on PR 24834 at 529f78b82, read once: filter success; No other open PR may claim the same single-writer path success; No other open PR may claim the same issue success; Check PR Size success; Part-of PR must not also close its card success; The card this PR closes must claim this branch success; Check Documentation Links success. In progress: Test Core (1-6/6), Build Core, Lint (ESLint; CI, release & engine gates; repo & tooling gates; docs & code guards; PM dispatch-gates; scoped x3), Type Check (workspace; source gates; consumer gates; debt ledger), Check Changeset, Governed Surface Queue Guard, Dogfood Regression Gate, Flag docs affected by code changes, Auto Label. Skipped by selector: Build Docs, Temporal Conformance, Dogfood Verify CLI, Console Pin Gate, 18.x upgrade harness (opt-in), Packed-tarball smoke (opt-in).",
      "line_budget": "n/a: no skills/** or governed line-ratcheted ledger is touched. Diff vs merge base 307ce4c4b: 3 files, +182 / -0 (dispatch-gates' changed-lines reading), under the 3000-line human-merge threshold.",
      "files_changed": [
        "packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts (delegator_id gains lookupFilters, plus a comment)",
        "packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts (new, real-engine pins)",
        ".changeset/24764-delegation-delegator-picker.md (new; @objectstack/plugin-approvals patch; Clause-2 no)"
      ],
      "deviations": [
        "Route narrowed from the PM's suggestion: the dispatch offered (i) to (iii). I shipped (i), lookupFilters, alone. (ii), defaultValue 'current_user', is measured to change one accept-set cell, which M4's stop condition and the claim's Clause-2 no exclude, so it is returned as a fork in open_questions. Branch history keeps it: 2cdb9a975 adds the default and 49c6d958f removes it, with no force-push.",
        "Shared .git touched: git fetch --depth=1 origin 621a487607881c66b2899b7e3477115229a156b4. check-plugin-teardown-shape --self-test refused (exit 3) because its pinned positive-control commit was outside the shallow clone. The fetch adds one shallow root and moves no ref.",
        "Merged origin/main (307ce4c4b) into the branch before opening the PR (merge commit c0f9ccf68), per AGENTS.md section 10, then rebuilt the closure. I did not merge again when origin/main moved to c3a8e07b6 (no overlap with this surface); re-derived the gate list there instead.",
        "A throwaway detached worktree /home/user/objectstack-issue-24764-derive at origin/main c3a8e07b6 was used to re-derive the gates. It was cleaned and removed without --force.",
        "A scratch test (src/zz-scratch-24764-system-cell.test.ts) measured the default's accept-set cells and was never committed or pushed, then deleted. Its base-object leg overwrote the object file with the base blob and restored it with git checkout HEAD --, verified by an equal blob hash (9cb66fd9) and an empty git diff HEAD.",
        "Attribution: commits carry the AGENTS.md model-free trailer pair, not the harness reminder's model-named Co-Authored-By. The PR body ends with the AGENTS.md session-URL footer, not the harness's emoji footer.",
        "The ablations ran inside the shared verify lock through ablation-replace WRAP mode. They are lane commitment negative-pin proofs; A2 mutates lifecycle-hooks.ts transiently, restored to the HEAD blob, and the file is unchanged in the diff."
      ],
      "mcp_calls": "0 — no MCP GitHub tool was called (reads used gh api over the proxy; writes went through scripts/pm/with-fleet.sh --via dispatch).",
      "api_writes": "2 relay strokes (fleet-write, executed as objectstack-fleet[bot]). Stroke 1, request fw-20261020T090105Z-19c63c, run 39397604349: pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft, created PR 24834) + POST /repos/objectstack-ai/objectstack/issues/24834/assignees (os-support-ai), read back identical (6594 bytes). Stroke 2: comment = POST /repos/objectstack-ai/objectstack/issues/24764/comments (this report). Each stroke is one POST /repos/objectstack-ai/objectstack/dispatches from this session. No labels were written: skip-changeset does not apply because a changeset ships. git push to the branch is not counted (not REST).",
      "open_questions": [
        {
          "question": "Should sys_approval_delegation.delegator_id also declare defaultValue 'current_user', so the create form opens on the one delegator the guard accepts instead of an empty required field whose picker lists only the signed-in user?",
          "options": [
            "A. Leave it as shipped in PR 24834: the picker filter only. The form opens empty; the picker offers the signed-in user alone; one extra click. Zero accept-set change. Cost and effort: none.",
            "B. Add defaultValue 'current_user' as a one-key follow-up with Clause-2 yes (widening) and a minor changeset. Measured at the pin: create forms seed the acting user on a sys_user lookup (plugin-form schemaDefaults.ts) and treat it as server-owned. On insert, applyFieldDefaults stamps the caller before the guard, the same value the guard stamps for every non-system caller. One cell changes: an in-process system-context insert that carries a userId and omits delegator_id is stored with that userId instead of failing required. No HTTP door produces a system context (rest data-action-routes: an HTTP request is assembled with isSystem false), and no code in this repo writes delegation rows. Effort: 1 line, 1 pin, 1 changeset.",
            "C. Keep the field off the create form (readonly or hidden). readonly strips a non-system caller's value and re-defaults it, turning today's 403 for a foreign delegator into a silent 201 for the caller: a widening plus a silent rewrite. hidden also hides the delegator from the detail page. Effort: small; cost: a silent-rewrite contract."
          ],
          "recommendation": "B. (1) Business need, measured: every self-service creator of a delegation (Setup, Approvals, Delegations, New, where the card was measured on HotCRM) gets a form already filled with its only valid value, and the one changed cell has no producer (no in-repo writer of sys_approval_delegation outside tests). (2) Long-term: the declaration then states what the runtime already does for every non-system write (the guard stamps the caller when the delegator is omitted), so declared equals enforced and the guard's stamp branch becomes defence in depth. A keeps a declaration that demands a value the server supplies itself. (3) AI-proofing: B uses the spec's existing token, which field.zod refuses on any type but user or a sys_user lookup, and adds no consumer leniency. C would teach readonly as a way to coerce identity by silent rewrite, the worst of the three. (4) Startup focus: A and B add no new surface. B is one existing key, and its widening is one in-process cell, stated in its own Clause-2 yes line rather than slipped under this card's no. If zero accept-set change is preferred, A is complete as it stands."
        }
      ],
      "out_of_scope_findings": [
        "class: a · reach: Setup, Approvals, Delegations, New on a seeded install, as measured on the card: POST /api/v1/data/sys_approval_delegation answers 403 and the dialog stays open with no toast and no field error. After this PR the delegation picker no longer offers a refused value, but the console's create dialog still drops any server refusal silently. · evidence: objectui at the pin 12dc7b47c3, packages/app-shell/src/console/AppContent.tsx around line 1424 mounts the global ModalForm with onSuccess (handleRecordFormSuccess) and no onError. packages/plugin-form/src/ModalForm.tsx lines 747-752: the submit catch calls schema.onError only if present and rethrows, with no toast or field error of its own (its only toast.error is the upload gate). objectui#13161 does NOT cover it: that card is ActionParamDialog, which closes on a server error and does show a toast, a different surface and the opposite symptom. · dedupe words: create dialog silent 403; ModalForm server error no toast; record create refusal not surfaced",
        "carrier: none · noted, not filed: the edit form's Delegator picker now also narrows to the acting user. The update guard refuses any other delegator, so this is consistent, recorded in the PR's Acceptance notes."
      ]
    }

    Generated by Claude Code

  3. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24834 comment 6287490187 · domain:services seat 2 · session_01UmqzD97e14iws6hmVhgZ5y · 2026-10-20T09:07Z


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Filed objectstack-ai/objectui#13205 · domain:services seat 2 · session_01UmqzD97e14iws6hmVhgZ5y · 2026-10-20T09:10Z

    The out-of-scope finding of PR #24834's report (class (a), ACCEPT 6287490187 item 5): the console's global record create/edit dialog drops a server refusal silently. PR #24834 removes this card's refusal; the dialog's silence for any other refusal is objectui's.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 2 · os-support-ai · session_01UmqzD97e14iws6hmVhgZ5y · 2026-10-20T09:56Z

    Reading Value
    PR #24834 timeline added_to_merge_queue 2026-10-20T09:32Z → merged 2026-10-20T09:55Z
    origin/main merge commit 5cb6e6e0aa is an ancestor (git merge-base --is-ancestor, exit 0)
    By content on origin/main sys-approval-delegation.object.ts declares the {current_user_id} picker filter (1 hit)
    This card closed completed by the PR's Fixes #24764; pm:dispatched removed in this act

    ACCEPT 6287490187 is on the PR. The console's silent create-dialog refusal is objectstack-ai/objectui#13205.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

area:workflowApprovals and automation — the work that runs without a person driving itbugSomething isn't workingdomain:servicespriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions