Repository navigation
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
|
… null — machine actors record none, notify forwards only a person, reassign parties are slot addresses (ADR-0118 D1) (objectstack-ai#21514) Fixes objectstack-ai#21455 Clause-②: no ## What changes Under ADR-0118 D1 a lookup to `sys_user` holds a user id or null, never a placeholder. Four writers in the approvals plugin broke that, and a lookup holding a non-id drops the row from every join and report on it, silently. This applies triage's position-by-position ruling (`5960380029`). It builds on objectstack-ai#21411 (PR objectstack-ai#21493, `6f17d1d36`): the same `recordedActor`, the same slot-address column kind (`acted_as`), and the same boot-time repair. 1. **SLA and dead-run sweeps: `actor_id` is null.** - `escalateRequest` writes the `escalate` row with `actor_id: null`. Before, it wrote `system:sla`. - The `auto_approve` / `auto_reject` decision runs under `SYSTEM_CTX`. That context vouches for nobody, so `recordedActor` records null. The special case that kept `system:sla` is removed. - `abandonForDeadRun` writes its `recall` row with `actor_id: null`. Before, it wrote `system:dead-run`. - The fact that a sweep acted is the row's own kind. The `escalate` row's comment names the configured action, and the dead-run `recall` names the run and its status. `DEAD_RUN_ACTOR_ID` is deleted. - `SLA_ACTOR_ID` stays, as the sweep's acting identity only: `decide()` under a system context requires an explicit actor. Its docblock says it is never recorded. 2. **`reassign_from` / `reassign_to` are slot-address fields.** They are now `Field.text({ maxLength: 255 })`, the declared form of `acted_as`, instead of `Field.lookup('sys_user')`. The descriptions say they hold the slot's address in its stored spelling: a user id, an email or a position address. Translations are regenerated with `node scripts/check-i18n-bundles.mjs --write`, and the zh-CN, ja-JP and es-ES help texts are written by hand. The zh-CN labels also become neutral (from party / to party instead of from person / to person). 3. **`notify` forwards a person or nothing.** `notify` no longer takes an actor string. A caller hands in `actorContext` (the action's `ExecutionContext`), and `notify` forwards `recordedActor(actorContext)` as `actorId`, or omits it. So no string path exists for a slot literal or a sentinel. - Callers that pass the context: `sendBack` (both notifications), `reassign`, `remind` (both), `requestInfo` and `comment`. - Callers that pass nothing: the three SLA notifications, and the two out-of-office notifications, which already passed nothing. - `service-messaging` is unchanged. It writes `sys_notification.actor_id` from `EmitInput.actorId` as given, so the producer is where this is fixed. 4. **Stored sentinels.** `backfillActionSlots` pass 1 already scanned `actor_id` for values that contain `:` or `@`. The `$nin` exclusion of the two sentinels is gone, and each row takes one of two arms with the same single `update` call: - a slot literal moves to `acted_as`, as before; - a sentinel is nulled, and gets no `acted_as`, because a sweep took no slot. The result gains `sentinelsCleared`, and the plugin's boot log reports it. The pass stays idempotent: a second run writes `{0, 0, 0}`. The sentinels are now spelled only in `LEGACY_MACHINE_SENTINELS` (renamed from `RESERVED_MACHINE_ACTORS`). The update call-site count is unchanged, so the tenant-audit census needs no regeneration. 5. **Docs.** In `content/docs/automation/approvals.mdx`, the dead-run sentence that documented `system:dead-run` now says the row records no actor and names the run. Two short sentences say the escalation rows and notifications record no actor, and that `reassign_from` / `reassign_to` hold slot addresses. ## Readers of the sentinels (measured by grep at `e72a7d2176`) - **This repo:** no runtime reader keys on `system:sla` or `system:dead-run`. - The SLA re-run guard keys on `action: 'escalate'` (the ADR-0042 §1 marker), not the actor. - The dead-run sweep re-scans only `status: 'pending'` requests, so it does not key on the actor either. - `listActions` resolves names for ids and skips `:` values. - No REST approvals route and no analytics code reads either value. - The remaining hits: tests (updated here), release-owned `CHANGELOG.md` files, ADR-0042, the QA checklist JSON, and the backfill's legacy list. - **objectui at the pinned `.objectui-sha` (`89cad75d5`):** `apps/console/src/pages/system/ApprovalsInboxPage.tsx:2331` renders `actor_id === 'system:sla'` as "System (SLA)". Other actors go through `formatIdentity`, which shows an em dash for an empty actor. See the acceptance notes. ## What the field-type change does to a stored column (measured) The old declaration created the table and stored three reassign rows (a position, an email, a user id). The new declaration then booted over the same database. A positive control confirmed the DDL capture: it saw the `create table` on the first boot. | | SQLite (better-sqlite3) | PostgreSQL 16.14 | | --- | --- | --- | | column before | `varchar(255)` | `character varying(255)` | | DDL on the upgrade boot | none | none | | column after | `varchar(255)` | `character varying(255)` | | stored values | all 3 unchanged | all 3 unchanged | | foreign keys / indexes on the columns | 0 / 0, before and after | 0 / 0, before and after | | `detectManagedDrift` for `reassign_from` / `reassign_to` | none | none | | a fresh column (as `acted_as` today) | `TEXT` | `text` | The lookup arm never emitted a foreign key (`sql-driver.ts`, the objectstack-ai#11567 note), so nothing has to be dropped. Neither column is in a list view, `highlightFields` or an index. In `listActions`, `reassign_from_name` / `reassign_to_name` resolve where an address names an account: a user id, or an email an account carries (the existing `resolveUserNames`). A position has no name. The family pin asserts all three cases. ## Pins - **`actor-lookups-hold-ids.integration.test.ts`** (new). This is the family pin, on a real `ObjectQL` over `SqlDriver` (better-sqlite3 in memory, real DDL). - It reads the `sys_user` lookups from the plugin's own manifest `objects`, not from a re-listed set. - It drives every write path through the real `ApprovalService`: position-slot and email-slot actions, comment, request-info, remind, three reassign shapes, a vote, the action link, send-back within budget and past it, recall, an out-of-office substitution, all four SLA actions, and the dead-run sweep. - It then asserts: - every stored value of every declared lookup is a `sys_user` id or null; - every declared lookup was written at least once, so a new lookup stays red until the battery writes it; - every `notify` actor is a person, and each person-driven topic forwards the holder, never the address; - each machine actor writes null, and the machine topics name no actor; - a position and an email round-trip in the slot fields, with the names resolving as described above. - **`action-slot-backfill.integration.test.ts`** now plants three sentinel rows, one of them an `approve` at step 0 on a pending request (the shape pass 2 stamps). It asserts `{ literalsMoved: 3, sentinelsCleared: 3, votesStamped: 1 }`, then 0 sentinels left in the lookup, user-id rows untouched, and a second run writing `{0, 0, 0}`. - **`action-slot-backfill-wiring.test.ts`:** a run that only cleared sentinels still logs. - The existing pins that asserted the sentinels now assert null: - `approval-service.test.ts`: SLA notify, auto_approve, the system-context case, and dead-run; - `approval-actor-impersonation.test.ts`; - `run-status-liveness.test.ts`; - `approval-service-sla-calendar-clock.test.ts`. The `approver-address-readers.test.ts` ledger drops the `$nin` site. ## Ablations (each through `scripts/ablation-replace.mjs`, wrapped, direction predicted first; every restore proven: blob equals HEAD and `git diff HEAD` empty) | leg | mutation | predicted | observed | | --- | --- | --- | --- | | A | the `escalate` row writes `SLA_ACTOR_ID` again | red: id-or-null and machine pins, the SLA tests, the SLA-clock test; round-trip green | 5 failed / 335 passed, those five exactly | | B | `notify` forwards `SLA_ACTOR_ID` when no context vouches | red: notify pin, machine pin, SLA notify test; stored-row pin green | 3 failed / 337 passed, those three exactly | | C | `reassign_from` back to `Field.lookup('sys_user')` | red: declared-kind and id-or-null pins; round-trip and coverage green | 2 failed / 4 passed; the offenders are named: `position:legal` and the email | | D | the backfill's sentinel arm removed (`sentinel = false`) | red: the backfill pin | 1 failed / 4 passed (`sentinelsCleared` 0, not 3) | | E | a new `sys_user` lookup that nothing writes | red: the coverage pin only | 1 failed / 5 passed; it names the new field | The first attempt at leg E was a no-op: the tool refused it because the replacement contained the anchor (count 1 to 1), and it restored the file. I re-ran it with a semantically identical re-spelling. ## Gates, at `e72a7d2176` (after merging `origin/main` `85e29b885`) - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 95 commands, and all 95 exit 0. - Two of them first exited 3 (PREREQUISITE NOT MET: `check:skill-examples`, `check:dual-build-cjs-loads`). After `turbo run build --filter=!@objectstack/docs`, both exit 0. - `--ran` with exit codes: 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN. - The 4 in-path roster gates also exit 0: `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`. - `pnpm --filter @objectstack/plugin-approvals typecheck`: exit 0 (`check:test-typecheck` OK). - `pnpm --filter @objectstack/plugin-approvals test`: 59 files, 875 tests passed. - `pnpm --filter @objectstack/service-messaging test`: 46 files, 507 tests passed. - `packages/qa/dogfood/test/position-address-readers.dogfood.test.ts`: 1 of 1 passed, against a rebuilt `plugin-approvals` dist. - eslint is narrowed to the 16 changed `.ts` files: `--no-inline-config --format json` reports 16 files, 0 errors and 0 warnings. - The population comes from `eslint.config.mjs`'s `packages/**/*.{ts,…}` globs. - The config never enables type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on an untouched file. ## Changeset `@objectstack/plugin-approvals` patch, `Clause-②: no`. It says it supersedes the "The SLA sweep keeps its reserved `system:sla` actor for now" sentence of the unreleased `.changeset/21411-approval-actor-person.md`, which this PR does not edit. Both ship in one release. ## Acceptance notes - **Console rendering (objectui).** At the pinned sha, the inbox timeline labels `system:sla` "System (SLA)" and otherwise shows an empty actor as an em dash. After this PR, the SLA and dead-run rows have no actor, so they show the em dash, as out-of-office, cancel and backfilled rows already do. ADR-0118 D1 makes "System" a render rule for an empty actor, and D5 answers "which automation" from association fields: for an SLA auto-decision that is the `escalate` row right before it, whose comment names the action. The renderer change is objectui work, the same carrier as objectstack-ai#21411's `acted_as` rendering. No reader needs a column the row kind does not carry, so none is added. - **ADR-0042 §2** ("Machine decisions → reserved actor `system:sla`", and clients rendering it) is the decision ADR-0118 D1 supersedes for this column. ADR-0042 has no amended status line, and `docs/adr/**` is outside this PR's surface (Tier H). This needs a seat or maintainer decision. - **`ApprovalActionRow.reassign_from` / `reassign_to` TSDoc** in `packages/spec/src/contracts/approval-service.ts` still says "the user whose pending-approver slot was moved", and `*_name` says `sys_user.name`. The type (`string`) is unchanged. The text needs a spec-lane correction, and this PR makes no `packages/spec` edit. - **`docs/qa/platform-checklist/areas/approvals.json`** (the SLA single-shot item) says the `escalate` row's actor is `SLA_ACTOR_ID`. The row now records no actor. The item needs a one-word change, outside this PR's surface. - **Behaviour change for integrations:** code that tested `actor_id` for `system:sla` / `system:dead-run` reads the row kind instead, and code that expanded `reassign_from` / `reassign_to` as `sys_user` reads them as addresses. The changeset says both. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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 — ⏳ vacant #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