Commit 5e58193
fix(plugin-approvals): every slot reader takes the caller's acting addresses — can_act, the decision slot test, the already-acted probe and the email-keyed slot (#21410)
Fixes #21379
Clause-②: no
## What changes
PR #21378 moved the slot-address equivalence into
`packages/plugins/plugin-approvals/src/approver-address.ts` and pointed
the "My Pending" filter and the participant gate at it. Four readers
still compared a slot with the bare user id. Each now takes the caller's
**acting addresses** from that module. That set is the user id, the
email the caller's own `sys_user` row carries, and both spellings of
each position in the server-resolved `context.positions`.
`approver-address.ts` gains three functions, and there is still one
fold:
- `actingAddresses(caller)` returns the default actor's addresses, in
the order a slot is taken: user id, account email, then `position:P` and
the deprecated prefix for each held position.
- `actorAddresses(actorId, caller)` covers one resolved actor. The
caller's own user id expands to `actingAddresses`. A named position
address expands to both spellings of that position. Anything else, such
as a named email or a machine actor, matches only itself.
- `heldSlot(pending, actorId, caller)` is **the** slot test. It returns
the first of those addresses that the slate holds.
The readers:
| reader | before | now |
|---|---|---|
| `attachViewers`' `viewer.can_act` | `pending.includes(uid)` |
`heldSlot(pending, uid, caller)`, the same call the decision methods
make with no `actorId` |
| decision slot test: `decideNode`, `sendBack`, `reassign`,
`requestInfo`, `comment` | `pending.includes(actorId)` | `takenSlot()`,
which wraps `heldSlot` |
| `visibleRequestIds`, already-acted probe | `actor_id: uid` |
`actor_id` in `actingAddresses(caller)` |
| `visibleRequestIds`, current approver | user id plus position
addresses | `actingAddresses(caller)`, which adds the email half (item
4) |
`resolveActor` is unchanged. It still admits exactly the identities it
admitted before, and the #21350 oracle pin still holds. **Nobody new may
decide.** The holder could always decide by naming `position:P`. Now the
default actor and the console's spelling reach the same slot. A user who
holds a different position is still refused: 403 at the route, pinned in
the unit and dogfood suites.
**What `sys_approval_action.actor_id` records.** A slot-gated action
records the slot it took, in that slot's stored spelling: `position:P`
on a `position:P` slot (whichever spelling the caller used), the
deprecated-prefix literal on a 15.x slot, and the email on an email
slot. Naming that slot has always recorded exactly this; the measured
row on `main` is `approve:position:m21379_reviewer`. It must stay this
way because the multi-approver tally in `decideNode` and the
`decision_progress` enrichment count approvals by matching `actor_id`
against the slate. A decision recorded under any other spelling would
leave its slot pending after its holder approved, so a unanimous request
would never finalize. The tally pin goes red if this changes. The cost
is noted under Acceptance notes.
`#21387` (retiring the deprecated prefix) is not addressed here.
## Measured on a booted app (`bootStack`, real `/api/v1/approvals`
routes)
The fixture opens a request on a position nobody holds, then staffs the
holder. Readings were taken with a scratch measurement test, which was
not committed. The committed dogfood pin below replaces it.
| request | `main` at `ecb6ca025` | this branch |
|---|---|---|
| holder: `viewer.can_act` | `false` | `true` |
| holder: approve, no `actorId` | 403 `FORBIDDEN` | 200 `approved` |
| holder: approve, `actorId` with the deprecated prefix | 403
`FORBIDDEN` | 200 (dogfood pin, on its own request) |
| holder: approve, `actorId: position:P` | 200 | 200 (unit pin) |
| holder: `GET /requests/:id` after deciding | 404 | 200 |
| bystander (holds another position): approve, no `actorId` | 403 | 403
|
**Item 4, the email-keyed slot: it reproduced and is fixed here.** A
`user` approver authored as an email stores the email as its slot. The
table below is for the reviewer who owns that email and is not the
submitter.
| request | `main` at `ecb6ca025` | this branch |
|---|---|---|
| "My Pending" (`approverId` = id, email) | `[]` | the request;
`can_act: true` |
| `GET /requests/:id` | 404 | 200 |
| approve, no `actorId` | 403 | 200 |
| approve, `actorId` = the email | 200 | (decided above) |
| `GET /requests/:id` after deciding | 404 | 200 |
## Pins
- `approval-service.test.ts`, describe `every slot reader takes the
acting addresses (#21379)`, has one pin per reader:
- `can_act` as a table over holder, bystander, submitter and admin. It
asserts that `can_act` equals "admitted as a slot holder" with no
`actorId`, and that `can_override` covers the admin.
- The decision slot test under the default actor, the deprecated prefix
and `position:P`, each on its own request, with the recorded spelling. A
15.x slot keeps its own spelling. The bystander is refused under the
default actor and under a named position.
- The four sibling methods: holder admitted, bystander refused.
- The unanimous tally consumes exactly the position slot.
- The already-acted probe: the holder keeps sight of the request, the
bystander does not, and a successor holder of the position does.
- The email-keyed slot: listed, counted, `can_act`, decided by the
default actor, and still in sight afterwards. Another account's email is
a negative control.
- The user-id slot is taken before a position slot.
- `approver-address.test.ts` pins the three new pure functions, with
negative controls.
- `approver-address-readers.test.ts` is the **enumeration pin**:
1. `READERS` lists every reader of the equivalence: acting path, list
filter, participant gate, `can_act`, `takenSlot`, and the five decision
methods. Each listed method must read its `approver-address.ts` export,
which the pin checks through the TypeScript AST. It also checks that
both participant-gate probes read the one `acting` set.
2. The pin parses every non-test `.ts` file under `src/` except
`approver-address.ts`. It collects every site shaped like a slot
comparison:
- a membership call (`includes`, `has`, `some`, and so on) on a receiver
whose text names a pending slate;
- a slot column (`approver`, `actor_id`, `pending_approvers`) inside a
`where` or `filter` predicate, or assigned to `where.COLUMN`;
- a declarative `field: 'pending_approvers'` row under a `filter`.
Every site must be classified in `SLOT_SITES` by file, enclosing
function and exact text. Today there are 3 `reader` sites, 6
`slot-vs-slot` sites, such as a hand-off target or a token's bound slot,
and 1 `declarative` site (see Acceptance notes). A new site fails with
its location. So does a changed or vanished site. A planted-source case
shows that each of the four shapes is detected.
What the pin does not see: a comparison written in none of those shapes,
such as a hand-written loop using `===`.
## Ablations
Each ablation is committed first and runs through `node
scripts/ablation-replace.mjs` in WRAP mode. A belt trap restores from
`git checkout HEAD` on an absolute path. Each mutation was checked on
disk: anchor count went from 1 to 0, replacement count from 0 to 1, and
the blob changed. Each restore was proven by blob == HEAD and an empty
`git diff HEAD`.
The unit pins import `src` directly, and dogfood's `isolated` project
aliases `@objectstack/plugin-approvals` to `src/index.ts`, so no `dist`
leg applies.
Final run at `dabba36f3`:
| ablation | unit (3 files, 338 tests) | dogfood (2 files) |
|---|---|---|
| A1: `can_act` back to `pending.includes(caller.userId)` | 4 red:
table, email, both enumeration tests | red: holder `can_act` expected
true |
| A2: `takenSlot` back to the literal actor | 8 red | red: holder
approve 403 |
| A3: already-acted probe back to `actor_id: uid` | 3 red | red: holder
detail 404 |
| A4: current-approver probe back to the user id alone | 5 red,
including both #21350 pins | both red |
| A5: default actor widened to a non-held position (`heldSlot` takes any
`position:` slot) | 8 red: bystander rows, the bystander refusals, and 4
existing #3424 override pins | red: submitter `can_act` became true |
The first A5 attempt was refused by the tool: the replacement contained
the anchor text, so the anchor count did not drop. No test ran on that
attempt. It was redone with a replacement that does not contain the
anchor. Every direction was red, as predicted.
## Gates and tests, at `dabba36f3`
- `pnpm --filter @objectstack/plugin-approvals test`: 56 files, 855
tests, all passed.
- `typecheck` for plugin-approvals and dogfood: exit 0.
`check:test-typecheck` holds 8 files and 324 errors in the ledger, with
no new debt. All three new or edited test files are in
`tsconfig.test.json`'s program (checked with `--listFilesOnly`).
- Dogfood (`--project isolated`): the new `position-address-readers`
pin, the #21350 pin, `approval-override-composite-pin` and
`approval-snapshot-masked-field` ran: 4 files, all passed.
- `dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`
derived 95 commands. All 95 exited 0 at `dabba36f3`, and `--ran`
reconciled them as `95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN`. Three
earlier readings were superseded:
- `check-system-context-census` was red on the first pass, because a new
`context.isSystem` read in `actingCaller` had no row. Commit `dabba36f3`
removes that read, and the census holds at 114 sites.
- `check:skill-examples` and `check:dual-build-cjs-loads` answered
PREREQUISITE NOT MET until the eight packages without `dist/` were
built.
- `check:dual-build-cjs-loads` then went red once on
`@objectstack/mcp`'s `index.d.cts`. That file is outside this diff, and
its timestamp shows it was being written during the run. The rerun was
green.
- `eslint --no-inline-config --format json` on the 7 touched `.ts`
files: 7 files, 0 errors, 0 warnings. `eslint.config.mjs` enables no
type-aware linting (`--print-config` shows no `parserOptions.project` or
`projectService`), so no untouched file's result can move. The repo-wide
`pnpm lint` is left to CI.
- `origin/main` was merged in as `8312775bd`. The two incoming commits
do not touch these files.
## Docs
`content/docs/automation/approvals.mdx`: corrected the sentences this
change made false.
- the inbox and participant counts now include the account email;
- "already acted" is counted by the same identities;
- the decision paragraph now describes the default actor's slot and the
recorded spelling;
- the `can_act` bullet;
- the admin-override callout's "no concrete user can act", which no
longer holds for a staffed `position:P` slot.
"A position-addressed approver is never wrongly hidden" is now true and
stays. The deprecated prefix is described in words, and
`check:role-word` holds.
## Acceptance notes
- **Admin who holds the routed position:** this admin now decides as a
slot holder (`via_override: false`, one vote in a multi-approver tally),
exactly as when they named the slot. An admin who holds no slot is
unchanged. The changeset says this.
- **Already acted belongs to the position:** a decision recorded under
`position:P` stays visible to every current holder of P. A former holder
who decided it stops seeing it once they leave P. Pinned (successor) and
documented.
- **Attribution:** `sys_approval_action.actor_id` is a `sys_user`
lookup, but for a position or email slot it holds the slot literal. The
person who decided is not on the row. This was already true when the
slot was named; the default actor now reaches it too. Reported to the
seat.
- **Email casing:** the default actor matches the account's email
exactly as stored. `resolveActor` admits a named email
case-insensitively. So a slot authored in a casing that differs from the
account's stored email can be decided only by naming that exact
spelling, as before, and the participant gate does not count it. NOT
MEASURED through a door.
- **The `my_pending` list view** in `sys-approval-request.object.ts`
filters `pending_approvers contains {current_user_id}`. It is the one
declarative slot-against-caller comparison, and metadata cannot read
`approver-address.ts`. It is served only by the generic data door. On
this fixture that door answers 403 `PERMISSION_DENIED` to a member
without read on `sys_approval_request`. The admin read returned the
email-slot row. It is classified `declarative` in the enumeration pin,
so a second such filter goes red.
- **The spec contract's `can_act` TSDoc**
(`packages/spec/src/contracts/approval-service.ts`) still says "their
user id is in the request's resolved `pending_approvers`". Its main
sentence, "mirrors the exact check the service uses to authorize a
decision", is now true. The parenthetical is narrower than the behavior.
`packages/spec` was not edited under this order.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 41a3c8d commit 5e58193
9 files changed
Lines changed: 1098 additions & 86 deletions
File tree
- .changeset
- content/docs/automation
- packages
- plugins/plugin-approvals/src
- qa/dogfood/test
- fixtures
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
387 | 387 | | |
388 | 388 | | |
389 | 389 | | |
390 | | - | |
| 390 | + | |
| 391 | + | |
391 | 392 | | |
392 | 393 | | |
393 | 394 | | |
| |||
426 | 427 | | |
427 | 428 | | |
428 | 429 | | |
429 | | - | |
430 | | - | |
431 | | - | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
432 | 437 | | |
433 | 438 | | |
434 | 439 | | |
| |||
496 | 501 | | |
497 | 502 | | |
498 | 503 | | |
499 | | - | |
500 | | - | |
501 | | - | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
502 | 515 | | |
503 | 516 | | |
504 | 517 | | |
| |||
606 | 619 | | |
607 | 620 | | |
608 | 621 | | |
609 | | - | |
610 | | - | |
611 | | - | |
612 | | - | |
| 622 | + | |
| 623 | + | |
| 624 | + | |
| 625 | + | |
| 626 | + | |
| 627 | + | |
613 | 628 | | |
614 | 629 | | |
615 | 630 | | |
| |||
625 | 640 | | |
626 | 641 | | |
627 | 642 | | |
628 | | - | |
629 | | - | |
630 | | - | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
631 | 648 | | |
632 | 649 | | |
633 | 650 | | |
| |||
0 commit comments