Skip to content

fix(plugin-approvals): the delegation form's Delegator picker offers only the signed-in user - #24834

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24764-delegation-delegator-field
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24764-delegation-delegator-field

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24764

Clause-②: no

What changed

sys_approval_delegation.delegator_id now declares a picker filter:

lookupFilters: [{ field: 'id', operator: 'eq', value: '{current_user_id}' }],

The delegation write guard (bindDelegationWriteGuard in packages/plugins/plugin-approvals/src/lifecycle-hooks.ts) accepts one delegator on a non-system write: the acting user. Its docstring records the decision: "a delegation names its own author as delegator; acting for someone else is done through the approval-level override". The field was a free sys_user lookup, so Approvals → Delegations → New offered every user, and the server refused all but one of them with 403. The picker now offers the signed-in user and nobody else.

  • Landing. packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts, where the card expected it.
  • Unchanged. The guard: no refusal is widened and there is no admin exemption. The field stays required. What a write may store does not change, because nothing on the write path reads lookupFilters.
  • Not touched. No spec edit, no objectui edit, no consumer-side mapping. approval-service.ts is not touched.

Why this vocabulary (measured)

  • lookupFilters is the spec's structured picker filter (packages/spec/src/data/field.zod.ts). In the pinned console (objectui 12dc7b47c3, .objectui-sha), LookupField lowers each entry through lookupFiltersToRecord (eq becomes { id: value }). It sends the result as the $filter of its candidate query, and the popover, the record-picker dialog, the people picker and the recents rail all use that query.
  • The engine resolves {current_user_id} in a read's where against the caller (ObjectQL.resolveWhereTokens, the one gate every server read passes). @objectstack/lint's filter-token rule already covers lookupFilters (validate-filter-tokens.test.ts).
  • M4 (order on insert) measured: applyFieldDefaults runs, then the beforeInsert hooks (where the guard stamps an omitted delegator), then record validation (required). Nothing here needed required changed.

Route not taken: a current_user default (raised as a decision in the report)

Option (ii) from the dispatch, defaultValue: 'current_user', is honoured by the pinned console: create forms seed the acting user on a sys_user lookup (plugin-form/src/schemaDefaults.ts). It would open the form on the one value the guard accepts. I measured it on the real engine at this branch, as an insert that omits delegator_id. It changes exactly one cell of the write accept set:

caller base with the default
member u_admin stored u_admin (the guard stamps it) stored u_admin
anonymous, non-system refused, FORBIDDEN 403 refused, FORBIDDEN 403
system, no user id refused, VALIDATION_FAILED (required) refused, VALIDATION_FAILED
system, user id u_svc refused, VALIDATION_FAILED (required) stored u_svc

No HTTP door produces a system context, and no code in this repo writes delegation rows. Even so, it is an accept-set change, and the claim's Clause-②: no does not cover one. The picker filter alone makes the form offer only what the guard accepts, so this PR does not add the default and leaves it for a decision. The first commit on this branch carried the default, and the second removed it after the measurement. The net diff has no default.

Tests

New file: packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts. It runs a real ObjectQL over SqlDriver (better-sqlite3, in-memory, real DDL) with the guard bound, and asserts:

  1. the declaration, read off the object and off the registered body;
  2. the picker's candidate query returns the caller and nobody else, for each of three callers (the declared filter, lowered the way the console lowers it);
  3. the guard accepts every candidate the picker offers;
  4. the guard is not widened: a create naming another user is refused with FORBIDDEN / 403, and so is an update relabelling the delegator, and nothing is stored;
  5. required stays.

Ablations, each through scripts/ablation-replace.mjs: the anchor hit once, the blob changed on disk, the test ran, and the file was restored to the HEAD blob with git diff HEAD empty. The test imports the object and the guard from src/ by relative path, so no dist/ is involved.

  • A1, drop the filter. 6 failed, 3 passed. The picker offers [u_admin, u_priya, u_tomas] instead of [caller], both declaration pins fail (expected undefined to deeply equal), and the guard refuses an offered candidate.
  • A2, put a foreign delegator back into the guard (if (false && has && supplied !== userId) deny();). 2 failed, 7 passed: both refusal pins, with "promise resolved instead of rejecting".
  • A3, required: false on the delegator. 2 failed, 7 passed: the declaration pin, and "required stays" (the promise resolved instead of rejecting).

Readings at head 529f78b82 (after the last commit; main merged in at 307ce4c4b)

  • pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2: 87 files, 1156 tests passed, exit 0.
  • pnpm --filter @objectstack/plugin-approvals typecheck: exit 0. check:test-typecheck: OK, and the debt ledger is unchanged (8 files, 324 errors, 27 signatures).
  • Local gate subset: the 68 commands node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives for this diff all exit 0, and --ran reconciles 68 derived, 68 run, 0 NOT-MEASURED. The same 68-command list derives at origin/main c3a8e07b6 with these three files applied.
  • Repo-wide runs (pnpm lint, test:repo and the full farm) are left to this PR's CI.

Out of scope (the seat files these)

  • The console says nothing when the 403 comes back (objectui's half of the card). At the pin, ModalForm's submit catch hands the error to schema.onError and rethrows. It shows no toast and no field error of its own, so the dialog just stays open. objectui#13161 does not cover it: that card is about the action-param dialog, which closes on a server error and shows a toast.

Acceptance notes

  • The edit form's Delegator picker narrows to the acting user too. That matches the guard on update, which refuses a delegator other than the caller.
  • lookupFilters is presentation only. The guard remains the enforcement, and this PR claims nothing beyond the picker.

Generated by Claude Code

… acting user

bindDelegationWriteGuard admits one delegator on a non-system write, the
acting user, and refuses every other with 403. The field was a free sys_user
lookup, so the create form offered every user. It now declares
defaultValue 'current_user' and a lookupFilters entry on id equal to
{current_user_id}, so the form opens on the acting user and the picker offers
nobody else. The guard is unchanged.

Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
Co-authored-by: Claude <noreply@anthropic.com>
Measured: declaring defaultValue 'current_user' changes one engine cell, a
system-context insert that carries a userId and omits delegator_id (refused
by required before, stored with that userId after). That is an accept-set
change the claim's Clause-2 line does not cover, and the picker filter alone
makes the form offer only what the guard admits, so the default is dropped
here and raised as a decision in the report. What a write may store is now
unchanged.

Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
Co-authored-by: Claude <noreply@anthropic.com>
…dd the changeset

The registered-body pin compared the registered field with the source field,
so dropping the filter left both undefined and the pin green. It now compares
with the literal filter.

Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
Co-authored-by: Claude <noreply@anthropic.com>
… options

check:query-options-erasure counted five untyped option bags in the new
test (236 to 241 test sites). They are EngineQueryOptions and
EngineCountOptions now, and the ratchet holds at 236.

Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see

Coarse fallback — 6 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 c3a8e07b6aca321c0114f49944b0564baf0d2c0f → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · #24764 · domain:services seat 2 · os-support-ai · session_01UmqzD97e14iws6hmVhgZ5y · 2026-10-20T09:07Z · head 529f78b82c, the review of record for claim 6285563049.

  1. Form:
    • Draft, base main. The first line is Fixes #24764; the seat's grep of the body finds no other closing keyword beside an issue number.
    • Clause-②: no at line start in the body and in the changeset.
    • One patch changeset for @objectstack/plugin-approvals.
    • 3 files, +182/−0, inside the claim's file surface. lifecycle-hooks.ts and approval-service.ts are unchanged in the diff.
  2. Ruling and fix, read by the seat:
    • The diff. sys_approval_delegation.delegator_id gains exactly lookupFilters: [{ field: 'id', operator: 'eq', value: '{current_user_id}' }], plus a comment. required: true and the field type are unchanged.
    • The governing semantic holds. "A delegation names its own author as delegator" (bindDelegationWriteGuard's docstring). The guard is not widened and there is no admin exemption. The picker now offers only the value the guard admits.
    • The pins run on a real ObjectQL over SqlDriver with the guard bound. For three callers, the picker's candidate query returns only the caller, and every candidate is admitted. A create or an update naming another delegator is refused with code: 'FORBIDDEN' and statusCode: 403, and nothing is stored. required still refuses a system write with no delegator (VALIDATION_FAILED).
    • Ablation. Three legs went red as predicted and were each restored to the HEAD blob: the filter removed, the guard's refusal disabled, required: false.
  3. Gates:
    • CI on 529f78b82c at this read: 39 check-runs, 24 success, 7 skipped, 8 in progress, 0 failed. Not yet green, so not yet ready.
    • Local on the head: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN. check:query-options-erasure caught untyped option bags in the new test; they were typed, and the ratchet holds at 236.
    • Package suites: plugin-approvals 87 files, 1156 tests; typecheck exit 0 with the test-typecheck ledger unchanged.
  4. Accepted deviations and the open question:
    • Route (i) only. Route (ii), defaultValue: 'current_user', was measured to change one accept-set cell: an in-process system-context insert that carries a userId and omits delegator_id is stored instead of refused by required. So it was dropped from the net diff (2cdb9a975 adds it, 49c6d958f removes it; no force-push).
    • The dev's open question (add that default as a follow-up): answered by the seat as not in this card and not filed. It is a widening that owes its own Clause-②: yes. The measured pull is one click on a form whose picker now offers a single value. The startup-focus axis keeps an accept-set widening without a named need out. If the maintainer wants the prefilled form, it is a one-key card.
    • The shallow-clone fetch of the teardown gate's pinned fixture adds one shallow root and moves no ref.
  5. Out of scope:
    • Class (a), filed by the seat in objectui: the console's create dialog drops a server refusal silently (no toast, no field error; ModalForm calls onError only when given, and the console's global record form passes none). objectui#13161 is a different surface (the action-param dialog). The seat's filing follows this comment.
    • The edit form's Delegator picker narrows the same way. That is consistent with the update guard, and noted in the PR.
  6. Landing: ordinary queue landing (check-governed-merges --pr 24834: not governed, 182 lines). When every check on the current head is green, the seat sends pr_ready + automerge_enable through the relay, then confirms MERGED.

Generated by Claude Code

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.

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

2 participants