Skip to content

Commit 6f17d1d

Browse files
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)
Fixes #21411 Clause-②: no ## What changes `sys_approval_action.actor_id` is a lookup to `sys_user`. Under ADR-0118 D1 it holds a user id or nothing. A slot-gated action recorded the SLOT it took there instead: the literal `position:P` for a position staffed after the request opened, or an email for a `user` approver authored as one. Measured on a booted showcase at `main` `53fd35e3e`: a position-slot approve wrote 8 rows across both databases, and none of them names the person who decided. The 3 audit-ledger rows carry `user_id` / `actor` null, the 3 activity rows carry `actor_id` null, and the action row carries `actor_id` `position: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. - **Column.** `sys_approval_action.acted_as` (text, max 255) holds the pending-approver slot the action was taken as, in its stored spelling. `actor_id` gains its ADR-0118 D1 describe. Both are in `highlightFields` and the `recent` / `all_actions` grids, as `via_override` was. Translations are regenerated, with zh-CN, ja-JP and es-ES written by hand. - **Writes.** - Every insert in `decideNode`, `sendBack` (revise and auto-reject), `reassign`, `requestInfo`, `comment`, `recall`, `resubmit` and `remind` records `actor_id: recordedActor(actorId, context)`. That is the person the context vouches for, never an address the caller named. The slot-gated ones also record `acted_as: slot ?? null`. - A system context that vouches for nobody records nobody. One exception, kept by name: the SLA sweep's reserved `system:sla`, which is #21455's. - The action link (`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. - **Readers.** - The multi-approver tally and `decision_progress` read `acted_as`. There is no `??` to `actor_id` anywhere. - The already-acted probe follows Q3 = A: `actor_id` equals the caller's user id, OR `acted_as` is in `actingAddresses(caller)`. These are two facts, each compared only with its own identity kind. - `listActions` maps `acted_as` onto `ApprovalActionRow.acted_as`, which #21458 declared and which merged first, beside `actor_id` and `actor_name`. A row no slot admitted omits the member. - **Boot-time backfill** (`action-slot-backfill.ts`). It is hooked on `kernel:ready` beside `backfillApproverIndex`, the plugin's existing boot-time repair, so it ships as code and no agent runs a bulk write. - Pass 1 covers the whole history. A row whose `actor_id` holds a slot address (contains `:` or `@`, and is not one of the reserved `system:sla` / `system:dead-run`) gets `acted_as := actor_id` and `actor_id := null`. No stored record names its decider, so null is ADR-0118's value, not a guess. - Pass 2 covers the approve votes, at step 0, of still-pending requests whose `acted_as` is empty: they get `acted_as := actor_id`. This is exact, not a guess, because an override finalizes the node. - No other row is stamped. Finished user-id rows are read by their person. - It is idempotent (both predicates are empty after a run). A failure logs at `error` with the consequence and the fix. - **Docs.** `content/docs/automation/approvals.mdx`: the sentence that said the decision is recorded in `actor_id` under its slot now names both columns. ## Pins - `approval-service.test.ts`: - The #21379 slot tests now assert `[actor_id, acted_as]`. - A #21411 block covers: - position, email and user-id slots through the session door; - the action link: user id, an email with an account, an email with none; - an override recording the admin and no slot, even when the admin named a position address; - a system context recording nobody, with the SLA sentinel kept; - the `per_group` tally and `decision_progress` counting slots; - both probe halves, including a holder who lost the position, a legacy row found by its person, and a literal left in `actor_id` never read as a slot; - the action log showing person, name and slot, and a backfilled row showing the slot alone. - `approver-address-readers.test.ts`, the enumeration pin: `acted_as` replaces `actor_id` among the slot columns. Every read of `actor_id` in the package is classified with its count. The only comparison is with the caller's user id (`actor_id: uid` in `visibleRequestIds`). - `action-slot-backfill.integration.test.ts` runs on a real `ObjectQL` with `SqlDriver` (better-sqlite3, in-memory) and the real DDL: - literals are moved; - a pending vote is stamped; - a finished user-id row, the sentinels, a system row and a new-style row are untouched; - a second run writes `{0, 0}`; - end to end, an in-flight unanimous request still finalizes on the votes the old writer recorded. - `action-slot-backfill-wiring.test.ts`: the plugin runs the repair once at `kernel:ready` (never at `start()`), against its own engine, and logs a failure at `error`. - `packages/qa/dogfood/test/position-address-readers.dogfood.test.ts`, a cross-lane `domain:cli` addition declared on #6024: `recorded()` reads `actor_id`, `acted_as` and `via_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 and `git diff HEAD` empty) Legs a to f ran at `b668cf134`; leg g ran at `dc6d72a9c`. Each direction was predicted before its run. | leg | mutation | predicted | observed | | --- | --- | --- | --- | | a | `decideNode` writes no `acted_as` | red, more than the slot pins (the tally too) | 24 failed / 304 passed: every slot pin, plus the unanimous, quorum and per_group tallies and the probe. ⚠ The first attempt was a no-op: the tool refused it because the replacement text was a substring of the anchor (count 1 to 1), and it restored. Re-run with a distinct replacement. | | b | tally reads `a.actor_id` | red: tally pins, backfill end to end, enumeration pin | 14 failed / 320 passed across all 3 files | | b2 | `decision_progress` reads `a.actor_id` | red: per_group progress pin and enumeration pin | 4 failed / 328 passed | | c | backfill pass 2 writes nothing | red: backfill pin | 1 failed / 1 passed | | d | backfill pass 1 writes nothing | red: backfill pin | 1 failed / 1 passed | | e | the `kernel:ready` hook is removed | red: wiring pin | 2 failed | | f | the probe's `{ actor_id: uid }` half is removed | red: person pins and enumeration pin | 3 failed / 329 passed | | g | `listActions` stops mapping `acted_as` | red: action-log pin only | 1 failed / 328 passed | ## Gates, at `dc6d72a9c` - `dispatch-gates --repo objectstack-ai/objectstack --commands` derived 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. `--ran` with exit codes answers: 97 derived, 97 run, 0 NOT-MEASURED (a derived zero). - `pnpm --filter @objectstack/plugin-approvals typecheck`: exit 0 (`check:test-typecheck` OK). `pnpm --filter @objectstack/plugin-approvals test`: 58 files, 868 tests passed. - The dogfood file: 1 of 1 passed. `pnpm --filter @objectstack/dogfood typecheck`: exit 0. - Earlier reds, each repaired in this PR: - `check:engine-double-contract`: the wiring test's double now declares no write verbs. - `check-tenant-audit-census`: the backfill adds 2 elevated `update` sites (229 to 231 write call sites, 110 to 112 elevated). `node scripts/tenant-audit-census.mjs --write` regenerated both census tables, and the page's hand-written prose counts were updated to match. This adds `content/docs/permissions/tenant-audit-census.mdx` and `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` to the diff, declared here. ## Changeset `@objectstack/plugin-approvals` patch, `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 - **One widening, by ruling (Q3 = A):** a person who decided under a position they later lost keeps sight of that request. The old probe hid it. - **Behaviour narrowed for system contexts:** a machine caller that names an actor without vouching for a person on the context now records `actor_id` null, 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 asserted `actor_id` moved to a vouching context. - **Out of this PR, the ADR-0118 D1 family on #21455:** the SLA and dead-run sentinels in `actor_id`, `reassign_from` / `reassign_to` holding slot literals and emails (`reassign_from` still records the slot handed over; a pin here names it), and `sys_notification.actor_id` fed by `notify`. - **Console:** rendering `acted_as` beside 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](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9ff7428 commit 6f17d1d

17 files changed

Lines changed: 1063 additions & 102 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
An approval action now records the user who took it in `sys_approval_action.actor_id`, and the pending-approver slot it was taken as in a new `acted_as` column; rows stored before this move their slot out of `actor_id` at the next boot
6+
7+
Clause-②: no
8+
9+
`actor_id` is a lookup to `sys_user`, so under ADR-0118 D1 it holds a user id or nothing. A slot-gated action used to record the slot it took there instead: a `position:<name>` literal for a position staffed after the request opened, or an email for a `user` approver authored as one. On those decisions no record named the person who decided. The audit ledger and activity rows the write produces carry no user, so the attribution was lost, and every join or report on the lookup silently dropped the row.
10+
11+
**This supersedes the "What is recorded" sentence of the unreleased `21379-position-address-readers` changeset**, which says `actor_id` holds the slot. From this release it holds the person.
12+
13+
- **What is recorded.**
14+
- `actor_id` is the user the request's context vouches for: the signed-in caller, whatever address they named.
15+
- `acted_as` is the slot the action took, in the slot's stored spelling (a user id, an email, or `position:<name>`). It is empty on actions no slot admitted: the submitter's own actions, system actions, and an admin override, which `via_override` still marks.
16+
- An emailed action link records the one account that carries the token's email. If no account carries it, the link records no person.
17+
- The SLA sweep keeps its reserved `system:sla` actor for now.
18+
- **What reads it.**
19+
- The multi-approver tally and `decision_progress` count `acted_as`.
20+
- A participant who already acted keeps sight of a request by either of two facts: `actor_id` is their user id, or `acted_as` is a slot they act under (so a decision taken as `position:<name>` stays visible to that position's holders).
21+
- Nothing compares a slot with `actor_id` any more.
22+
- The action log (`GET /api/v1/approvals/requests/:id/actions`, `listActions`) returns `acted_as` beside `actor_id` and `actor_name`, filling the `ApprovalActionRow.acted_as` member `@objectstack/spec` declares. It is omitted when the action took no slot, or when no stored record kept the slot.
23+
- **Stored rows.** A repair runs on every boot and is idempotent.
24+
- Pass 1: a row whose `actor_id` still holds a slot address gets `acted_as` set to it and `actor_id` cleared. No stored record names who decided it, so it shows the slot and no person.
25+
- Pass 2: the approve votes a still-pending request's tally counts get their `acted_as`, so in-flight `unanimous`, `quorum` and `per_group` requests keep the approvals they already collected.
26+
- A failure is logged at error level and retried at the next boot.
27+
- **For a report or integration that read `actor_id` as the slot:** read `acted_as` instead. `actor_id` now always joins to `sys_user`.

‎content/docs/automation/approvals.mdx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -509,9 +509,10 @@ opened, once someone is staffed into it). A named `actorId` must be one of those
509509
identities, and a position named under either spelling takes that position's
510510
slot. No such slot and no admin override returns 403 (`FORBIDDEN: actor '…' is
511511
not a pending approver`); a request that isn't pending returns 409
512-
(`INVALID_STATE`). The decision is recorded in `sys_approval_action.actor_id`
513-
under the slot it took, in that slot's stored spelling — the multi-approver
514-
tally counts approvals by matching that value against the slate. Always go through
512+
(`INVALID_STATE`). The decision records two facts on its `sys_approval_action`
513+
row: `actor_id` is the user who decided, and `acted_as` is the slot the decision
514+
took, in that slot's stored spelling — the multi-approver tally counts approvals
515+
by matching `acted_as` against the slate. Always go through
515516
these endpoints — never resume the flow run directly, and since #3801 you
516517
**cannot**: `POST /api/v1/automation/{flow}/runs/{runId}/resume` answers 403 for
517518
a run parked on an `approval` node (including via a `subflow` pause) and changes

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way.
122122

123123
The same holds twice over for the context. An options argument spelled as a
124124
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
125-
forwarding shim cannot, and **67 of the 229 sites are spelled that way**. A
125+
forwarding shim cannot, and **67 of the 231 sites are spelled that way**. A
126126
context resolved from an inline literal or a local `const` can be tested for
127127
`isSystem`; one arriving from a helper call cannot.
128128

@@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
187187

188188
| carried figure | where it survives | this census |
189189
| :--- | :--- | ---: |
190-
| 175 write call sites | quoted in the merged changeset | **229** |
190+
| 175 write call sites | quoted in the merged changeset | **231** |
191191
| 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **34** more whose options argument is unreadable |
192-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **152 of 229** decidable, **77** undecidable |
193-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 110 decidably elevated, 0 decidably not, 102 undecidable |
192+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **154 of 231** decidable, **77** undecidable |
193+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 112 decidably elevated, 0 decidably not, 102 undecidable |
194194
| 141 and 132, two independent re-derivations | the card that filed this work | — |
195195

196196
**The differences are not reconciled, and deliberately so.** The old census's
@@ -207,11 +207,11 @@ would report a smaller number and would not say so.
207207

208208
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
209209
figure has no surviving corroboration anywhere in the tree.** This census reads
210-
110 of 229 (48%) as decidably elevated, with 102 more whose elevation is a
210+
112 of 231 (48%) as decidably elevated, with 102 more whose elevation is a
211211
run-time fact — so the claim is neither confirmed nor refuted, and the honest
212212
answer is that a static reading cannot settle it.
213213

214-
⇒ **Cite `9 / 229`, and say what it is**: the sites whose options argument was
214+
⇒ **Cite `9 / 231`, and say what it is**: the sites whose options argument was
215215
READ and holds no tenant context, against a decidably tenancy-enabled object.
216216
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
217217
without tenant context" — **34 further sites** have an options argument this
@@ -223,28 +223,28 @@ cannot read, and they are neither in nor out.
223223

224224
| what | count |
225225
| :--- | ---: |
226-
| write call sites on the application surface | **229** |
227-
| …whose object name is statically decidable | 152 |
226+
| write call sites on the application surface | **231** |
227+
| …whose object name is statically decidable | 154 |
228228
| …whose object name is chosen at run time | 77 |
229-
| …against an object with tenancy ENABLED | 151 |
229+
| …against an object with tenancy ENABLED | 153 |
230230
| …against an object that declares tenancy off | 1 |
231-
| threading a tenant context | 145 |
231+
| threading a tenant context | 147 |
232232
| PROVABLY carrying none (options read, no context key) | **17** |
233233
| …of those, against a decidably tenancy-enabled object | **9** |
234234
| options argument UNREADABLE — may or may not carry one | 67 |
235235
| …of those, against a decidably tenancy-enabled object | 34 |
236-
| threading a decidably ELEVATED (`isSystem`) context | 110 |
236+
| threading a decidably ELEVATED (`isSystem`) context | 112 |
237237
| threading a context that is decidably NOT elevated | 0 |
238238
| threading a context whose elevation is a run-time fact | 102 |
239239

240240
| how the instrument reached the site | count |
241241
| :--- | ---: |
242-
| receiver carried a readable engine type | 181 |
242+
| receiver carried a readable engine type | 183 |
243243
| receiver erased, placed by the object NAME | 28 |
244244
| receiver erased, placed by an `object: string` PARAMETER | 15 |
245245
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
246246

247-
| object name spelled inline | 101 |
247+
| object name spelled inline | 103 |
248248
| object name spelled through a `const` | 51 |
249249
| object name is an `object: string` parameter | 17 |
250250
| object name is some other run-time expression | 60 |
@@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
297297
their values are not compared. The reasoning, and the measurement behind it,
298298
are in `scripts/check-tenant-audit-census.mjs`.
299299

300-
Measured on 2026-10-02 at `c41817b12`.
300+
Measured on 2026-10-02 at `b668cf134`.
301301

302302
| corpus scale (not enforced) | count |
303303
| :--- | ---: |
304-
| tracked non-test sources scanned | 599 |
305-
| engine-shaped types recognised | 66 |
304+
| tracked non-test sources scanned | 602 |
305+
| engine-shaped types recognised | 67 |
306306
| declared objects in the registry | 116 |
307-
| same-named calls subtracted as non-engine | 150 |
307+
| same-named calls subtracted as non-engine | 152 |
308308

309309
{/* END GENERATED: tenant-audit-census */}

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3333

3434
| Measure | Value |
3535
|---|---:|
36-
| Write call sites | 229 |
37-
| Object name statically decidable | 152 |
36+
| Write call sites | 231 |
37+
| Object name statically decidable | 154 |
3838
| Object name chosen at run time | 77 |
39-
| Against a tenancy-enabled object | 151 |
39+
| Against a tenancy-enabled object | 153 |
4040
| Against an object declaring tenancy off | 1 |
41-
| Threading a tenant context | 145 |
41+
| Threading a tenant context | 147 |
4242
| Provably carrying none | 17 |
4343
| …and decidably tenancy-enabled | 9 |
4444
| Options argument unreadable | 67 |
4545
| …and decidably tenancy-enabled | 34 |
46-
| Threading a decidably elevated context | 110 |
46+
| Threading a decidably elevated context | 112 |
4747
| Threading a decidably non-elevated context | 0 |
4848
| Threading a context of undecidable elevation | 102 |
4949

@@ -90,21 +90,22 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-10-02 at `c41817b12`.
93+
Measured on 2026-10-02 at `b668cf134`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 599 |
98-
| engine-shaped types recognised | 66 |
97+
| tracked non-test sources scanned | 602 |
98+
| engine-shaped types recognised | 67 |
9999
| declared objects in the registry | 116 |
100-
| same-named calls subtracted as non-engine | 150 |
100+
| same-named calls subtracted as non-engine | 152 |
101101

102102
## Every site
103103

104104
| file | verb | object | tenancy | tenant context | n |
105105
|---|---|---|---|---|---:|
106106
| `packages/plugins/organizations/src/claim-org-seed-ownership.ts` | `update` | `schema.name` | undecidable | elevated | 1 |
107107
| `packages/plugins/organizations/src/claim-orphan-org-rows.ts` | `update` | `schema.name` | undecidable | elevated | 1 |
108+
| `packages/plugins/plugin-approvals/src/action-slot-backfill.ts` | `update` | `sys_approval_action` | enabled | elevated | 2 |
108109
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 |
109110
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 14 |
110111
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `delete` | `sys_approval_approver` | enabled | elevated | 2 |
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #21411 — the action-slot backfill is WIRED: `ApprovalsServicePlugin.start()`
5+
* registers it on `kernel:ready`, hands it the plugin's own engine, and logs a
6+
* failure at `error`.
7+
*
8+
* `action-slot-backfill.integration.test.ts` proves what the repair does. This
9+
* file proves it runs at all — a repair module nothing calls is code `grep`
10+
* finds and production never executes, and every slot reader would then miss
11+
* the rows it exists to move, silently. The module is replaced by a spy so the
12+
* assertion is about the call, not about the repair.
13+
*/
14+
15+
import { describe, it, expect, vi, beforeEach } from 'vitest';
16+
17+
const backfill = vi.hoisted(() => ({
18+
fn: vi.fn(async (_engine: unknown) => ({ literalsMoved: 0, votesStamped: 0 })),
19+
}));
20+
21+
vi.mock('./action-slot-backfill.js', async (importOriginal) => ({
22+
...(await importOriginal<typeof import('./action-slot-backfill.js')>()),
23+
backfillActionSlots: backfill.fn,
24+
}));
25+
26+
import { ApprovalsServicePlugin } from './approvals-plugin.js';
27+
28+
function fakeContext(engine: unknown) {
29+
const hooks: Record<string, Array<() => Promise<void> | void>> = {};
30+
const logged = { info: [] as string[], warn: [] as string[], error: [] as string[] };
31+
const ctx: any = {
32+
hook: (name: string, fn: () => Promise<void> | void) => { (hooks[name] ??= []).push(fn); },
33+
getService: (name: string) => {
34+
if (name === 'objectql') return engine;
35+
throw new Error(`no service '${name}'`);
36+
},
37+
registerService: () => {},
38+
logger: {
39+
info: (msg: string) => { logged.info.push(String(msg)); },
40+
warn: (msg: string) => { logged.warn.push(String(msg)); },
41+
error: (msg: string) => { logged.error.push(String(msg)); },
42+
debug: () => {},
43+
},
44+
};
45+
const fire = async (name: string) => { for (const fn of hooks[name] ?? []) await fn(); };
46+
return { ctx, fire, logged };
47+
}
48+
49+
/**
50+
* Enough engine for `start()` with `disableAutoHooks`: the service only needs
51+
* an object to hold, and the one boot-time read that reaches it (the
52+
* approver-index rebuild) finds nothing. The repair itself is the spy above,
53+
* so nothing here writes — and a double with no write verbs makes no claim
54+
* about how writes dispatch.
55+
*/
56+
function fakeEngine() {
57+
return { find: async () => [] };
58+
}
59+
60+
describe('the action-slot backfill is wired on kernel:ready (#21411)', () => {
61+
beforeEach(() => { backfill.fn.mockClear(); });
62+
63+
it('runs once at kernel:ready, never before, against the plugin\'s own engine', async () => {
64+
const engine = fakeEngine();
65+
const { ctx, fire } = fakeContext(engine);
66+
await new ApprovalsServicePlugin({ disableAutoHooks: true }).start(ctx);
67+
68+
expect(backfill.fn, 'not at start(): the registries are still filling').not.toHaveBeenCalled();
69+
await fire('kernel:ready');
70+
expect(backfill.fn).toHaveBeenCalledTimes(1);
71+
expect(backfill.fn.mock.calls[0][0]).toBe(engine);
72+
});
73+
74+
it('a failed run is logged at error, naming what stays wrong and the fix', async () => {
75+
backfill.fn.mockRejectedValueOnce(new Error('driver went away'));
76+
const { ctx, fire, logged } = fakeContext(fakeEngine());
77+
await new ApprovalsServicePlugin({ disableAutoHooks: true }).start(ctx);
78+
await fire('kernel:ready');
79+
80+
const line = logged.error.find((m) => m.includes('action-slot backfill failed'));
81+
expect(line, `error lines: ${JSON.stringify(logged.error)}`).toBeDefined();
82+
expect(line).toMatch(/multi-approver tallies/);
83+
expect(line).toMatch(/restart/);
84+
expect(logged.warn.some((m) => m.includes('action-slot backfill'))).toBe(false);
85+
});
86+
});

0 commit comments

Comments
 (0)