Skip to content

Commit 88fb5e8

Browse files
fix(approvals): every sys_user lookup approvals writes holds an id or null — machine actors record none, notify forwards only a person, reassign parties are slot addresses (ADR-0118 D1) (#21514)
Fixes #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 #21411 (PR #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 #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 #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>
1 parent e9dec3d commit 88fb5e8

18 files changed

Lines changed: 628 additions & 151 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
Every `sys_user` lookup the approvals plugin writes now holds a user id or nothing: the SLA and dead-run sweeps record no actor instead of a `system:` placeholder, notifications name only the person who acted, and `reassign_from` / `reassign_to` become slot-address text columns; stored placeholders are cleared at the next boot
6+
7+
Clause-②: no
8+
9+
Under ADR-0118 D1 a lookup to `sys_user` holds a user id or null, never a placeholder value. Four writers broke that, and a lookup holding a non-id drops the row from every join and report on it, silently.
10+
11+
**This supersedes the "The SLA sweep keeps its reserved `system:sla` actor for now" sentence of the unreleased `21411-approval-actor-person` changeset.** Both ship in one release; from it, the sweep records no actor.
12+
13+
- **Machine actors record no actor.**
14+
- The SLA sweep's `escalate` row, and the `approve` / `reject` an `auto_approve` / `auto_reject` escalation then records, have `actor_id` empty. Before, both held `system:sla`. The `escalate` row's comment still names the configured action.
15+
- The dead-run sweep's `recall` row has `actor_id` empty. Before, it held `system:dead-run`. Its comment still names the dead run and its status, and a submitter's own recall still records the submitter.
16+
- **Notifications name only a person.** The actor the plugin hands to `sys_notification.actor_id` (and so to each `sys_inbox_message.actor_id`) is the user the action's context vouches for, or nothing.
17+
- Before, a reassign, reminder, request for information, comment or send-back taken under a named position or email forwarded that address as the actor.
18+
- Before, every SLA notification forwarded `system:sla`. It now forwards no actor, as the out-of-office notifications already did.
19+
- **`reassign_from` / `reassign_to` are slot addresses.** A reassignment moves a pending-approver slot, so both columns hold the slot's address in its stored spelling: a user id, an email, or `position:<name>`. They are now text columns (max 255 characters, like `acted_as`) instead of `sys_user` lookups, which matches what they already stored.
20+
- Existing values need no rewrite, and an existing database keeps its columns as they are. On SQLite and PostgreSQL 16, booting the new declaration over a table created by the old one issues no DDL, keeps every stored value, and reports no schema drift for either column. A new database creates them as `text`, as it does `acted_as`.
21+
- The action log still resolves `reassign_from_name` / `reassign_to_name` where an address names an account: a user id, or an email an account carries. A position address has no name.
22+
- **Stored rows.** The boot-time repair that moves slot literals out of `actor_id` now also clears `system:sla` and `system:dead-run` from it, in the same pass and in the same idempotent way. A cleared sentinel gets no `acted_as`, because a sweep takes no slot. The boot log line reports the count as `sentinelsCleared`.
23+
- **For a report or integration that read these values:**
24+
- To find the SLA sweep's actions, read the `escalate` rows, and the decision that directly follows an `escalate` row whose comment names `auto_approve` or `auto_reject`. Do not test `actor_id` for `system:sla`.
25+
- To find a dead-run release, read the `recall` row whose comment names the run. Do not test `actor_id` for `system:dead-run`.
26+
- Read `reassign_from` / `reassign_to` as slot addresses. Do not expand them as `sys_user` references.

‎content/docs/automation/approvals.mdx‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,9 @@ not a pending approver`); a request that isn't pending returns 409
512512
(`INVALID_STATE`). The decision records two facts on its `sys_approval_action`
513513
row: `actor_id` is the user who decided, and `acted_as` is the slot the decision
514514
took, in that slot's stored spelling — the multi-approver tally counts approvals
515-
by matching `acted_as` against the slate. Always go through
515+
by matching `acted_as` against the slate. A `reassign` row records the slot it
516+
moved the same way: `reassign_from` and `reassign_to` hold slot addresses (a
517+
user id, an email or a position address), not users. Always go through
516518
these endpoints — never resume the flow run directly, and since #3801 you
517519
**cannot**: `POST /api/v1/automation/{flow}/runs/{runId}/resume` answers 403 for
518520
a run parked on an `approval` node (including via a `subflow` pause) and changes
@@ -672,8 +674,9 @@ trace that a slate had been bypassed was the designated approver's later
672674
reaches a terminal state without a decision — it failed, was cancelled, timed
673675
out, or the process hosting it crashed — nothing is left to decide the request,
674676
so a periodic sweep finalizes it as `recalled` and releases the record. The
675-
audit row records the actor `system:dead-run` and names the run and its status,
676-
so it reads distinctly from a submitter's own recall.
677+
audit row records no actor — a sweep is not a user, so `actor_id` is empty,
678+
never a placeholder value (ADR-0118 D1) — and names the run and its status, so
679+
it reads distinctly from a submitter's own recall, which records the submitter.
677680

678681
The sweep only ever acts on a run it can positively confirm is terminal: a
679682
paused run (the normal state of a live approval), an unknown run, or an
@@ -724,7 +727,10 @@ that request's drawer directly instead of a generic list.
724727
`escalation` is real, not decorative: set `enabled: true` and `timeoutHours`,
725728
and pick an `action` — `notify` (default), `reassign`, `auto_approve`, or
726729
`auto_reject`. Auto decisions run through the normal decide path, so the flow
727-
resumes exactly as if a human had clicked. Every escalation writes an audit row.
730+
resumes exactly as if a human had clicked. Every escalation writes an audit row
731+
of kind `escalate` naming the configured action; it, the auto decision that
732+
follows it, and the escalation's notifications record no actor, because no
733+
person acted (ADR-0118 D1).
728734
`timeoutHours` counts **calendar (wall-clock) hours** — nights, weekends and
729735
holidays included, because the platform ships no business-hours calendar — so a
730736
request opened at 17:00 on a Friday with `timeoutHours: 4` escalates at 21:00

‎packages/plugins/plugin-approvals/src/action-slot-backfill-wiring.test.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import { describe, it, expect, vi, beforeEach } from 'vitest';
1616

1717
const backfill = vi.hoisted(() => ({
18-
fn: vi.fn(async (_engine: unknown) => ({ literalsMoved: 0, votesStamped: 0 })),
18+
fn: vi.fn(async (_engine: unknown) => ({ literalsMoved: 0, sentinelsCleared: 0, votesStamped: 0 })),
1919
}));
2020

2121
vi.mock('./action-slot-backfill.js', async (importOriginal) => ({
@@ -71,6 +71,14 @@ describe('the action-slot backfill is wired on kernel:ready (#21411)', () => {
7171
expect(backfill.fn.mock.calls[0][0]).toBe(engine);
7272
});
7373

74+
it('a run that only cleared machine sentinels still reports what it wrote', async () => {
75+
backfill.fn.mockResolvedValueOnce({ literalsMoved: 0, sentinelsCleared: 2, votesStamped: 0 });
76+
const { ctx, fire, logged } = fakeContext(fakeEngine());
77+
await new ApprovalsServicePlugin({ disableAutoHooks: true }).start(ctx);
78+
await fire('kernel:ready');
79+
expect(logged.info.some((m) => m.includes('action slots backfilled'))).toBe(true);
80+
});
81+
7482
it('a failed run is logged at error, naming what stays wrong and the fix', async () => {
7583
backfill.fn.mockRejectedValueOnce(new Error('driver went away'));
7684
const { ctx, fire, logged } = fakeContext(fakeEngine());

‎packages/plugins/plugin-approvals/src/action-slot-backfill.integration.test.ts‎

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88
* the person who decided. The writers now put the person in `actor_id` and the
99
* slot in `acted_as`, and every slot reader reads `acted_as` with no fallback.
1010
* Rows written before that are moved by `backfillActionSlots`, wired on
11-
* `kernel:ready`. This pins what it moves, what it leaves alone, that it is
12-
* idempotent, and — end to end — that an in-flight multi-approver tally still
13-
* counts the votes it had already collected.
11+
* `kernel:ready`. The same column also held the two machine sweeps' sentinels
12+
* (`system:sla`, `system:dead-run`); a machine now records null (ADR-0118 D1),
13+
* and the same pass nulls the stored ones. This pins what it moves, what it
14+
* clears, what it leaves alone, that it is idempotent, and — end to end — that
15+
* an in-flight multi-approver tally still counts the votes it had already
16+
* collected.
1417
*
1518
* ## Why this file boots the real engine
1619
*
@@ -27,8 +30,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2730
import { ObjectQL } from '@objectstack/objectql';
2831
import { SqlDriver } from '@objectstack/driver-sql';
2932
import type { EngineQueryOptions } from '@objectstack/spec/data';
30-
import { ApprovalService, SLA_ACTOR_ID, DEAD_RUN_ACTOR_ID } from './approval-service.js';
31-
import { backfillActionSlots, RESERVED_MACHINE_ACTORS } from './action-slot-backfill.js';
33+
import { ApprovalService, SLA_ACTOR_ID } from './approval-service.js';
34+
import { backfillActionSlots, LEGACY_MACHINE_SENTINELS } from './action-slot-backfill.js';
3235
import { SysApprovalRequest } from './sys-approval-request.object.js';
3336
import { SysApprovalAction } from './sys-approval-action.object.js';
3437
import { SysApprovalApprover } from './sys-approval-approver.object.js';
@@ -86,11 +89,14 @@ describe('the boot-time action-slot backfill (#21411)', () => {
8689
await engine.insert('crm_deal', { id: 'D1', title: 'Deal' }, { context: SYSTEM } as any);
8790
});
8891

89-
it('the reserved machine actors it leaves alone are the service\'s own sentinels', () => {
90-
expect([...RESERVED_MACHINE_ACTORS].sort()).toEqual([SLA_ACTOR_ID, DEAD_RUN_ACTOR_ID].sort());
92+
/** The value the dead-run sweep stored in `actor_id` before it recorded null. */
93+
const DEAD_RUN_SENTINEL = 'system:dead-run';
94+
95+
it('the sentinels it clears are the two the machine sweeps stored — the SLA one is still the sweep\'s acting identity', () => {
96+
expect([...LEGACY_MACHINE_SENTINELS].sort()).toEqual([SLA_ACTOR_ID, DEAD_RUN_SENTINEL].sort());
9197
});
9298

93-
it('moves slot literals out of actor_id, stamps the votes a pending tally counts, leaves everything else — and a second run writes nothing', async () => {
99+
it('moves slot literals out of actor_id, nulls the machine sentinels, stamps the votes a pending tally counts, leaves everything else — and a second run writes nothing', async () => {
94100
// A request still collecting votes, opened by the real service: unanimous
95101
// over a position nobody holds (a literal slot) and two user-id slots.
96102
const open = await svc.openNodeRequest({
@@ -103,6 +109,10 @@ describe('the boot-time action-slot backfill (#21411)', () => {
103109
// and the slate the old tally then left.
104110
await legacy('aact_vote_literal', open.id, 'approve', 'position:finance');
105111
await legacy('aact_vote_user', open.id, 'approve', 'u9');
112+
// A sentinel row on the still-pending request, in the very shape pass 2
113+
// stamps (an approve at step 0): it took no slot, so it is cleared and
114+
// never stamped as a vote.
115+
await legacy('aact_sla_vote', open.id, 'approve', SLA_ACTOR_ID);
106116
await engine.update('sys_approval_request', { id: open.id, pending_approvers: 'u8' }, { context: SYSTEM } as any);
107117

108118
// A finished request's history.
@@ -116,7 +126,7 @@ describe('the boot-time action-slot backfill (#21411)', () => {
116126
await legacy('aact_role', done, 'comment', 'role:finance');
117127
await legacy('aact_user_done', done, 'approve', 'u7');
118128
await legacy('aact_sla', done, 'escalate', SLA_ACTOR_ID);
119-
await legacy('aact_dead', done, 'recall', DEAD_RUN_ACTOR_ID);
129+
await legacy('aact_dead', done, 'recall', DEAD_RUN_SENTINEL);
120130
await legacy('aact_system', done, 'ooo_substitute', null);
121131
// A row the NEW writer wrote: already two facts.
122132
await engine.insert('sys_approval_action', {
@@ -125,9 +135,9 @@ describe('the boot-time action-slot backfill (#21411)', () => {
125135
}, { context: SYSTEM } as any);
126136

127137
const first = await backfillActionSlots(engine as any, { pageSize: 2 });
128-
// Pass 1 moved three literals (two finished, one pending); pass 2 stamped
129-
// the one user-id vote still being counted.
130-
expect(first).toEqual({ literalsMoved: 3, votesStamped: 1 });
138+
// Pass 1 moved three literals (two finished, one pending) and nulled three
139+
// sentinels; pass 2 stamped the one user-id vote still being counted.
140+
expect(first).toEqual({ literalsMoved: 3, sentinelsCleared: 3, votesStamped: 1 });
131141

132142
// Slot literals: the slot moves to acted_as, the person is unknown — null.
133143
expect(await recorded('aact_vote_literal')).toEqual([null, 'position:finance']);
@@ -138,14 +148,23 @@ describe('the boot-time action-slot backfill (#21411)', () => {
138148
// A finished request's user-id row: no guessed slot (it might be an
139149
// override that predates via_override). Found by its person instead.
140150
expect(await recorded('aact_user_done')).toEqual(['u7', null]);
141-
// The machine sentinels, the system row and the new row: untouched.
142-
expect(await recorded('aact_sla')).toEqual([SLA_ACTOR_ID, null]);
143-
expect(await recorded('aact_dead')).toEqual([DEAD_RUN_ACTOR_ID, null]);
151+
// The machine sentinels: null, and no slot — the sweep took none.
152+
expect(await recorded('aact_sla')).toEqual([null, null]);
153+
expect(await recorded('aact_dead')).toEqual([null, null]);
154+
expect(await recorded('aact_sla_vote')).toEqual([null, null]);
155+
// Their kind and comment are what say a sweep acted, and stay as stored.
156+
expect((await action('aact_dead'))?.action).toBe('recall');
157+
// The system row and the new row: untouched.
144158
expect(await recorded('aact_system')).toEqual([null, null]);
145159
expect(await recorded('aact_new')).toEqual(['u5', 'position:legal']);
160+
// No sentinel is left anywhere in the lookup.
161+
const leftover = (await engine.find('sys_approval_action', {
162+
where: { actor_id: { $in: [...LEGACY_MACHINE_SENTINELS] } }, context: SYSTEM,
163+
} satisfies EngineQueryOptions)) as any[];
164+
expect(leftover).toEqual([]);
146165

147166
// Idempotent: nothing left that either predicate matches.
148-
expect(await backfillActionSlots(engine as any, { pageSize: 2 })).toEqual({ literalsMoved: 0, votesStamped: 0 });
167+
expect(await backfillActionSlots(engine as any, { pageSize: 2 })).toEqual({ literalsMoved: 0, sentinelsCleared: 0, votesStamped: 0 });
149168

150169
// ⭐ End to end: the last slot holder's approval finalizes the request,
151170
// because the tally counts the two votes the old writer recorded.

0 commit comments

Comments
 (0)