@cosmicdrift/kumiko-framework 0.288.0 → 0.289.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.
Files changed (38) hide show
  1. package/package.json +4 -4
  2. package/src/changes.json +57 -0
  3. package/src/compliance/__tests__/sub-processors.test.ts +10 -0
  4. package/src/compliance/sub-processors.ts +14 -1
  5. package/src/crypto/__tests__/kek-source.test.ts +298 -0
  6. package/src/crypto/index.ts +3 -0
  7. package/src/crypto/kek-source.ts +189 -0
  8. package/src/crypto/kms-wiring.ts +20 -0
  9. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +191 -0
  10. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +60 -1
  11. package/src/db/event-store-executor-context.ts +79 -0
  12. package/src/db/event-store-executor-write.ts +42 -5
  13. package/src/db/index.ts +1 -0
  14. package/src/db/tenant-db.ts +8 -1
  15. package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +56 -0
  16. package/src/engine/__tests__/required-surface-keys.test.ts +196 -0
  17. package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +63 -0
  18. package/src/engine/boot-validator/entity-handler.ts +10 -4
  19. package/src/engine/extensions/storage-provider.ts +17 -2
  20. package/src/engine/extensions/tenant-data.ts +9 -0
  21. package/src/engine/factories.ts +2 -0
  22. package/src/engine/screen-helpers.ts +17 -0
  23. package/src/errors/classes.ts +24 -0
  24. package/src/errors/index.ts +3 -0
  25. package/src/errors/member-resolution.ts +12 -0
  26. package/src/event-store/__tests__/provenance-append.integration.test.ts +33 -1
  27. package/src/event-store/provenance-append.ts +9 -1
  28. package/src/files/__tests__/file-handle.test.ts +28 -1
  29. package/src/files/file-handle.ts +26 -7
  30. package/src/files/index.ts +1 -1
  31. package/src/i18n/required-surface-keys.ts +24 -12
  32. package/src/pipeline/__tests__/member-resolution-read-only.test.ts +79 -0
  33. package/src/pipeline/dispatch-shared.ts +14 -13
  34. package/src/pipeline/dispatch-stream.ts +8 -3
  35. package/src/pipeline/dispatch-write.ts +3 -2
  36. package/src/pipeline/event-dispatcher.ts +18 -5
  37. package/src/pipeline/member-read-only-transaction.ts +2 -6
  38. package/src/stack/table-helpers.ts +6 -0
@@ -8,6 +8,7 @@
8
8
  // is wrong), `resolveKmsWiring` is the boot entry point that also constructs
9
9
  // the adapter.
10
10
 
11
+ import { type KekSourceOptions, resolvePlatformKeks } from "./kek-source";
11
12
  import { createPgKmsAdapter, type PgKmsAdapter, type PgKmsAdapterOptions } from "./pg-kms-adapter";
12
13
 
13
14
  // The index signature is what lets callers pass `process.env` directly. Without
@@ -186,3 +187,22 @@ export function requireKmsWiring(
186
187
  }
187
188
  return wiring;
188
189
  }
190
+
191
+ /** Same as `resolveKmsWiring`, but resolves `PLATFORM_KEK`/`PLATFORM_KEK_PREVIOUS`
192
+ * from a Key Manager ciphertext first when no plaintext is set — see
193
+ * `resolvePlatformKeks`. The validation stays in the sync function; this only
194
+ * adds the KEK-fetching step in front of it. */
195
+ export async function resolveKmsWiringAsync(
196
+ env: KmsWiringEnv,
197
+ options: KmsWiringOptions & KekSourceOptions = {},
198
+ ): Promise<KmsWiring> {
199
+ return resolveKmsWiring(await resolvePlatformKeks(env, options), options);
200
+ }
201
+
202
+ /** Async counterpart to `requireKmsWiring`, KEK-resolving like `resolveKmsWiringAsync`. */
203
+ export async function requireKmsWiringAsync(
204
+ env: KmsWiringEnv,
205
+ options: KmsWiringOptions & KekSourceOptions = {},
206
+ ): Promise<ActiveKmsWiring> {
207
+ return requireKmsWiring(await resolvePlatformKeks(env, options), options);
208
+ }
@@ -385,6 +385,197 @@ describe("event-store-executor write-verbs — version_conflict edge cases", ()
385
385
  });
386
386
  });
387
387
 
388
+ // =============================================================================
389
+ // expect precondition (kumiko-framework#3024) — declarative "genau einmal"
390
+ // guard on update(). The version_conflict describe above proves the pre-
391
+ // existing stream-version race protection; these prove `expect:` closes the
392
+ // gap that leaves open: a caller (like updateUserLifecycle) that opts out of
393
+ // the optimistic lock entirely, so a "late" writer whose own version read is
394
+ // fresh never trips version_conflict at all.
395
+ // =============================================================================
396
+
397
+ const expectEntity = createEntity({
398
+ table: "read_es_write_expect",
399
+ fields: {
400
+ email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
401
+ status: createTextField({ personal: false, reason: "test_fixture" }),
402
+ note: createTextField({ personal: false, reason: "test_fixture" }),
403
+ },
404
+ });
405
+ const expectTable = buildEntityTable("esWriteExpect", expectEntity);
406
+
407
+ describe("event-store-executor write-verbs — expect precondition (#3024)", () => {
408
+ const crud = createEventStoreExecutor(expectTable, expectEntity, {
409
+ entityName: "esWriteExpect",
410
+ });
411
+
412
+ beforeAll(async () => {
413
+ await unsafeCreateEntityTable(testDb.db, expectEntity, "esWriteExpect");
414
+ });
415
+
416
+ beforeEach(async () => {
417
+ await asRawClient(testDb.db).unsafe(
418
+ `TRUNCATE kumiko_events, read_es_write_expect RESTART IDENTITY CASCADE`,
419
+ );
420
+ });
421
+
422
+ test("expect matching the fresh row → applies", async () => {
423
+ const created = await crud.create({ email: "match@test.de", status: "Active" }, admin, tdb);
424
+ if (!created.isSuccess) throw new Error("setup failed");
425
+
426
+ const result = await crud.update(
427
+ { id: created.data.id, changes: { status: "Requested" } },
428
+ admin,
429
+ tdb,
430
+ { skipOptimisticLock: true, expect: { status: "Active" } },
431
+ );
432
+ expect(result.isSuccess).toBe(true);
433
+ });
434
+
435
+ test("expect not matching the fresh row → precondition_failed, row unchanged", async () => {
436
+ const created = await crud.create({ email: "stale@test.de", status: "Requested" }, admin, tdb);
437
+ if (!created.isSuccess) throw new Error("setup failed");
438
+
439
+ const result = await crud.update(
440
+ { id: created.data.id, changes: { status: "Deleted" } },
441
+ admin,
442
+ tdb,
443
+ { skipOptimisticLock: true, expect: { status: "Active" } },
444
+ );
445
+ expect(result.isSuccess).toBe(false);
446
+ if (result.isSuccess) return;
447
+ expect(result.error.code).toBe("precondition_failed");
448
+
449
+ const row = await asRawClient(testDb.db).unsafe(
450
+ `SELECT status FROM read_es_write_expect WHERE id = $1`,
451
+ [created.data.id],
452
+ );
453
+ expect((row as unknown as { status: string }[])[0]?.status).toBe("Requested");
454
+ });
455
+
456
+ test("multiple expect fields, one mismatches → precondition_failed", async () => {
457
+ const created = await crud.create(
458
+ { email: "multi@test.de", status: "Active", note: "kept" },
459
+ admin,
460
+ tdb,
461
+ );
462
+ if (!created.isSuccess) throw new Error("setup failed");
463
+
464
+ const result = await crud.update(
465
+ { id: created.data.id, changes: { status: "Requested" } },
466
+ admin,
467
+ tdb,
468
+ { skipOptimisticLock: true, expect: { status: "Active", note: "different" } },
469
+ );
470
+ expect(result.isSuccess).toBe(false);
471
+ if (result.isSuccess) return;
472
+ expect(result.error.code).toBe("precondition_failed");
473
+ });
474
+
475
+ test("expect: null matches a null field, rejects a non-null one", async () => {
476
+ const withNullNote = await crud.create({ email: "null-note@test.de" }, admin, tdb);
477
+ if (!withNullNote.isSuccess) throw new Error("setup failed");
478
+ const matched = await crud.update(
479
+ { id: withNullNote.data.id, changes: { note: "now set" } },
480
+ admin,
481
+ tdb,
482
+ { skipOptimisticLock: true, expect: { note: null } },
483
+ );
484
+ expect(matched.isSuccess).toBe(true);
485
+
486
+ const withNote = await crud.create(
487
+ { email: "present-note@test.de", note: "present" },
488
+ admin,
489
+ tdb,
490
+ );
491
+ if (!withNote.isSuccess) throw new Error("setup failed");
492
+ const rejected = await crud.update(
493
+ { id: withNote.data.id, changes: { note: "overwritten" } },
494
+ admin,
495
+ tdb,
496
+ { skipOptimisticLock: true, expect: { note: null } },
497
+ );
498
+ expect(rejected.isSuccess).toBe(false);
499
+ });
500
+
501
+ // The scenario version_conflict alone can't catch: both callers skip the
502
+ // optimistic lock (updateUserLifecycle's shape), so the second caller's own
503
+ // stream-version read is fresh and never collides — only the fresh expect
504
+ // re-read at write time sees that the first caller already moved the row on.
505
+ test("late writer: expect catches a precondition an earlier write already violated, with no version race", async () => {
506
+ const created = await crud.create({ email: "late@test.de", status: "Active" }, admin, tdb);
507
+ if (!created.isSuccess) throw new Error("setup failed");
508
+ const id = created.data.id;
509
+
510
+ const first = await crud.update({ id, changes: { status: "Requested" } }, admin, tdb, {
511
+ skipOptimisticLock: true,
512
+ expect: { status: "Active" },
513
+ });
514
+ expect(first.isSuccess).toBe(true);
515
+
516
+ const second = await crud.update({ id, changes: { status: "Requested" } }, admin, tdb, {
517
+ skipOptimisticLock: true,
518
+ expect: { status: "Active" },
519
+ });
520
+ expect(second.isSuccess).toBe(false);
521
+ if (second.isSuccess) return;
522
+ expect(second.error.code).toBe("precondition_failed");
523
+ });
524
+
525
+ // Wrapped in its own transaction per racer, like "two concurrent first-time
526
+ // creates ... inside a transaction" above — this is how the dispatcher
527
+ // always calls update() in production (the whole handler runs in one
528
+ // transaction), and it matters here: without it, a single writer's own
529
+ // event-append and projection-update commit as two SEPARATE, independently
530
+ // visible statements against the bare pool, so a second reader can
531
+ // observe "event committed, projection not yet" — a torn state that
532
+ // doesn't exist once both writes commit together as one transaction. The
533
+ // HTTP-level equivalent (anonymous-deletion.integration.test.ts, real
534
+ // dispatcher, real transaction) already covers the true production
535
+ // guarantee; this test pins the same guarantee at the executor level with
536
+ // an explicit transaction to match.
537
+ test("two concurrent updates with the same expect, both skipOptimisticLock → exactly one applies", async () => {
538
+ const created = await crud.create(
539
+ { email: "race-expect@test.de", status: "Active" },
540
+ admin,
541
+ tdb,
542
+ );
543
+ if (!created.isSuccess) throw new Error("setup failed");
544
+ const id = created.data.id;
545
+ const options = { skipOptimisticLock: true, expect: { status: "Active" } } as const;
546
+
547
+ const [a, b] = await Promise.all([
548
+ transaction(testDb.db, (tx) =>
549
+ crud.update(
550
+ { id, changes: { status: "Requested" } },
551
+ admin,
552
+ createTenantDb(tx, admin.tenantId),
553
+ options,
554
+ ),
555
+ ),
556
+ transaction(testDb.db, (tx) =>
557
+ crud.update(
558
+ { id, changes: { status: "Requested" } },
559
+ admin,
560
+ createTenantDb(tx, admin.tenantId),
561
+ options,
562
+ ),
563
+ ),
564
+ ]);
565
+
566
+ const results = [a, b];
567
+ expect(results.filter((r) => r.isSuccess)).toHaveLength(1);
568
+ const loser = results.find((r) => !r.isSuccess);
569
+ if (!loser || loser.isSuccess) throw new Error("expected exactly one loser");
570
+ expect(["precondition_failed", "version_conflict"]).toContain(loser.error.code);
571
+
572
+ const healthCheck = (await asRawClient(testDb.db).unsafe(`SELECT 1 AS ok`)) as Array<{
573
+ ok: number;
574
+ }>;
575
+ expect(healthCheck[0]?.ok).toBe(1);
576
+ });
577
+ });
578
+
388
579
  // =============================================================================
389
580
  // Explicit-id create: cross-tenant isolation, no resurrection of a deleted row
390
581
  // =============================================================================
@@ -1,10 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { EscapeHatchKind } from "@cosmicdrift/kumiko-types/handlers";
3
3
  import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
4
- import { AccessDeniedError, InternalError } from "../../errors";
4
+ import { AccessDeniedError, FrameworkReasons, InternalError } from "../../errors";
5
5
  import { testTenantId } from "../../stack";
6
6
  import type { DbRunner } from "../connection";
7
7
  import {
8
+ acknowledgeConventionCrossTenant,
8
9
  createTenantDb,
9
10
  createUncheckedSystemDb,
10
11
  unsafeRawForDeclaredStep,
@@ -102,3 +103,61 @@ describe("unsafeRawForDeclaredStep", () => {
102
103
  expect(unsafeRawForDeclaredStep(regranted, REASON)).toBe(runner);
103
104
  });
104
105
  });
106
+
107
+ describe("memberReadOnly grant", () => {
108
+ function memberReadOnlyDb(report?: (kind: EscapeHatchKind, reason: string) => void): TenantDb {
109
+ return createTenantDb(fakeRunner(), tenantId, "tenant", undefined, undefined, undefined, {
110
+ unsafeRaw: { reason: "handler declared unsafeRaw" },
111
+ memberReadOnly: true,
112
+ ...(report && { report }),
113
+ });
114
+ }
115
+
116
+ function deniedReason(run: () => unknown): unknown {
117
+ try {
118
+ run();
119
+ } catch (e) {
120
+ if (!(e instanceof AccessDeniedError)) return e;
121
+ const details: unknown = e.details;
122
+ return typeof details === "object" && details !== null && "reason" in details
123
+ ? details.reason
124
+ : details;
125
+ }
126
+ return "no throw";
127
+ }
128
+
129
+ test("denies unsafeRaw even with a declared escapeHatch, without reporting", () => {
130
+ const reports: Array<{ kind: EscapeHatchKind; reason: string }> = [];
131
+ const tdb = memberReadOnlyDb((kind, reason) => {
132
+ reports.push({ kind, reason });
133
+ });
134
+
135
+ expect(deniedReason(() => tdb.unsafeRaw(REASON))).toBe(
136
+ FrameworkReasons.memberResolutionReadOnly,
137
+ );
138
+ expect(reports).toEqual([]);
139
+ });
140
+
141
+ test("denies the declared-step path and survives a withUnsafeRawGrant rebind", () => {
142
+ const tdb = memberReadOnlyDb();
143
+ const regranted = withUnsafeRawGrant(tdb, { reason: "hook re-grant" });
144
+
145
+ expect(deniedReason(() => unsafeRawForDeclaredStep(tdb, REASON))).toBe(
146
+ FrameworkReasons.memberResolutionReadOnly,
147
+ );
148
+ expect(deniedReason(() => regranted.unsafeRaw(REASON))).toBe(
149
+ FrameworkReasons.memberResolutionReadOnly,
150
+ );
151
+ expect(deniedReason(() => unsafeRawForDeclaredStep(regranted, REASON))).toBe(
152
+ FrameworkReasons.memberResolutionReadOnly,
153
+ );
154
+ });
155
+
156
+ test("survives the acknowledgeConventionCrossTenant rebind", () => {
157
+ const crossTenant = acknowledgeConventionCrossTenant(memberReadOnlyDb(), "test: cross-tenant");
158
+
159
+ expect(deniedReason(() => crossTenant.unsafeRaw(REASON))).toBe(
160
+ FrameworkReasons.memberResolutionReadOnly,
161
+ );
162
+ });
163
+ });
@@ -175,6 +175,12 @@ export type ExecutorContext = {
175
175
  | { kind: "empty" }
176
176
  | { kind: "sql"; sqlText: string; params: readonly unknown[] },
177
177
  ) => Promise<Record<string, unknown>[]>;
178
+ readonly loadExpectSnapshot: (
179
+ db: TenantDb,
180
+ id: EntityId,
181
+ streamTenantId: TenantId,
182
+ expectKeys: readonly string[],
183
+ ) => Promise<{ readonly row: Record<string, unknown> | null; readonly streamVersion: number }>;
178
184
  readonly encryptForStorage: (
179
185
  row: Record<string, unknown>,
180
186
  user: SessionUser,
@@ -411,6 +417,78 @@ export function buildExecutorContext(
411
417
  ];
412
418
  }
413
419
 
420
+ // Combined, atomic read for the `expect:` precondition (#3024): the
421
+ // projection row's expect-checked fields AND the events table's current
422
+ // MAX(version) for this aggregate, in ONE SQL statement.
423
+ //
424
+ // Two separate reads (loadById then getStreamVersion, in either order)
425
+ // leave a real gap: applyEntityEvent writes the projection in a SEPARATE
426
+ // statement AFTER its event commits, so a second reader can see a version
427
+ // that already reflects a concurrent writer's event while its OWN read of
428
+ // the expect fields still reflects the pre-write projection row — verified
429
+ // empirically: ~40% of genuinely concurrent update() calls slipped a stale
430
+ // precondition through with two round-trips, regardless of read order. One
431
+ // query removes the gap: Postgres executes it against a single consistent
432
+ // snapshot.
433
+ //
434
+ // This also sidesteps a correctness bug the row's own `version` column
435
+ // can't be trusted for: applyEntityEvent only keeps row.version in
436
+ // lock-step with the OTHER projection columns for rows this executor
437
+ // itself wrote. A row seeded directly (raw INSERT — test fixtures, or
438
+ // legacy pre-#762 data) can carry a default version (e.g. 1) with ZERO
439
+ // matching events. Deriving expectedVersion from such a row.version makes
440
+ // the append below target a non-existent predecessor and fail outright.
441
+ // The events table's MAX(version) (0 for such a row) is the only value
442
+ // append() can safely use as expectedVersion — exactly what
443
+ // getStreamVersion() already returns for every other (non-`expect`)
444
+ // caller; this reads it in the same statement as the expect columns
445
+ // instead of a second round-trip.
446
+ //
447
+ // Reads raw column values — no decryptForRead pass — so `expect:` only
448
+ // supports plain (non-pii, non-encrypted) columns: business-state fields
449
+ // like status flags or foreign-key ids, not PII.
450
+ async function loadExpectSnapshot(
451
+ db: TenantDb,
452
+ id: EntityId,
453
+ streamTenantId: TenantId,
454
+ expectKeys: readonly string[],
455
+ ): Promise<{ readonly row: Record<string, unknown> | null; readonly streamVersion: number }> {
456
+ const quote = (name: string): string => `"${name.replace(/"/g, '""')}"`;
457
+ const columnOf = (field: string): string =>
458
+ quote((table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field));
459
+ const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
460
+
461
+ const selectCols = expectKeys.map((key) => `${columnOf(key)} AS ${quote(key)}`);
462
+ const whereParts: string[] = [`${columnOf("id")} = $1`];
463
+ const params: unknown[] = [id];
464
+ if (table["tenantId"] !== undefined && db.mode === "tenant") {
465
+ params.push(db.tenantId, SYSTEM_TENANT_ID);
466
+ whereParts.push(`${columnOf("tenantId")} IN ($${params.length - 1}, $${params.length})`);
467
+ }
468
+ params.push(String(id), streamTenantId);
469
+ const streamAggregateIdx = params.length - 1;
470
+ const streamTenantIdx = params.length;
471
+
472
+ const sqlText =
473
+ `SELECT ${selectCols.join(", ")}, ` +
474
+ `(SELECT MAX("version") FROM "kumiko_events" WHERE "aggregate_id" = $${streamAggregateIdx} ` +
475
+ `AND "tenant_id" = $${streamTenantIdx}) AS "__streamVersion" ` +
476
+ `FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
477
+
478
+ const rows = await executeRawQueryRead<Record<string, unknown>>(
479
+ tenantDbRunner(db),
480
+ sqlText,
481
+ params,
482
+ );
483
+ const row = rows[0];
484
+ if (!row) return { row: null, streamVersion: 0 };
485
+ const { __streamVersion, ...fields } = row;
486
+ return {
487
+ row: fields,
488
+ streamVersion: typeof __streamVersion === "number" ? __streamVersion : 0,
489
+ };
490
+ }
491
+
414
492
  return {
415
493
  table,
416
494
  entity,
@@ -423,6 +501,7 @@ export function buildExecutorContext(
423
501
  loadById,
424
502
  assertStreamWritable,
425
503
  loadWithOwnership,
504
+ loadExpectSnapshot,
426
505
  encryptForStorage,
427
506
  decryptForRead,
428
507
  applyDefaults,
@@ -8,6 +8,7 @@ import {
8
8
  IdempotentReplayError,
9
9
  InternalError,
10
10
  NotFoundError,
11
+ PreconditionFailedError,
11
12
  UnprocessableError,
12
13
  writeFailure,
13
14
  } from "../errors";
@@ -114,6 +115,7 @@ export function createWriteVerbs(
114
115
  stripSensitive,
115
116
  loadById,
116
117
  assertStreamWritable,
118
+ loadExpectSnapshot,
117
119
  } = ctx;
118
120
 
119
121
  return {
@@ -399,11 +401,46 @@ export function createWriteVerbs(
399
401
  // aggregate); a stale row.version here would make the next CRUD write
400
402
  // trip `events_aggregate_version_uq` (tenant_id, aggregate_id, version)
401
403
  // with version_conflict.
402
- const currentVersion = await getStreamVersion(
403
- runner,
404
- String(payload.id),
405
- streamTenantFor(user),
406
- );
404
+ //
405
+ // `expect:` (#3024) reads this same authoritative version, but through
406
+ // loadExpectSnapshot's single combined query instead of a second,
407
+ // separate getStreamVersion() round-trip: two reads (in either order)
408
+ // leave a real gap — applyEntityEvent writes the projection in a
409
+ // SEPARATE statement after its event commits, so a second reader's
410
+ // version-read can land in that gap and see a fresh, non-conflicting
411
+ // version paired with a still-stale projection row (verified
412
+ // empirically: ~40% of genuinely concurrent runs slipped through with
413
+ // two round-trips). One query removes the gap. It also avoids trusting
414
+ // the row's own `version` column for the expectedVersion: that column
415
+ // is only in lock-step with the rest of the row for rows THIS executor
416
+ // wrote — a raw-seeded row (test fixture, or legacy pre-#762 data) can
417
+ // carry a default version with zero matching events, which would make
418
+ // the append below target a non-existent predecessor and fail outright.
419
+ let currentVersion: number;
420
+ if (updateOptions?.expect) {
421
+ const expectKeys = Object.keys(updateOptions.expect);
422
+ const snapshot = await loadExpectSnapshot(
423
+ db,
424
+ payload.id,
425
+ streamTenantFor(user),
426
+ expectKeys,
427
+ );
428
+ if (!snapshot.row) {
429
+ return writeFailure(new PreconditionFailedError({ entityId: payload.id, field: "id" }));
430
+ }
431
+ const mismatch = Object.entries(updateOptions.expect).find(
432
+ ([key, expected]) => snapshot.row?.[key] !== expected,
433
+ );
434
+ if (mismatch) {
435
+ return writeFailure(
436
+ new PreconditionFailedError({ entityId: payload.id, field: mismatch[0] }),
437
+ );
438
+ }
439
+ currentVersion = snapshot.streamVersion;
440
+ } else {
441
+ currentVersion = await getStreamVersion(runner, String(payload.id), streamTenantFor(user));
442
+ }
443
+
407
444
  if (!updateOptions?.skipOptimisticLock) {
408
445
  if (payload.version === undefined) {
409
446
  return writeFailure(
package/src/db/index.ts CHANGED
@@ -148,6 +148,7 @@ export {
148
148
  buildEntityTable,
149
149
  declareGlobalTenancy,
150
150
  type EntityTable,
151
+ physicalColumnName,
151
152
  toSnakeCase,
152
153
  toTableName,
153
154
  } from "./table-builder";
@@ -25,7 +25,7 @@ import {
25
25
  type WhereObject,
26
26
  } from "../db/query";
27
27
  import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
28
- import { AccessDeniedError, InternalError } from "../errors";
28
+ import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied } from "../errors";
29
29
  import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
30
30
  import { fallbackEscapeHatchReporter } from "../observability/escape-hatch-report";
31
31
  import type { DbRunner } from "./connection";
@@ -202,6 +202,9 @@ export type TenantDbGrants = {
202
202
  readonly globalWrites?: EscapeHatchDeclaration;
203
203
  readonly unsafeRaw?: EscapeHatchDeclaration;
204
204
  readonly report?: EscapeHatchReporter;
205
+ // Set for a resolved member principal (ctx.queryAsMember): no raw DbRunner leaves
206
+ // this TenantDb, so no handler can COMMIT/RELEASE SAVEPOINT out of the READ ONLY scope.
207
+ readonly memberReadOnly?: boolean;
205
208
  };
206
209
 
207
210
  const unsafeRawRebinders = new WeakMap<
@@ -419,6 +422,10 @@ export function createTenantDb(
419
422
  if (reason.trim().length === 0) {
420
423
  throw new Error("unsafeRaw requires a non-empty reason");
421
424
  }
425
+ // Ahead of the grant check: a declared escapeHatch must not buy a raw runner here either.
426
+ if (grants?.memberReadOnly) {
427
+ throw memberResolutionReadOnlyDenied();
428
+ }
422
429
  if (!hasGrant(grants?.unsafeRaw)) {
423
430
  throw new AccessDeniedError({
424
431
  message:
@@ -95,3 +95,59 @@ describe("validateBoot — i18n surface keys", () => {
95
95
  expect(() => validateBoot([feature])).not.toThrow();
96
96
  });
97
97
  });
98
+
99
+ // fw#2986: requiredKeysFromScreen read only section.fields, so a field declared
100
+ // through section.groups — and every groups[].title — slipped past this guard.
101
+ describe("validateBoot — i18n surface keys from section.groups (fw#2986)", () => {
102
+ function groupsFeature(keys: Record<string, { de: string; en: string }>) {
103
+ return defineFeature("demo", (r) => {
104
+ r.entity(
105
+ "item",
106
+ createEntity({
107
+ table: "Items",
108
+ fields: {
109
+ name: createTextField({ sortable: true, personal: false, reason: "test_fixture" }),
110
+ },
111
+ }),
112
+ );
113
+ r.screen({
114
+ id: "item-edit",
115
+ type: "entityEdit",
116
+ entity: "item",
117
+ layout: {
118
+ sections: [
119
+ {
120
+ fields: [],
121
+ groups: [{ title: "demo:group.basics", fields: ["name"] }],
122
+ },
123
+ ],
124
+ },
125
+ });
126
+ r.translations({ keys });
127
+ });
128
+ }
129
+
130
+ const complete = {
131
+ "screen:item-edit.title": { de: "Bearbeiten", en: "Edit" },
132
+ "demo:group.basics": { de: "Basis", en: "Basics" },
133
+ "demo:entity:item:field:name": { de: "Name", en: "Name" },
134
+ };
135
+
136
+ test("passes when the group title and the group's field label are translated", () => {
137
+ expect(() => validateBoot([groupsFeature(complete)])).not.toThrow();
138
+ });
139
+
140
+ test("throws for a missing label of a field declared only in groups", () => {
141
+ const { "demo:entity:item:field:name": _dropped, ...withoutFieldLabel } = complete;
142
+ expect(() => validateBoot([groupsFeature(withoutFieldLabel)])).toThrow(
143
+ /required translation key missing: "demo:entity:item:field:name"/,
144
+ );
145
+ });
146
+
147
+ test("throws for a missing groups[].title translation", () => {
148
+ const { "demo:group.basics": _dropped, ...withoutGroupTitle } = complete;
149
+ expect(() => validateBoot([groupsFeature(withoutGroupTitle)])).toThrow(
150
+ /required translation key missing: "demo:group\.basics"/,
151
+ );
152
+ });
153
+ });