@cosmicdrift/kumiko-bundled-features 0.234.0 → 0.235.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.234.0",
3
+ "version": "0.235.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -130,12 +130,12 @@
130
130
  "./workflow-runner": "./src/workflow-runner/index.ts"
131
131
  },
132
132
  "dependencies": {
133
- "@cosmicdrift/kumiko-dispatcher-live": "0.234.0",
134
- "@cosmicdrift/kumiko-framework": "0.234.0",
135
- "@cosmicdrift/kumiko-headless": "0.234.0",
136
- "@cosmicdrift/kumiko-renderer": "0.234.0",
137
- "@cosmicdrift/kumiko-renderer-web": "0.234.0",
138
- "@cosmicdrift/kumiko-types": "0.234.0",
133
+ "@cosmicdrift/kumiko-dispatcher-live": "0.235.0",
134
+ "@cosmicdrift/kumiko-framework": "0.235.0",
135
+ "@cosmicdrift/kumiko-headless": "0.235.0",
136
+ "@cosmicdrift/kumiko-renderer": "0.235.0",
137
+ "@cosmicdrift/kumiko-renderer-web": "0.235.0",
138
+ "@cosmicdrift/kumiko-types": "0.235.0",
139
139
  "@mollie/api-client": "^4.5.0",
140
140
  "@node-rs/argon2": "^2.0.2",
141
141
  "@types/mailparser": "^3.4.6",
@@ -164,7 +164,7 @@
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
166
  "@types/qrcode": "^1.5.5",
167
- "@cosmicdrift/kumiko-locale-de": "0.234.0",
168
- "@cosmicdrift/kumiko-locale-es": "0.234.0"
167
+ "@cosmicdrift/kumiko-locale-de": "0.235.0",
168
+ "@cosmicdrift/kumiko-locale-es": "0.235.0"
169
169
  }
170
170
  }
@@ -1,6 +1,11 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
4
+ import {
5
+ configureBlindIndexKey,
6
+ configurePiiSubjectKms,
7
+ InMemoryKmsAdapter,
8
+ } from "@cosmicdrift/kumiko-framework/crypto";
4
9
  import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
10
  import {
6
11
  createTestUser,
@@ -16,6 +21,9 @@ import {
16
21
  expectErrorIncludes,
17
22
  getSetCookieRaw,
18
23
  getSetCookieValue,
24
+ resetBlindIndexKeyForTests,
25
+ resetPiiSubjectKmsForTests,
26
+ updateRows,
19
27
  } from "@cosmicdrift/kumiko-framework/testing";
20
28
  import { createConfigFeature } from "../../config";
21
29
  import { createConfigResolver } from "../../config/resolver";
@@ -215,6 +223,50 @@ describe("scenario 3: login without membership", () => {
215
223
  });
216
224
  });
217
225
 
226
+ // --- Scenario 3b: login resolves the live account when its email is shared
227
+ // with a soft-deleted row (fw#2464 partial bidx index only covers live rows) ---
228
+
229
+ describe("scenario 3b: login with an email shared by a soft-deleted row", () => {
230
+ // The DB-level dedup that lets a soft-deleted row and a live row share an
231
+ // email only exists on the blind-index column (see
232
+ // email-unique-blind-index.integration.test.ts) — without a configured
233
+ // key, email_bidx stays NULL and the plaintext-fallback unique index
234
+ // blocks the second create outright, never reaching the login path.
235
+ beforeAll(() => {
236
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
237
+ configureBlindIndexKey(Buffer.alloc(32, 7).toString("base64"));
238
+ });
239
+
240
+ afterAll(() => {
241
+ resetPiiSubjectKmsForTests();
242
+ resetBlindIndexKeyForTests();
243
+ });
244
+
245
+ test("new account wins the login, not the soft-deleted original", async () => {
246
+ const original = await seedLoginUser({
247
+ email: "reused-login@example.com",
248
+ password: "original-password-123",
249
+ });
250
+ await updateRows(stack.db, userTable, { isDeleted: true }, { id: original.id });
251
+
252
+ const recreated = await seedLoginUser({
253
+ email: "reused-login@example.com",
254
+ password: "new-account-password-123",
255
+ });
256
+ expect(recreated.id).not.toBe(original.id);
257
+
258
+ const res = await stack.http.raw("POST", "/api/auth/login", {
259
+ email: "reused-login@example.com",
260
+ password: "new-account-password-123",
261
+ });
262
+
263
+ expect(res.status).toBe(200);
264
+ const body = await res.json();
265
+ expect(body.isSuccess).toBe(true);
266
+ expect(body.user.id).toBe(recreated.id);
267
+ });
268
+ });
269
+
218
270
  // --- Scenario 4 + 5: change-password flow ---
219
271
 
220
272
  describe("scenario 4: change-password with wrong old password", () => {
@@ -139,11 +139,13 @@ export async function provisionSignupAccount(
139
139
  // create paths (#1478 — this was only wired for tenant before).
140
140
  hooks?: SeedTenantHooks,
141
141
  ): Promise<{ readonly userId: string; readonly tenantId: TenantId }> {
142
- // Create-only-Guard VOR seedTenant: bei bereits registrierter Email hart
143
- // abbrechen, sonst entstünde ein verwaister Tenant und seedUser (idempotent
144
- // add-only) gäbe still die bestehende userId zurück → Confirm mintet eine
145
- // Session für den fremden Account (#365).
146
- const existingUser = await fetchOne(db, userTable, { email: options.email });
142
+ // Create-only guard BEFORE seedTenant: hard-abort on an already-registered
143
+ // email, otherwise an orphaned tenant would be created and seedUser
144
+ // (idempotent add-only) would silently return the existing userId ->
145
+ // confirm mints a session for the wrong account (#365).
146
+ // isDeleted: false — the partial bidx unique index only covers live rows,
147
+ // so a soft-deleted row must not block re-registering its email.
148
+ const existingUser = await fetchOne(db, userTable, { email: options.email, isDeleted: false });
147
149
  if (existingUser) {
148
150
  throw new ConflictError({ message: "signup: email already registered" });
149
151
  }
@@ -1,9 +1,4 @@
1
- type LocalizedString = { readonly en: string };
1
+ import { SETTINGS_HUB_I18N } from "@cosmicdrift/kumiko-framework/i18n";
2
2
 
3
3
  /** Server boot keys for the auto-generated Settings-Hub (mirrors web/i18n.ts). */
4
- export const CONFIG_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {
5
- "config.settings.title": { en: "Settings" },
6
- "config.settings.system": { en: "Platform" },
7
- "config.settings.tenant": { en: "Tenant" },
8
- "config.settings.user": { en: "Personal" },
9
- };
4
+ export const CONFIG_FEATURE_I18N = SETTINGS_HUB_I18N;
@@ -11,6 +11,13 @@ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
11
11
 
12
12
  export const defaultTranslations: TranslationsByLocale = {
13
13
  en: {
14
+ "config.secrets.delete": "Delete",
15
+ "config.secrets.notSet": "Not set",
16
+ "config.secrets.placeholder": "Enter a value",
17
+ "config.secrets.replacePlaceholder": "Enter a new value to replace",
18
+ "config.secrets.required": "Required",
19
+ "config.secrets.set": "Set",
20
+ "config.secrets.title": "Secrets",
14
21
  "config.settings.title": "Settings",
15
22
  "config.settings.system": "Platform",
16
23
  "config.settings.tenant": "Tenant",
@@ -18,5 +25,9 @@ export const defaultTranslations: TranslationsByLocale = {
18
25
  "config.errors.systemOnly": "This value can only be set by the system.",
19
26
  "config.errors.invalidScope": "This scope is not allowed for this key.",
20
27
  "config.errors.unknownKey": "Unknown configuration key.",
28
+ // Required by every generated screen (screenTitleKey, required-surface-keys.ts) —
29
+ // the secrets screen has a fixed id ("secrets"), so the framework ships its
30
+ // title translation directly instead of asking every app to declare it.
31
+ "screen:secrets.title": "Secrets",
21
32
  },
22
33
  };
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
3
  import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
3
4
  import {
@@ -55,7 +56,8 @@ export const subjectForgottenSchema = z.object({
55
56
  });
56
57
 
57
58
  export const subjectForgetDeniedSchema = z.object({
58
- subjectKey: z.string().min(1),
59
+ subjectKeyDigest: z.string().min(1),
60
+ subjectKind: z.enum(["user", "tenant"]),
59
61
  reason: z.string().min(10),
60
62
  forgottenBy: z.string().min(1),
61
63
  actorTenantId: z.string().min(1),
@@ -138,13 +140,21 @@ export const forgetSubjectWrite = defineWriteHandler({
138
140
  raw,
139
141
  );
140
142
  if (tenantScopeDenial) {
141
- // Denied cross-tenant probes must still leave an audit trail (fw#2348).
143
+ // Denied cross-tenant probes must still leave an audit trail (fw#2348),
144
+ // but the denial lands in the REQUESTING actor's own tenant-scoped
145
+ // stream — it must never materialise the foreign subject's identifiers
146
+ // there. A plaintext subjectKey/aggregateId would survive as a
147
+ // permanent record for the prober and would still be present when the
148
+ // owning tenant later runs its own (legitimate) forget-subject for that
149
+ // subject. The digest still lets an operator correlate repeated probes
150
+ // of the same subject without exposing it (fw#2452).
142
151
  await ctx.unsafeAppendEvent({
143
- aggregateId: raw.kind === "user" ? raw.userId : raw.tenantId,
152
+ aggregateId: event.user.id,
144
153
  aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
145
154
  type: SUBJECT_FORGET_DENIED_EVENT_NAME,
146
155
  payload: {
147
- subjectKey,
156
+ subjectKeyDigest: createHash("sha256").update(subjectKey, "utf8").digest("base64url"),
157
+ subjectKind: raw.kind,
148
158
  reason: event.payload.reason,
149
159
  forgottenBy: event.user.id,
150
160
  actorTenantId: event.user.tenantId,
@@ -451,10 +451,12 @@ describe("jobs:write:retry decrypts payload before dispatch (#2465)", () => {
451
451
  const RETRY_USER_ID = "u-pii-retry-1";
452
452
  const RETRY_JOB_NAME = "retryapp:job:capture-import";
453
453
  const capturedPayloads: Record<string, unknown>[] = [];
454
+ const capturedTriggeredBy: (string | null)[] = [];
454
455
 
455
456
  const retryAppFeature = defineFeature("retryapp", (r) => {
456
- r.job("captureImport", { trigger: { manual: true } }, async (payload) => {
457
+ r.job("captureImport", { trigger: { manual: true } }, async (payload, ctx) => {
457
458
  capturedPayloads.push(payload);
459
+ capturedTriggeredBy.push(ctx.triggeredBy?.id ?? null);
458
460
  });
459
461
  });
460
462
 
@@ -476,6 +478,7 @@ describe("jobs:write:retry decrypts payload before dispatch (#2465)", () => {
476
478
 
477
479
  beforeEach(() => {
478
480
  capturedPayloads.length = 0;
481
+ capturedTriggeredBy.length = 0;
479
482
  retryKms = new InMemoryKmsAdapter();
480
483
  configurePiiSubjectKms(retryKms);
481
484
  });
@@ -527,6 +530,40 @@ describe("jobs:write:retry decrypts payload before dispatch (#2465)", () => {
527
530
  expect(errInfo.details).toMatchObject({ reason: "job_payload_erased" });
528
531
  });
529
532
 
533
+ // #2469: retry dispatched without a `meta` argument, so the retried run
534
+ // carried no triggeredById at all — job-run-logger's onJobStart then saw
535
+ // triggeredById: null, skipped encryption entirely, and the retried run's
536
+ // payload landed in job_runs as unshreddable plaintext. The asserted
537
+ // invariant is the input to that chain: the retried BullMQ job must carry
538
+ // the ORIGINAL run's owner, not the retrying SystemAdmin and not null.
539
+ // (Asserting the retried run's job_runs row directly is not possible here:
540
+ // this stack wires no job-run-logger into the runner, so a real dispatch
541
+ // writes no row — every other row in this file is seeded by calling
542
+ // retryLogger.onJobStart by hand.)
543
+ test("retry re-dispatches with the original run's triggeredById so the new run's payload stays encrypted", async () => {
544
+ await retryLogger.onJobStart?.(RETRY_JOB_NAME, "bull-retry-5", {
545
+ triggeredById: RETRY_USER_ID,
546
+ payload: SECRET_PAYLOAD,
547
+ });
548
+ await retryLogger.onJobFailed?.(RETRY_JOB_NAME, "bull-retry-5", "boom", []);
549
+
550
+ const originalRow = await fetchOne(retryStack.db, jobRunsTable, { bullJobId: "bull-retry-5" });
551
+ expect(isPiiCiphertext(originalRow?.["payload"])).toBe(true);
552
+ expect(originalRow?.["triggeredById"]).toBe(RETRY_USER_ID);
553
+
554
+ await retryStack.http.writeOk<{
555
+ jobName: string;
556
+ bullJobId: string;
557
+ retriedFromRunId: string;
558
+ }>(JobHandlers.retry, { runId: originalRow?.["id"] }, TestUsers.systemAdmin);
559
+
560
+ await sleep(1000);
561
+
562
+ expect(capturedTriggeredBy).toEqual([RETRY_USER_ID]);
563
+ expect(capturedTriggeredBy[0]).not.toBe(TestUsers.systemAdmin.id);
564
+ expect(capturedPayloads).toEqual([JSON.parse(SECRET_PAYLOAD)]);
565
+ });
566
+
530
567
  // The other producer of PII_ERASED_SENTINEL: job-run-logger writes the
531
568
  // literal "[[erased]]" string (not ciphertext) when the key is already
532
569
  // gone at onJobStart time (see the mirrored case above this describe
@@ -18,6 +18,7 @@ type JobRunRow = {
18
18
  readonly status: string;
19
19
  readonly jobName: string;
20
20
  readonly payload: string | null;
21
+ readonly triggeredById: string | null;
21
22
  };
22
23
 
23
24
  export const retryWrite = defineWriteHandler({
@@ -61,6 +62,7 @@ export const retryWrite = defineWriteHandler({
61
62
  // PII_ERASED_SENTINEL, which is not JSON; reject the retry rather than
62
63
  // dispatch it with a silently emptied payload.
63
64
  let payload: Record<string, unknown> = {};
65
+ let decryptedPayloadJson: string | null = null;
64
66
  if (run.payload) {
65
67
  const decryptedPayload = await decryptStoredPii(
66
68
  run.payload,
@@ -78,9 +80,14 @@ export const retryWrite = defineWriteHandler({
78
80
  decryptedPayload,
79
81
  `job run ${event.payload.runId} payload`,
80
82
  );
83
+ decryptedPayloadJson = decryptedPayload;
81
84
  }
82
85
 
83
- const bullJobId = await jobRunner.dispatch(run.jobName, payload);
86
+ // Retry re-uses the original run's DEK owner so the retry's logs/payload stay encrypted.
87
+ const bullJobId = await jobRunner.dispatch(run.jobName, payload, {
88
+ triggeredById: run.triggeredById ?? undefined,
89
+ ...(decryptedPayloadJson !== null && { payload: decryptedPayloadJson }),
90
+ });
84
91
 
85
92
  return {
86
93
  isSuccess: true,
@@ -110,8 +110,10 @@ routes.
110
110
 
111
111
  ## Boot check
112
112
 
113
- `r.job` with `runOnBoot: true` checks at app start whether the
114
- required blocks exist in SYSTEM_TENANT. **Default** (DACH):
113
+ `r.job` with `bootGate: true` runs inline while the API job runner
114
+ starts and checks whether the required blocks exist in SYSTEM_TENANT.
115
+ A throw rejects the boot, so the process never reports ready.
116
+ **Default** (DACH):
115
117
 
116
118
  | Slug + locale | What happens when missing |
117
119
  |---|---|
@@ -12,7 +12,7 @@ import {
12
12
  } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
13
13
  import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/template-resolver/seeding";
14
14
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
15
- import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { createRegistry, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
16
16
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
17
17
  import {
18
18
  setupTestStack,
@@ -226,10 +226,21 @@ describe("markdown render helpers", () => {
226
226
  });
227
227
  });
228
228
 
229
- // Boot-Check direkt (ohne dev-server-Job-Runner-Path) — verifiziert
230
- // dass die Logik fehlende Blocks im SYSTEM_TENANT erkennt. Der eigentliche
231
- // runOnBoot-Trigger lebt im JobRunner und wird in jobs-feature integration-
232
- // tests separately exercised.
229
+ describe("legal-pages :: boot gate wiring", () => {
230
+ // The abort behaviour itself is proven in the framework's job-runner
231
+ // integration tests ("boot gates"); this only pins that legal-pages is
232
+ // wired onto that path and not onto the fire-and-forget runOnBoot one.
233
+ test("boot check is declared as a bootGate, not as runOnBoot", () => {
234
+ const registry = createRegistry([textFeature, legalFeature]);
235
+ const job = registry.getJob("legal-pages:job:legal-pages-boot-check");
236
+ expect(job).toBeDefined();
237
+ expect(job?.bootGate).toBe(true);
238
+ expect(job?.runOnBoot).toBeUndefined();
239
+ });
240
+ });
241
+
242
+ // Boot check called directly (without the dev-server job-runner path) —
243
+ // verifies that the logic detects blocks missing in SYSTEM_TENANT.
233
244
  describe("legal-pages :: SYSTEM_TENANT-routing (production-bug-regression)", () => {
234
245
  test("legal-pages serven SYSTEM_TENANT-Texte auch wenn tenantResolver einen anderen Tenant zurückgibt", async () => {
235
246
  // Simuliert publicstatus's Setup: host-basierter tenantResolver der
@@ -208,7 +208,10 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
208
208
  "legal-pages-boot-check",
209
209
  {
210
210
  trigger: { manual: true },
211
- runOnBoot: true,
211
+ // bootGate, not runOnBoot: only an inline gate can actually abort the
212
+ // boot. runOnBoot merely enqueues, so a missing imprint would fail a
213
+ // queue job while the pod goes ready anyway (fw#2590).
214
+ bootGate: true,
212
215
  runIn: "api",
213
216
  },
214
217
  async (_payload, ctx) => runLegalPagesBootCheck(ctx, requiredBlocks),
@@ -0,0 +1,27 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defineFeature, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createConfigFeature } from "../../config/feature";
4
+ import { createSecretsFeature } from "../feature";
5
+
6
+ // `secrets` is a foundation feature auto-mounted even when `config` is not
7
+ // (dev-server scaffold-app.ts FOUNDATION_FEATURES). A declared r.secret()
8
+ // makes buildConfigFeatureSchema emit a secretsEdit screen + Settings-Hub
9
+ // chrome navs, whose dot-form labels (config.settings.title, etc.) must
10
+ // resolve at boot without the config feature present (fw#2577).
11
+ function stripeSecret() {
12
+ return defineFeature("stripe", (r) => {
13
+ r.secret("apiKey", { label: { en: "Stripe API Key" }, scope: "tenant" });
14
+ });
15
+ }
16
+
17
+ describe("Settings-Hub chrome i18n keys — secrets without config", () => {
18
+ test("boots with only the secrets feature mounted (no config feature)", () => {
19
+ expect(() => validateBoot([createSecretsFeature(), stripeSecret()])).not.toThrow();
20
+ });
21
+
22
+ test("boots with both secrets and config mounted", () => {
23
+ expect(() =>
24
+ validateBoot([createSecretsFeature(), createConfigFeature(), stripeSecret()]),
25
+ ).not.toThrow();
26
+ });
27
+ });
@@ -4,6 +4,7 @@ import {
4
4
  type FeatureDefinition,
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
7
+ import { SETTINGS_HUB_I18N } from "@cosmicdrift/kumiko-framework/i18n";
7
8
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
8
9
  import { z } from "zod";
9
10
  import { DEFAULT_SECRETS_ACCESS } from "./constants";
@@ -129,6 +130,15 @@ export function createSecretsFeature(opts: SecretsFeatureOptions = {}): FeatureD
129
130
  });
130
131
  r.envSchema(secretsEnvSchema);
131
132
 
133
+ // `secrets` is a foundation feature auto-mounted even when `config` is
134
+ // not (see FOUNDATION_FEATURES, dev-server scaffold-app.ts), so it must
135
+ // ship the Settings-Hub chrome keys itself — server-authored
136
+ // r.translations are projected into FeatureSchema.translations and used
137
+ // as a client fallback bundle (create-app.tsx schemaTranslations),
138
+ // covering both boot-time validation and rendering without a web
139
+ // client-plugin for this feature.
140
+ r.translations({ keys: SETTINGS_HUB_I18N });
141
+
132
142
  // ES entity: set/delete go through the executor, `tenantSecret.created/
133
143
  // .updated/.deleted` events land on the aggregate stream. Reads fire a
134
144
  // separate `tenantSecretRead` event per call (see secrets-context.get
@@ -4,10 +4,10 @@ import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
4
4
  import { AuthHandlers } from "../../auth-email-password/constants";
5
5
  import { createConfigFeature } from "../../config/feature";
6
6
  import {
7
- DEFAULT_INVITE_ROLE_OPTIONS,
8
7
  INVITE_CREATE_SCREEN_ID,
9
8
  MEMBER_ROLES_EDIT_SCREEN_ID,
10
9
  MEMBERS_SCREEN_ID,
10
+ OWNER_INVITE_ROLE_OPTIONS,
11
11
  TenantHandlers,
12
12
  TenantQueries,
13
13
  } from "../constants";
@@ -106,7 +106,7 @@ describe("tenant members screen + handler access alignment", () => {
106
106
  expect(section.fields).toEqual([{ field: "userId", readOnly: true }, "roles"]);
107
107
  expect(screen.fields["roles"]).toEqual({
108
108
  type: "multiSelect",
109
- options: DEFAULT_INVITE_ROLE_OPTIONS,
109
+ options: OWNER_INVITE_ROLE_OPTIONS,
110
110
  required: true,
111
111
  });
112
112
  if (screen && "access" in screen && screen.access && "roles" in screen.access) {
@@ -116,6 +116,34 @@ describe("tenant members screen + handler access alignment", () => {
116
116
  }
117
117
  });
118
118
 
119
+ // fw-2452 regression: the roles-edit multiSelect is prefilled with a
120
+ // member's current roles, so it must list every rank updateMemberRoles can
121
+ // actually assign (incl. TenantAdmin) — while the invite picker stays a
122
+ // closed allowlist without TenantAdmin (fw#2414), since inviting always
123
+ // starts a brand-new membership rather than editing an existing one.
124
+ test("member-roles-edit options can represent every assignable rank; invite picker stays TenantAdmin-free", () => {
125
+ const tenant = createTenantFeature({ inviteScreen: true });
126
+ const rolesEditScreen = tenant.screens[MEMBER_ROLES_EDIT_SCREEN_ID];
127
+ if (rolesEditScreen?.type !== "actionForm") {
128
+ throw new Error("expected member-roles-edit screen to be actionForm");
129
+ }
130
+ const rolesField = rolesEditScreen.fields["roles"];
131
+ if (rolesField?.type !== "multiSelect") {
132
+ throw new Error("expected roles field to be multiSelect");
133
+ }
134
+ expect(rolesField.options).toContain("TenantAdmin");
135
+
136
+ const inviteScreen = tenant.screens[INVITE_CREATE_SCREEN_ID];
137
+ if (inviteScreen?.type !== "actionForm") {
138
+ throw new Error("expected invite-create screen to be actionForm");
139
+ }
140
+ const roleField = inviteScreen.fields["role"];
141
+ if (roleField?.type !== "select") {
142
+ throw new Error("expected role field to be select");
143
+ }
144
+ expect(roleField.options).not.toContain("TenantAdmin");
145
+ });
146
+
119
147
  // The generic drawer-open/submit/close/refetch mechanics for kind:"drawer"
120
148
  // toolbar actions are already covered end-to-end with a synthetic screen in
121
149
  // renderer-web's projection-list-actions.test.tsx — this only proves OUR
@@ -23,10 +23,12 @@ export const MEMBER_STATUS_CELL_COMPONENT = "MemberStatusCell" as const;
23
23
  export const MEMBER_ROLES_CELL_COMPONENT = "MemberRolesCell" as const;
24
24
 
25
25
  /** Closed allowlist for invite-role picker — never free text (escalation guard). */
26
- // Admin (rank 2) shares these screens with TenantAdmin — TenantAdmin must not
27
- // appear here or Admin sees an unassignable option (fw#2414). Apps that need
28
- // owner invites compose OWNER_INVITE_ROLE_OPTIONS into a custom screen.
26
+ // Admin (rank 2) shares the invite-create screen with TenantAdmin — TenantAdmin
27
+ // must not appear here or Admin sees an unassignable option (fw#2414).
29
28
  export const DEFAULT_INVITE_ROLE_OPTIONS = ["User", "Editor", "Admin"] as const;
29
+ // Full assignable rank list, used by memberRolesEditScreen: that screen is
30
+ // prefilled with a member's *current* roles, so it must be able to represent
31
+ // every rank a membership can actually hold, including TenantAdmin (fw#2452).
30
32
  export const OWNER_INVITE_ROLE_OPTIONS = ["User", "Editor", "Admin", "TenantAdmin"] as const;
31
33
 
32
34
  // Qualified write handler names (QN format: scope:type:name)
@@ -12,6 +12,7 @@ import {
12
12
  MEMBER_ROLES_EDIT_SCREEN_ID,
13
13
  MEMBER_STATUS_CELL_COMPONENT,
14
14
  MEMBERS_SCREEN_ID,
15
+ OWNER_INVITE_ROLE_OPTIONS,
15
16
  TenantHandlers,
16
17
  TenantQueries,
17
18
  } from "./constants";
@@ -179,7 +180,11 @@ export const memberRolesEditScreen = {
179
180
  cancelTarget: MEMBERS_SCREEN_ID,
180
181
  fields: {
181
182
  userId: { type: "text", required: true },
182
- roles: { type: "multiSelect", options: DEFAULT_INVITE_ROLE_OPTIONS, required: true },
183
+ // Prefilled with the member's current roles, so the option list must be
184
+ // able to represent every assignable rank — DEFAULT_INVITE_ROLE_OPTIONS
185
+ // omits TenantAdmin and would silently strip it on submit. Escalation
186
+ // still stays server-side via findForbiddenRoleAssignment (update-member-roles.write.ts).
187
+ roles: { type: "multiSelect", options: OWNER_INVITE_ROLE_OPTIONS, required: true },
183
188
  },
184
189
  layout: {
185
190
  // Prefill userId from rowAction; readOnly so the operator cannot retarget.
@@ -54,8 +54,10 @@ export const createWrite = defineWriteHandler({
54
54
  "user rows are tenant-agnostic identity records; uniqueness check and create need no tenant filter",
55
55
  );
56
56
 
57
+ // Pre-flight must match the partial bidx unique index, which only covers live rows.
57
58
  const existing = await fetchOne<{ id: string }>(db, userTable, {
58
59
  email: event.payload.email,
60
+ isDeleted: false,
59
61
  });
60
62
 
61
63
  if (existing) {
@@ -29,9 +29,13 @@ export const findForAuthQuery = defineQueryHandler({
29
29
  ),
30
30
  access: { roles: access.system },
31
31
  handler: async (query, ctx) => {
32
- const where: { email: string } | { id: string } =
32
+ // Soft-deleted rows can now share an email with a live row (the partial
33
+ // bidx unique index covers live rows only), so the email arm must resolve
34
+ // the live identity; every email-arm caller already rejects isDeleted
35
+ // rows after the fetch, so this only removes rows that would be discarded anyway.
36
+ const where: { email: string; isDeleted: false } | { id: string } =
33
37
  query.payload.email !== undefined
34
- ? { email: query.payload.email }
38
+ ? { email: query.payload.email, isDeleted: false }
35
39
  : { id: query.payload.id as string }; // @cast-boundary engine-payload
36
40
 
37
41
  if (!ctx.systemDb) {
@@ -83,9 +83,30 @@ const retryWorkflow: WorkflowDefinition = defineWorkflow({
83
83
  ]),
84
84
  });
85
85
 
86
+ // Counts real side-effect executions of the step AFTER the suspension point
87
+ // — distinct from the recorded event log, so a double-resume that somehow
88
+ // wrote a deduplicated event log but still re-ran the pipeline would still
89
+ // be caught (framework#2552/1).
90
+ let sideEffectCount = 0;
91
+
92
+ const idempotentResumeWorkflow: WorkflowDefinition = defineWorkflow({
93
+ name: "rl-idempotent-resume",
94
+ trigger: { kind: "event", eventType: "rl-test.idempotent-resume" },
95
+ idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
96
+ steps: stepsPipeline(({ r }) => [
97
+ r.step.wait({ for: (ctx) => (ctx.event.payload as { forIso: string }).forIso }),
98
+ r.step.compute("sideEffect", () => {
99
+ sideEffectCount += 1;
100
+ return sideEffectCount;
101
+ }),
102
+ r.step.return({ isSuccess: true, data: undefined }),
103
+ ]),
104
+ });
105
+
86
106
  const testTriggersFeature = defineFeature("workflow-runner-resume-loop-test-triggers", (r) => {
87
107
  registerEventTrigger(r, waitWorkflow);
88
108
  registerEventTrigger(r, retryWorkflow);
109
+ registerEventTrigger(r, idempotentResumeWorkflow);
89
110
  });
90
111
 
91
112
  const noopLogger: JobContext["log"] = {
@@ -274,4 +295,45 @@ describe("workflow-runner resume loop", () => {
274
295
  rowsAfterResume.filter((row) => row["type"] === WORKFLOW_RETRY_SCHEDULED_TYPE),
275
296
  ).toHaveLength(1);
276
297
  });
298
+
299
+ test("a second resume-due-runs tick landing before pending-projection deletes the row resumes exactly once (fw#2552/1)", async () => {
300
+ const runKey = crypto.randomUUID();
301
+ const runId = workflowRunAggregateId(idempotentResumeWorkflow.name, runKey);
302
+ const pastIso = T.Now.instant().subtract({ hours: 1 }).toString();
303
+ sideEffectCount = 0;
304
+
305
+ await fireTrigger("rl-test.idempotent-resume", { runKey, forIso: pastIso });
306
+ expect(await pendingRowExists(runId, 0)).toBe(true);
307
+
308
+ await runResumeDueRunsJob();
309
+
310
+ const rowsAfterFirstResume = await loadRunEvents(runId);
311
+ expect(rowsAfterFirstResume.map((row) => row["type"])).toEqual([
312
+ WORKFLOW_RUN_STARTED_TYPE,
313
+ WORKFLOW_WAITING_TYPE,
314
+ WORKFLOW_RESUMED_TYPE,
315
+ WORKFLOW_RUN_COMPLETED_TYPE,
316
+ ]);
317
+ expect(sideEffectCount).toBe(1);
318
+
319
+ // pending-projection.ts only deletes this row on a LATER dispatcher pass
320
+ // reacting to WORKFLOW_RESUMED — deliberately not run here, reproducing
321
+ // the framework#2552/1 race window where a second resume-due-runs tick
322
+ // lands inside that async-delete gap and still finds the row.
323
+ expect(await pendingRowExists(runId, 0)).toBe(true);
324
+
325
+ await runResumeDueRunsJob();
326
+
327
+ const rowsAfterSecondDispatch = await loadRunEvents(runId);
328
+ expect(rowsAfterSecondDispatch).toHaveLength(4);
329
+ expect(
330
+ rowsAfterSecondDispatch.filter((row) => row["type"] === WORKFLOW_RESUMED_TYPE),
331
+ ).toHaveLength(1);
332
+ expect(
333
+ rowsAfterSecondDispatch.filter((row) => row["type"] === WORKFLOW_RUN_COMPLETED_TYPE),
334
+ ).toHaveLength(1);
335
+ // Not just the recorded event log — the step's own side effect (which a
336
+ // buggy second pipeline re-entry would double-run) executed exactly once.
337
+ expect(sideEffectCount).toBe(1);
338
+ });
277
339
  });
@@ -9,9 +9,12 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
9
  import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
10
10
  import {
11
11
  computeDefinitionFingerprint,
12
+ createSystemUser,
12
13
  defineFeature,
13
14
  defineWorkflow,
14
15
  stepsPipeline,
16
+ WORKFLOW_RESUMED_TYPE,
17
+ WORKFLOW_RETRY_SCHEDULED_TYPE,
15
18
  WORKFLOW_RUN_COMPLETED_TYPE,
16
19
  WORKFLOW_RUN_FAILED_TYPE,
17
20
  WORKFLOW_RUN_STARTED_TYPE,
@@ -19,6 +22,7 @@ import {
19
22
  type WorkflowDefinition,
20
23
  } from "@cosmicdrift/kumiko-framework/engine";
21
24
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
25
+ import { getConsumerState } from "@cosmicdrift/kumiko-framework/pipeline";
22
26
  import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
23
27
  import { workflowRunAggregateId } from "../aggregate-id";
24
28
  import { registerEventTrigger } from "../event-trigger";
@@ -59,10 +63,56 @@ const suspendingWorkflow: WorkflowDefinition = defineWorkflow({
59
63
  ]),
60
64
  });
61
65
 
66
+ // fw#2552/1 regression fixture — counts how often the step AFTER the wait
67
+ // actually runs, keyed by runKey, so a double resume-run call can prove the
68
+ // step executed exactly once instead of just inferring it from event counts.
69
+ const doubleResumeStepRuns = new Map<string, number>();
70
+
71
+ const doubleResumeWorkflow: WorkflowDefinition = defineWorkflow({
72
+ name: "wr-integration-double-resume",
73
+ trigger: { kind: "event", eventType: "wr-test.double-resume" },
74
+ idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
75
+ steps: stepsPipeline(({ r }) => [
76
+ r.step.wait({ for: "PT1H" }),
77
+ r.step.compute("afterResume", (ctx) => {
78
+ const runKey = (ctx.event.payload as { runKey: string }).runKey;
79
+ doubleResumeStepRuns.set(runKey, (doubleResumeStepRuns.get(runKey) ?? 0) + 1);
80
+ return doubleResumeStepRuns.get(runKey);
81
+ }),
82
+ r.step.return({ isSuccess: true, data: undefined }),
83
+ ]),
84
+ });
85
+
86
+ // fw#2552/1 regression fixture (Aufgabe 1 bug) — times: 3 forces TWO
87
+ // retry-scheduled suspensions (attempt 1 and 2 both fail, attempt 3
88
+ // succeeds), so resuming both must each thread a distinct retryAttempt
89
+ // instead of the second resume being swallowed as already-resumed.
90
+ const doubleRetryWorkflow: WorkflowDefinition = defineWorkflow({
91
+ name: "wr-integration-double-retry",
92
+ trigger: { kind: "event", eventType: "wr-test.double-retry" },
93
+ idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
94
+ steps: stepsPipeline(({ r }) => [
95
+ r.step.retry({
96
+ times: 3,
97
+ backoff: "linear",
98
+ do: [
99
+ r.step.compute("gate", (ctx) => {
100
+ const attempt = ctx.workflow?.retryAttempt ?? 1;
101
+ if (attempt < 3) throw new Error(`attempt-${attempt}-fails`);
102
+ return attempt;
103
+ }),
104
+ ],
105
+ }),
106
+ r.step.return({ isSuccess: true, data: undefined }),
107
+ ]),
108
+ });
109
+
62
110
  const testTriggersFeature = defineFeature("workflow-runner-integration-test-triggers", (r) => {
63
111
  registerEventTrigger(r, happyWorkflow);
64
112
  registerEventTrigger(r, failingWorkflow);
65
113
  registerEventTrigger(r, suspendingWorkflow);
114
+ registerEventTrigger(r, doubleResumeWorkflow);
115
+ registerEventTrigger(r, doubleRetryWorkflow);
66
116
  });
67
117
 
68
118
  async function fireTrigger(eventType: string, payload: Record<string, unknown>): Promise<void> {
@@ -89,6 +139,17 @@ async function loadRunEvents(runId: string) {
89
139
  );
90
140
  }
91
141
 
142
+ // Dispatches resume-run directly (SYSTEM_ROLE-gated) instead of going
143
+ // through the resume-due-runs job — resume-run never checks wakeAt, so this
144
+ // resumes a suspended step immediately regardless of its backoff/wait length.
145
+ async function resumeRun(runId: string, stepIndex: number) {
146
+ return stack.dispatcher.write(
147
+ "workflow-runner:write:resume-run",
148
+ { runId, stepIndex },
149
+ createSystemUser(admin.tenantId),
150
+ );
151
+ }
152
+
92
153
  describe("workflow-runner event-trigger", () => {
93
154
  beforeAll(async () => {
94
155
  stack = await setupTestStack({ features: [workflowRunnerFeature, testTriggersFeature] });
@@ -123,8 +184,28 @@ describe("workflow-runner event-trigger", () => {
123
184
  test("error path: a throwing step writes run-failed with the error text, no run-completed", async () => {
124
185
  const runKey = crypto.randomUUID();
125
186
  const runId = workflowRunAggregateId(failingWorkflow.name, runKey);
187
+ // The registrar namespaces every MSP as `<feature>:projection:<name>`.
188
+ const consumerName = `workflow-runner-integration-test-triggers:projection:workflow-${failingWorkflow.name}`;
189
+
190
+ await insertOne(stack.db, eventsTable, {
191
+ aggregateId: crypto.randomUUID(),
192
+ aggregateType: "wr-test-source",
193
+ tenantId: admin.tenantId,
194
+ version: 1,
195
+ type: "wr-test.failure",
196
+ eventVersion: 1,
197
+ payload: { runKey },
198
+ metadata: { userId: admin.id },
199
+ createdBy: admin.id,
200
+ });
126
201
 
127
- await fireTrigger("wr-test.failure", { runKey });
202
+ // fw#2482/2: run-failed is durably recorded, so the dispatcher must count
203
+ // the trigger event as delivered — not as a poison event to keep
204
+ // retrying (which would re-run every side-effect step until dead-letter).
205
+ const firstPass = await stack.eventDispatcher?.runOnce();
206
+ expect(firstPass?.byConsumer[consumerName]).toEqual({ processed: 1, failed: 0 });
207
+ const stateAfterFirstPass = await getConsumerState(stack.db, consumerName);
208
+ expect(stateAfterFirstPass?.status).toBe("idle");
128
209
 
129
210
  const rows = await loadRunEvents(runId);
130
211
  expect(rows.map((row) => row["type"])).toEqual([
@@ -133,6 +214,16 @@ describe("workflow-runner event-trigger", () => {
133
214
  ]);
134
215
  expect(rows[1]!["payload"]).toMatchObject({ workflowName: failingWorkflow.name, stepIndex: 0 });
135
216
  expect(String(rows[1]!["payload"]["error"])).toContain("boom-explicit-failure");
217
+
218
+ // Second pass has nothing left to redeliver — the same trigger event
219
+ // never spawns a second run-started for this runId. `processed` counts
220
+ // every event the cursor walks past (the run-started/run-failed rows the
221
+ // first pass appended included), so the redelivery invariant is asserted
222
+ // on the run stream itself plus a still-clean failure count.
223
+ const secondPass = await stack.eventDispatcher?.runOnce();
224
+ expect(secondPass?.byConsumer[consumerName]?.failed).toBe(0);
225
+ expect(await loadRunEvents(runId)).toHaveLength(2);
226
+ expect(await getConsumerState(stack.db, consumerName)).toMatchObject({ status: "idle" });
136
227
  });
137
228
 
138
229
  test("suspension: a wait step suspends the run instead of failing it — resumable by framework#2513 Phase 2, no run-completed yet", async () => {
@@ -147,4 +238,57 @@ describe("workflow-runner event-trigger", () => {
147
238
  WORKFLOW_WAITING_TYPE,
148
239
  ]);
149
240
  });
241
+
242
+ test("fw#2552/1: a second resume-run for the same (runId, stepIndex) before pending-projection deletes the row reports already-resumed and never re-runs the step", async () => {
243
+ const runKey = crypto.randomUUID();
244
+ const runId = workflowRunAggregateId(doubleResumeWorkflow.name, runKey);
245
+
246
+ await fireTrigger("wr-test.double-resume", { runKey });
247
+ // pending-projection observes the suspension on a pass after it exists.
248
+ await stack.eventDispatcher?.runOnce();
249
+
250
+ const first = await resumeRun(runId, 0);
251
+ // No runOnce() in between — pending-projection has not yet deleted the
252
+ // pending row, so the second call still finds it (the race window).
253
+ const second = await resumeRun(runId, 0);
254
+
255
+ expect(first).toMatchObject({ isSuccess: true, data: { outcome: "completed" } });
256
+ expect(second).toMatchObject({ isSuccess: true, data: { outcome: "already-resumed" } });
257
+ expect(doubleResumeStepRuns.get(runKey)).toBe(1);
258
+
259
+ const rows = await loadRunEvents(runId);
260
+ expect(rows.map((row) => row["type"])).toEqual([
261
+ WORKFLOW_RUN_STARTED_TYPE,
262
+ WORKFLOW_WAITING_TYPE,
263
+ WORKFLOW_RESUMED_TYPE,
264
+ WORKFLOW_RUN_COMPLETED_TYPE,
265
+ ]);
266
+ });
267
+
268
+ test("fw#2552/1 regression: a step retried twice gets both retry attempts, the second resume is not swallowed as already-resumed", async () => {
269
+ const runKey = crypto.randomUUID();
270
+ const runId = workflowRunAggregateId(doubleRetryWorkflow.name, runKey);
271
+
272
+ await fireTrigger("wr-test.double-retry", { runKey });
273
+ await stack.eventDispatcher?.runOnce(); // pending-projection materialises attempt 1's row
274
+
275
+ const firstResume = await resumeRun(runId, 0);
276
+ expect(firstResume).toMatchObject({ isSuccess: true, data: { outcome: "suspended" } });
277
+
278
+ await stack.eventDispatcher?.runOnce(); // pending-projection materialises attempt 2's row
279
+
280
+ const secondResume = await resumeRun(runId, 0);
281
+ expect(secondResume).toMatchObject({ isSuccess: true, data: { outcome: "completed" } });
282
+
283
+ const rows = await loadRunEvents(runId);
284
+ expect(rows.map((row) => row["type"])).toEqual([
285
+ WORKFLOW_RUN_STARTED_TYPE,
286
+ WORKFLOW_RETRY_SCHEDULED_TYPE,
287
+ WORKFLOW_RESUMED_TYPE,
288
+ WORKFLOW_RETRY_SCHEDULED_TYPE,
289
+ WORKFLOW_RESUMED_TYPE,
290
+ WORKFLOW_RUN_COMPLETED_TYPE,
291
+ ]);
292
+ expect(rows.filter((row) => row["type"] === WORKFLOW_RESUMED_TYPE)).toHaveLength(2);
293
+ });
150
294
  });
@@ -7,7 +7,8 @@
7
7
  // The MSP apply-fn runs in the dispatcher's own tx, so `workflow.run-started`
8
8
  // plus the synchronous portion of the pipeline land atomically. Any throw
9
9
  // from startAndRunWorkflow is recorded as `workflow.run-failed` and
10
- // rethrown so the dispatcher's retry/dead-letter handling still applies.
10
+ // swallowed — the failure is already durable, so the dispatcher advances
11
+ // past the trigger event instead of redelivering it.
11
12
 
12
13
  import type {
13
14
  FeatureRegistrar,
@@ -43,6 +44,9 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
43
44
 
44
45
  r.multiStreamProjection({
45
46
  name: `workflow-${workflow.name}`,
47
+ // Mounting an event-triggered workflow into an existing app must not
48
+ // replay the historical log and fire every side-effect step retroactively.
49
+ startFrom: "now",
46
50
  apply: {
47
51
  [eventType]: async (event, _tx, ctx) => {
48
52
  // skip: unreachable — the guard above already established this, but the
@@ -97,7 +101,10 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
97
101
  type: WORKFLOW_RUN_FAILED_TYPE,
98
102
  payload: failedPayload,
99
103
  });
100
- throw error;
104
+ // No rethrow: the failure is now durably recorded as
105
+ // workflow.run-failed. Rethrowing would make the dispatcher
106
+ // redeliver the same trigger event up to maxAttempts, re-running
107
+ // every side-effect step and eventually killing the consumer.
101
108
  }
102
109
  },
103
110
  },
@@ -99,13 +99,91 @@ function checkQ7Fingerprint(
99
99
  };
100
100
  }
101
101
 
102
- async function recoverTriggerEvent(
103
- ctx: HandlerContext,
102
+ type ResolvedWorkflow =
103
+ | { readonly workflow: WorkflowDefinition; readonly failure: null }
104
+ | { readonly workflow: null; readonly failure: WorkflowRunFailedPayload };
105
+
106
+ // Both preconditions fail the run the same way, so they resolve together: the
107
+ // workflow must still be registered AND its definition must still match the
108
+ // fingerprint the run was started against.
109
+ function resolveRunnableWorkflow(
110
+ workflowName: string,
111
+ runId: string,
112
+ stepIndex: number,
113
+ storedFingerprint: string | null,
114
+ ): ResolvedWorkflow {
115
+ const workflow = getWorkflow(workflowName);
116
+ if (!workflow) {
117
+ return {
118
+ workflow: null,
119
+ failure: {
120
+ workflowName,
121
+ stepIndex,
122
+ error: `Workflow "${workflowName}" is not registered — cannot resume run ${runId}.`,
123
+ reason: "workflow_definition_changed",
124
+ },
125
+ };
126
+ }
127
+ const fingerprintFailure = checkQ7Fingerprint(
128
+ workflow,
129
+ workflowName,
130
+ runId,
131
+ stepIndex,
132
+ storedFingerprint,
133
+ );
134
+ return fingerprintFailure
135
+ ? { workflow: null, failure: fingerprintFailure }
136
+ : { workflow, failure: null };
137
+ }
138
+
139
+ function readResumedPayload(payload: unknown): {
140
+ readonly stepIndex: number | undefined;
141
+ readonly retryAttempt: number | undefined;
142
+ } {
143
+ if (typeof payload !== "object" || payload === null) {
144
+ return { stepIndex: undefined, retryAttempt: undefined };
145
+ }
146
+ const stepIndex =
147
+ "stepIndex" in payload && typeof payload.stepIndex === "number" ? payload.stepIndex : undefined;
148
+ const retryAttempt =
149
+ "retryAttempt" in payload && typeof payload.retryAttempt === "number"
150
+ ? payload.retryAttempt
151
+ : undefined;
152
+ return { stepIndex, retryAttempt };
153
+ }
154
+
155
+ // The workflow_run_pending row is only deleted asynchronously by
156
+ // pending-projection, so a resume-due-runs tick inside that window finds the
157
+ // row again and the append-claim no longer conflicts — the event stream is the
158
+ // authoritative idempotency source.
159
+ //
160
+ // retryAttempt must match too, not just stepIndex: a retry suspension resumes
161
+ // the SAME stepIndex on every attempt, so matching on stepIndex alone would
162
+ // mistake attempt 1's WORKFLOW_RESUMED for attempt 2's and skip the second
163
+ // retry entirely.
164
+ function isRunAlreadySettled(
165
+ runEvents: Awaited<ReturnType<HandlerContext["loadAggregate"]>>,
166
+ stepIndex: number,
167
+ expectedRetryAttempt: number | undefined,
168
+ ): boolean {
169
+ return runEvents.some((e) => {
170
+ if (e.type === WORKFLOW_RUN_COMPLETED_TYPE || e.type === WORKFLOW_RUN_FAILED_TYPE) {
171
+ return true;
172
+ }
173
+ if (e.type !== WORKFLOW_RESUMED_TYPE) {
174
+ return false;
175
+ }
176
+ const resumed = readResumedPayload(e.payload);
177
+ return resumed.stepIndex === stepIndex && resumed.retryAttempt === expectedRetryAttempt;
178
+ });
179
+ }
180
+
181
+ function recoverTriggerEvent(
182
+ runEvents: Awaited<ReturnType<HandlerContext["loadAggregate"]>>,
104
183
  runId: string,
105
184
  user: WriteEvent["user"],
106
- ): Promise<WriteEvent> {
107
- const startedEvents = await ctx.loadAggregate(runId);
108
- const started = startedEvents.find((e) => e.type === WORKFLOW_RUN_STARTED_TYPE);
185
+ ): WriteEvent {
186
+ const started = runEvents.find((e) => e.type === WORKFLOW_RUN_STARTED_TYPE);
109
187
  if (!started) {
110
188
  throw new InternalError({
111
189
  message: `workflow-runner:write:resume-run: run ${runId} has no ${WORKFLOW_RUN_STARTED_TYPE} event — cannot recover its trigger event.`,
@@ -177,29 +255,23 @@ export const resumeRunHandler: WriteHandlerDef = {
177
255
  }
178
256
 
179
257
  const { workflowName } = pending;
180
- const workflow = getWorkflow(workflowName);
181
- if (!workflow) {
182
- const failedPayload: WorkflowRunFailedPayload = {
183
- workflowName,
184
- stepIndex,
185
- error: `Workflow "${workflowName}" is not registered — cannot resume run ${runId}.`,
186
- reason: "workflow_definition_changed",
187
- };
188
- await appendRunFailed(ctx, runId, failedPayload);
189
- return { isSuccess: true, data: { outcome: "failed" as const } };
190
- }
191
-
192
- const fingerprintFailure = checkQ7Fingerprint(
193
- workflow,
258
+ const resolved = resolveRunnableWorkflow(
194
259
  workflowName,
195
260
  runId,
196
261
  stepIndex,
197
262
  pending.definitionFingerprint,
198
263
  );
199
- if (fingerprintFailure) {
200
- await appendRunFailed(ctx, runId, fingerprintFailure);
264
+ if (resolved.failure !== null) {
265
+ await appendRunFailed(ctx, runId, resolved.failure);
201
266
  return { isSuccess: true, data: { outcome: "failed" as const } };
202
267
  }
268
+ const workflow = resolved.workflow;
269
+
270
+ const runEvents = await ctx.loadAggregate(runId);
271
+
272
+ if (isRunAlreadySettled(runEvents, stepIndex, pending.retryAttempt ?? undefined)) {
273
+ return { isSuccess: true, data: { outcome: "already-resumed" as const } };
274
+ }
203
275
 
204
276
  const claim = await ctx.tryAppendEvent({
205
277
  aggregateId: runId,
@@ -217,7 +289,7 @@ export const resumeRunHandler: WriteHandlerDef = {
217
289
  return { isSuccess: true, data: { outcome: "already-resumed" as const } };
218
290
  }
219
291
 
220
- const triggerEvent = await recoverTriggerEvent(ctx, runId, event.user);
292
+ const triggerEvent = recoverTriggerEvent(runEvents, runId, event.user);
221
293
 
222
294
  const resumeFrom =
223
295
  pending.suspensionEventType === WORKFLOW_RETRY_SCHEDULED_TYPE ? stepIndex : stepIndex + 1;