fix(approvals): actor_id records the person who decided, acted_as the slot it was taken as; stored slot literals move out at boot - #21493
Conversation
…n acted_as sys_approval_action.actor_id is a sys_user lookup, but a slot-gated action recorded the SLOT it took there: a position literal for a position staffed after open, an email for an email-authored user approver. On those decisions no row the decision writes names the person who decided. - sys_approval_action gains acted_as (text, the slot in its stored spelling); actor_id records the person the context vouches for (recordedActor), with the SLA sweep's reserved sentinel kept by name (separate ADR-0118 D1 debt). - The multi-approver tally and decision_progress read acted_as; the already-acted probe matches actor_id against the caller's user id OR acted_as against the caller's acting addresses - two facts, no fallback. - The action link resolves an email-bound token to the one account carrying that email, so its decision records that person (or none). - action-slot-backfill.ts, run on kernel:ready beside the approver-index rebuild: moves stored slot literals out of actor_id into acted_as (actor_id becomes null), and stamps acted_as on the approve votes a pending request's tally still counts. Idempotent. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
- approval-service.test.ts: the #21379 slot tests now assert both columns; a #21411 block pins position / email / user-id slots through the session door and the action link (an email-bound token records the one account carrying it, or nobody), an override recording the admin and no slot, a system context recording nobody (the SLA sentinel kept), per_group tally and decision_progress counting acted_as, and the already-acted probe's two halves (person, slot) with a legacy row found by its person and a literal left in actor_id never read as a slot. - approver-address-readers.test.ts: acted_as replaces actor_id as a slot column; every read of actor_id is classified, and the only comparison is with the caller's user id. - action-slot-backfill.integration.test.ts: real ObjectQL + SQL driver; literals moved, pending votes stamped, finished user-id rows and sentinels untouched, a second run writes nothing, and an in-flight unanimous request still finalizes on the votes the old writer recorded. - dogfood position-address-readers pin: recorded() reads actor_id, acted_as, via_override and asserts person plus slot. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…slot in acted_as; changeset Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…nd its error-level failure log Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…'s two writes; the wiring test's double declares no write verbs The action-slot backfill adds two elevated `update` sites on sys_approval_action (229 -> 231 write call sites, 110 -> 112 decidably elevated); `node scripts/tenant-audit-census.mjs --write` regenerated both census tables and the page's hand-written prose counts follow them. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
#21458's ApprovalActionRow.acted_as) Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
listActions maps sys_approval_action.acted_as onto ApprovalActionRow.acted_as (declared by @objectstack/spec in #21458), so a timeline shows who acted and as which slot; a row no slot admitted omits it. Pinned with a backfilled row that shows its slot and no person. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. 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 0666a388f7a4731b50ffc3acae825ee9d75d3ac2 && git checkout 0666a388f7a4731b50ffc3acae825ee9d75d3ac2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 100c394f6fd0113377bccdabed6ecf3b04b720da dc6d72a9cdf3d1bdf2bc5f0321b0ad3b0448a26a && git checkout -B drift-repro 100c394f6fd0113377bccdabed6ecf3b04b720da && git merge --no-ff dc6d72a9cdf3d1bdf2bc5f0321b0ad3b0448a26a
node scripts/docs-audit/affected-docs.mjs --json 100c394f6fd0113377bccdabed6ecf3b04b720da
|
Fixes #21411
Clause-②: no
What changes
sys_approval_action.actor_idis a lookup tosys_user. Under ADR-0118 D1 it holds a user id or nothing. A slot-gated action recorded the SLOT it took there instead: the literalposition:Pfor a position staffed after the request opened, or an email for auserapprover authored as one.Measured on a booted showcase at
main53fd35e3e: a position-slot approve wrote 8 rows across both databases, and none of them names the person who decided. The 3 audit-ledger rows carryuser_id/actornull, the 3 activity rows carryactor_idnull, and the action row carriesactor_idposition:finance. That is the raise to p1.Triage ruling B (
5954022700), with its retriage answers (5960329631), puts the person and the slot in two columns.sys_approval_action.acted_as(text, max 255) holds the pending-approver slot the action was taken as, in its stored spelling.actor_idgains its ADR-0118 D1 describe. Both are inhighlightFieldsand therecent/all_actionsgrids, asvia_overridewas. Translations are regenerated, with zh-CN, ja-JP and es-ES written by hand.decideNode,sendBack(revise and auto-reject),reassign,requestInfo,comment,recall,resubmitandremindrecordsactor_id: recordedActor(actorId, context). That is the person the context vouches for, never an address the caller named. The slot-gated ones also recordacted_as: slot ?? null.system:sla, which is approvals: ADR-0118 D1 family —sys_useractor columns still hold sentinels and slot literals (system:sla/system:dead-run,reassign_from/reassign_to,sys_notification.actor_id) #21455's.redeemActionToken) now puts the person behind the token's slot on the context: a user id as itself, an email as the ONE account carrying it, otherwise nobody. Before, it put the slot itself there, so an email-bound link recorded the email as the acting user, on the action row and in the status mirror alike.decision_progressreadacted_as. There is no??toactor_idanywhere.actor_idequals the caller's user id, ORacted_asis inactingAddresses(caller). These are two facts, each compared only with its own identity kind.listActionsmapsacted_asontoApprovalActionRow.acted_as, which spec(contracts):ApprovalActionRowdeclares the slot an approval action was taken as (an optional member), so the action log can show it onceactor_idholds the person (#21411 ruling B) #21458 declared and which merged first, besideactor_idandactor_name. A row no slot admitted omits the member.action-slot-backfill.ts). It is hooked onkernel:readybesidebackfillApproverIndex, the plugin's existing boot-time repair, so it ships as code and no agent runs a bulk write.actor_idholds a slot address (contains:or@, and is not one of the reservedsystem:sla/system:dead-run) getsacted_as := actor_idandactor_id := null. No stored record names its decider, so null is ADR-0118's value, not a guess.acted_asis empty: they getacted_as := actor_id. This is exact, not a guess, because an override finalizes the node.errorwith the consequence and the fix.content/docs/automation/approvals.mdx: the sentence that said the decision is recorded inactor_idunder its slot now names both columns.Pins
approval-service.test.ts:position:<p>can see the request but cannot decide it with the default actor (can_actfalse, approve 403), and loses sight of it after deciding (404) #21379 slot tests now assert[actor_id, acted_as].sys_approval_action.actor_id(asys_userlookup) records the slot literal (position:<p>, or an email) instead of the deciding user, so the person who decided is on no column (ADR-0118 D1) #21411 block covers:per_grouptally anddecision_progresscounting slots;actor_idnever read as a slot;approver-address-readers.test.ts, the enumeration pin:acted_asreplacesactor_idamong the slot columns. Every read ofactor_idin the package is classified with its count. The only comparison is with the caller's user id (actor_id: uidinvisibleRequestIds).action-slot-backfill.integration.test.tsruns on a realObjectQLwithSqlDriver(better-sqlite3, in-memory) and the real DDL:{0, 0};action-slot-backfill-wiring.test.ts: the plugin runs the repair once atkernel:ready(never atstart()), against its own engine, and logs a failure aterror.packages/qa/dogfood/test/position-address-readers.dogfood.test.ts, a cross-lanedomain:cliaddition declared on [PM seat] domain:cli — 🟢 os-bill · session_016GiHYRmLSNWTfbX9gVQkpz · R1 #6024:recorded()readsactor_id,acted_asandvia_override, and asserts the person plus the slot, and the admin plus no slot on an override.Ablations (committed first; every leg through
scripts/ablation-replace.mjs, with the anchor hit once, the blob changed, the restore proven equal to HEAD andgit diff HEADempty)Legs a to f ran at
b668cf134; leg g ran atdc6d72a9c. Each direction was predicted before its run.decideNodewrites noacted_asa.actor_iddecision_progressreadsa.actor_idkernel:readyhook is removed{ actor_id: uid }half is removedlistActionsstops mappingacted_asGates, at
dc6d72a9cdispatch-gates --repo objectstack-ai/objectstack --commandsderived 97 commands. All 97 ran with exit 0, plus the 4 in-path roster gates it flagged (check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity), also exit 0.--ranwith exit codes answers: 97 derived, 97 run, 0 NOT-MEASURED (a derived zero).pnpm --filter @objectstack/plugin-approvals typecheck: exit 0 (check:test-typecheckOK).pnpm --filter @objectstack/plugin-approvals test: 58 files, 868 tests passed.pnpm --filter @objectstack/dogfood typecheck: exit 0.check:engine-double-contract: the wiring test's double now declares no write verbs.check-tenant-audit-census: the backfill adds 2 elevatedupdatesites (229 to 231 write call sites, 110 to 112 elevated).node scripts/tenant-audit-census.mjs --writeregenerated both census tables, and the page's hand-written prose counts were updated to match. This addscontent/docs/permissions/tenant-audit-census.mdxanddocs/audits/2026-08-tenant-audit-write-call-sites.counts.mdto the diff, declared here.Changeset
@objectstack/plugin-approvalspatch,Clause-②: no(the spec widening is #21458's). It states that it supersedes the "What is recorded" sentence of the unreleased.changeset/21379-position-address-readers.md, which this PR does not edit. Both ship in one release.Acceptance notes
actor_idnull, with the slot still taken and recorded. The only first-party machine callers are the SLA sweep (kept by name) and the action link (which now vouches for the resolved person). Test fixtures that decided from a bare system context and assertedactor_idmoved to a vouching context.sys_useractor columns still hold sentinels and slot literals (system:sla/system:dead-run,reassign_from/reassign_to,sys_notification.actor_id) #21455: the SLA and dead-run sentinels inactor_id,reassign_from/reassign_toholding slot literals and emails (reassign_fromstill records the slot handed over; a pin here names it), andsys_notification.actor_idfed bynotify.acted_asbeside the actor's name in the timeline is objectui work. Until it lands, a backfilled historical row shows no actor, and the slot is on the wire.Generated by Claude Code