Repository navigation
fix(plugin-approvals): the delegation form's Delegator picker offers only the signed-in user - #24834
Conversation
… 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>
…legation-delegator-field
… 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>
📓 Docs Drift Check1 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): Which tree this was computed onThis run read A worktree cut from an older # 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 |
席位核对ACCEPT · #24764 ·
Generated by Claude Code |
Fixes #24764
Clause-②: no
What changed
sys_approval_delegation.delegator_idnow declares a picker filter:The delegation write guard (
bindDelegationWriteGuardinpackages/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 freesys_userlookup, 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.packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts, where the card expected it.required. What a write may store does not change, because nothing on the write path readslookupFilters.approval-service.tsis not touched.Why this vocabulary (measured)
lookupFiltersis the spec's structured picker filter (packages/spec/src/data/field.zod.ts). In the pinned console (objectui12dc7b47c3,.objectui-sha),LookupFieldlowers each entry throughlookupFiltersToRecord(eqbecomes{ id: value }). It sends the result as the$filterof its candidate query, and the popover, the record-picker dialog, the people picker and the recents rail all use that query.{current_user_id}in a read'swhereagainst the caller (ObjectQL.resolveWhereTokens, the one gate every server read passes).@objectstack/lint's filter-token rule already coverslookupFilters(validate-filter-tokens.test.ts).applyFieldDefaultsruns, then thebeforeInserthooks (where the guard stamps an omitted delegator), then record validation (required). Nothing here neededrequiredchanged.Route not taken: a
current_userdefault (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 asys_userlookup (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 omitsdelegator_id. It changes exactly one cell of the write accept set:u_adminu_admin(the guard stamps it)u_adminFORBIDDEN403FORBIDDEN403VALIDATION_FAILED(required)VALIDATION_FAILEDu_svcVALIDATION_FAILED(required)u_svcNo 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-②: nodoes 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 realObjectQLoverSqlDriver(better-sqlite3, in-memory, real DDL) with the guard bound, and asserts:FORBIDDEN/ 403, and so is an update relabelling the delegator, and nothing is stored;requiredstays.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 withgit diff HEADempty. The test imports the object and the guard fromsrc/by relative path, so nodist/is involved.[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.if (false && has && supplied !== userId) deny();). 2 failed, 7 passed: both refusal pins, with "promise resolved instead of rejecting".required: falseon the delegator. 2 failed, 7 passed: the declaration pin, and "requiredstays" (the promise resolved instead of rejecting).Readings at head
529f78b82(after the last commit;mainmerged in at307ce4c4b)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).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives for this diff all exit 0, and--ranreconciles 68 derived, 68 run, 0 NOT-MEASURED. The same 68-command list derives atorigin/mainc3a8e07b6with these three files applied.pnpm lint,test:repoand the full farm) are left to this PR's CI.Out of scope (the seat files these)
ModalForm's submitcatchhands the error toschema.onErrorand 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
lookupFiltersis presentation only. The guard remains the enforcement, and this PR claims nothing beyond the picker.Generated by Claude Code