@cosmicdrift/kumiko-framework 0.270.0 → 0.273.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 (104) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
  3. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
  4. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
  5. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
  6. package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
  7. package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
  8. package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
  9. package/src/api/server.ts +7 -20
  10. package/src/bun-db/__tests__/query-guards.test.ts +60 -3
  11. package/src/bun-db/query.ts +24 -21
  12. package/src/changes.json +91 -0
  13. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
  14. package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
  15. package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
  16. package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
  17. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
  18. package/src/db/__tests__/tenant-db-global.test.ts +92 -0
  19. package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
  20. package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
  21. package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
  22. package/src/db/apply-entity-event.ts +1 -1
  23. package/src/db/assert-exists-in.ts +1 -1
  24. package/src/db/event-store-executor-context.ts +5 -2
  25. package/src/db/event-store-executor-read.ts +6 -4
  26. package/src/db/event-store-executor-write.ts +24 -18
  27. package/src/db/queries/shadow-swap.ts +36 -0
  28. package/src/db/replay-migration-sql.ts +5 -5
  29. package/src/db/tenant-db-runner.ts +23 -0
  30. package/src/db/tenant-db.ts +97 -24
  31. package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
  32. package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
  33. package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
  34. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
  35. package/src/engine/__tests__/boot-validator.test.ts +51 -0
  36. package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
  37. package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
  38. package/src/engine/__tests__/entity-handlers.test.ts +28 -0
  39. package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
  40. package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
  41. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
  42. package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
  43. package/src/engine/__tests__/screen.test.ts +150 -0
  44. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
  45. package/src/engine/__tests__/steps-read.test.ts +204 -8
  46. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
  47. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
  48. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
  49. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
  50. package/src/engine/boot-validator/entity-handler.ts +21 -0
  51. package/src/engine/boot-validator/global-tenancy.ts +13 -1
  52. package/src/engine/boot-validator/parent-ref.ts +1 -1
  53. package/src/engine/boot-validator/projection-list-screens.ts +67 -2
  54. package/src/engine/boot-validator/query-output-columns.ts +33 -0
  55. package/src/engine/boot-validator/screens.ts +154 -31
  56. package/src/engine/build-config-feature-schema.ts +7 -4
  57. package/src/engine/entity-handlers.ts +71 -19
  58. package/src/engine/feature-entity-handlers.ts +4 -4
  59. package/src/engine/index.ts +5 -0
  60. package/src/engine/registry-validate.ts +12 -4
  61. package/src/engine/schema-builder.ts +6 -0
  62. package/src/engine/screen-helpers.ts +52 -0
  63. package/src/engine/steps/_read-source.ts +12 -0
  64. package/src/engine/steps/read-find-many.ts +8 -2
  65. package/src/engine/steps/read-find-one.ts +11 -8
  66. package/src/engine/steps/unsafe-projection-delete.ts +2 -1
  67. package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
  68. package/src/engine/types/index.ts +8 -1
  69. package/src/errors/i18n/de.yaml +14 -0
  70. package/src/errors/i18n/en.yaml +13 -0
  71. package/src/errors/reasons.ts +4 -0
  72. package/src/i18n/required-surface-keys.ts +6 -2
  73. package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
  74. package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
  75. package/src/jobs/job-runner.ts +35 -5
  76. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
  77. package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
  78. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
  79. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
  80. package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
  81. package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
  82. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
  83. package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
  84. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
  85. package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
  86. package/src/pipeline/dispatch-query.ts +27 -8
  87. package/src/pipeline/dispatch-shared.ts +141 -57
  88. package/src/pipeline/dispatch-stream.ts +1 -3
  89. package/src/pipeline/dispatch-write.ts +62 -14
  90. package/src/pipeline/dispatcher.ts +20 -0
  91. package/src/pipeline/escape-hatch-report.ts +127 -0
  92. package/src/pipeline/index.ts +8 -0
  93. package/src/pipeline/member-read-only-transaction.ts +75 -0
  94. package/src/pipeline/msp-rebuild.ts +2 -0
  95. package/src/pipeline/projection-rebuild.ts +2 -0
  96. package/src/pipeline/projections-runner.ts +15 -13
  97. package/src/pipeline/system-identity-switch.ts +121 -9
  98. package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
  99. package/src/scripts/codemod/migrate-db-raw.ts +19 -6
  100. package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
  101. package/src/ui-types/index.ts +3 -0
  102. package/src/utils/__tests__/ids.test.ts +29 -1
  103. package/src/utils/ids.ts +10 -1
  104. package/src/utils/index.ts +1 -1
package/src/api/server.ts CHANGED
@@ -1,17 +1,10 @@
1
1
  import { Hono } from "hono";
2
2
  import type { DbConnection, PgClient } from "../db/connection";
3
- import { createTenantDb } from "../db/tenant-db";
4
3
  import { createDerivativesContext } from "../derivatives/derivatives-context";
5
4
  import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names";
6
5
  import { runsInLane } from "../engine/run-in";
7
6
  import { createAnonymousUser } from "../engine/system-user";
8
- import {
9
- type AppContext,
10
- isFileField,
11
- type Registry,
12
- type RunIn,
13
- SYSTEM_TENANT_ID,
14
- } from "../engine/types";
7
+ import { type AppContext, isFileField, type Registry, type RunIn } from "../engine/types";
15
8
  import { createFileContext } from "../files/file-handle";
16
9
  import type { FileRoutesOptions } from "../files/file-routes";
17
10
  import { createFileRoutes } from "../files/file-routes";
@@ -491,9 +484,9 @@ export function buildServer(options: ServerOptions): KumikoServer {
491
484
 
492
485
  // MultiStreamProjections: one EventConsumer per MSP. Handler routes by
493
486
  // event.type into the MSP's apply map. MSPs aggregate cross-aggregate but
494
- // still within one tenant by default — the applier receives the
495
- // tenant-scoped DbRunner; SYSTEM_TENANT_ID events pass through the raw
496
- // baseDb so system-level sinks can read across tenants.
487
+ // still within one tenant by default — the applier receives the plain
488
+ // baseDb DbRunner; tenant consistency comes from the triggering event's
489
+ // own tenantId, not a per-event TenantDb scope.
497
490
  //
498
491
  // Lane-filter (Welle 2.6.b): MSPs declare `runIn` to pin them to a
499
492
  // deploy-lane. An MSP with `runIn: "api"` won't be wired into the
@@ -535,12 +528,6 @@ export function buildServer(options: ServerOptions): KumikoServer {
535
528
  // production. Defensive return for the type-narrowing path.
536
529
  return;
537
530
  }
538
- const scopedDb =
539
- event.tenantId === SYSTEM_TENANT_ID ? baseDb : createTenantDb(baseDb, event.tenantId);
540
- // Hand the raw DbRunner to apply(): MSPs write to their projection
541
- // table directly, they don't go through the TenantDb wrapper.
542
- const rawRunner =
543
- event.tenantId === SYSTEM_TENANT_ID ? baseDb : (scopedDb as { raw: typeof baseDb }).raw; // @cast-boundary engine-bridge
544
531
  // Saga/process-manager ctx: apply can call ctx.appendEvent to cascade
545
532
  // a follow-up event onto another aggregate. Uses the triggering event's
546
533
  // tenantId + userId so the causal chain stays tenant-consistent.
@@ -553,7 +540,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
553
540
  : undefined;
554
541
  const applyCtx = createMultiStreamApplyContext({
555
542
  registry: options.registry,
556
- db: rawRunner,
543
+ db: baseDb,
557
544
  tenantId: event.tenantId,
558
545
  userId: event.metadata.userId,
559
546
  ...(mspOwner && { callerFeature: mspOwner }),
@@ -562,12 +549,12 @@ export function buildServer(options: ServerOptions): KumikoServer {
562
549
  derivatives: createDerivativesContext({
563
550
  files: mspFiles,
564
551
  registry: options.registry,
565
- db: rawRunner,
552
+ db: baseDb,
566
553
  tenantId: event.tenantId,
567
554
  }),
568
555
  }),
569
556
  });
570
- await applyFn(event, rawRunner, applyCtx);
557
+ await applyFn(event, baseDb, applyCtx);
571
558
  // Keep ctx reachable to satisfy the EventConsumerHandler signature.
572
559
  void ctx;
573
560
  },
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import type { DbRunner } from "../../db/connection";
2
3
  import type { EntityTableMeta } from "../../db/entity-table-meta";
3
- import { incrementCounter, selectMany } from "../query";
4
+ import { createTenantDb } from "../../db/tenant-db";
5
+ import { testTenantId } from "../../stack";
6
+ import { asRawClient, countWhere, incrementCounter, selectMany, transaction } from "../query";
4
7
 
5
8
  const meta: EntityTableMeta = {
6
9
  source: "unmanaged",
@@ -35,9 +38,8 @@ describe("bun-db guards — limit injection", () => {
35
38
  });
36
39
 
37
40
  describe("bun-db guards — silent tenant-scope bypass", () => {
38
- // A TenantDb-shaped handle: has .raw.unsafe + the scoped methods + tenantId.
41
+ // A TenantDb-shaped handle: the scoped methods + tenantId, no `.unsafe` of its own.
39
42
  const tenantDb = {
40
- raw: { unsafe: async () => [] as unknown[] },
41
43
  tenantId: "00000000-0000-4000-8000-000000000001",
42
44
  selectMany: async () => [],
43
45
  fetchOne: async () => undefined,
@@ -52,3 +54,58 @@ describe("bun-db guards — silent tenant-scope bypass", () => {
52
54
  ).rejects.toThrow("does not apply the tenant filter");
53
55
  });
54
56
  });
57
+
58
+ describe("bun-db guards — a real TenantDb has no unfiltered escape via asRawClient", () => {
59
+ const tenantId = testTenantId(1);
60
+
61
+ // A runner whose unsafe()/begin() blow up — proves each guard throws
62
+ // BEFORE reaching the driver, same contract as `explodingDb` above.
63
+ function explodingRunner(): DbRunner {
64
+ return {
65
+ unsafe: async () => {
66
+ throw new Error("unsafe must not be reached — asRawClient should fail closed first");
67
+ },
68
+ begin: async () => {
69
+ throw new Error("begin must not be reached — asRawClient should fail closed first");
70
+ },
71
+ } as unknown as DbRunner;
72
+ }
73
+
74
+ test("asRawClient throws and points callers at ctx.db.unsafeRaw", () => {
75
+ const tdb = createTenantDb(explodingRunner(), tenantId);
76
+ expect(() => asRawClient(tdb)).toThrow("unsafeRaw");
77
+ });
78
+
79
+ test("countWhere throws before issuing any SQL", async () => {
80
+ const tdb = createTenantDb(explodingRunner(), tenantId);
81
+ await expect(countWhere(tdb, meta, {})).rejects.toThrow("unsafeRaw");
82
+ });
83
+
84
+ test("transaction throws before issuing any SQL", async () => {
85
+ const tdb = createTenantDb(explodingRunner(), tenantId);
86
+ await expect(transaction(tdb, async () => "unreached")).rejects.toThrow("unsafeRaw");
87
+ });
88
+ });
89
+
90
+ describe("bun-db guards — selectMany still delegates a real TenantDb, tenant-filtered", () => {
91
+ test("selectMany(tenantDb, ...) issues tenant-filtered SQL via the bound runner", async () => {
92
+ const tenantId = testTenantId(2);
93
+ const captured: { sql: string; values: readonly unknown[] }[] = [];
94
+ const recordingRunner: DbRunner = {
95
+ unsafe: async (sql: string, values: readonly unknown[]) => {
96
+ captured.push({ sql, values });
97
+ return [] as unknown[];
98
+ },
99
+ begin: async () => {
100
+ throw new Error("begin not used in this test");
101
+ },
102
+ } as unknown as DbRunner;
103
+ const tdb = createTenantDb(recordingRunner, tenantId);
104
+
105
+ await selectMany(tdb, meta, {});
106
+
107
+ expect(captured).toHaveLength(1);
108
+ expect(captured[0]?.sql).toMatch(/tenant_id/i);
109
+ expect(captured[0]?.values).toContain(tenantId);
110
+ });
111
+ });
@@ -100,11 +100,14 @@ export function asRawClient(db: unknown): RawClient {
100
100
  if (typeof dbAny["unsafe"] === "function") {
101
101
  return dbAny as unknown as RawClient;
102
102
  }
103
- // TenantDb-shape: framework wrapper exposing the underlying runner as `.raw`.
104
- // Callers that pass `ctx.db` instead of `ctx.db.raw` land here — unwrap once.
105
- const raw = dbAny["raw"];
106
- if (raw && typeof (raw as Record<string, unknown>)["unsafe"] === "function") {
107
- return raw as unknown as RawClient;
103
+ // TenantDb (ctx.db) has no unfiltered escape via asRawClient anymore — fail
104
+ // closed instead of silently unwrapping a tenant-scoped handle.
105
+ if (isTenantDbShape(db)) {
106
+ throw new Error(
107
+ "asRawClient: received a tenant-scoped TenantDb (ctx.db). Raw SQL helpers do not apply the " +
108
+ "tenant filter — use a ctx.db.<method>, or ctx.db.unsafeRaw(reason) with escapeHatch: " +
109
+ "{ reason } on the handler/hook (r.systemScope() features: ctx.systemDb.unsafeRaw(reason)).",
110
+ );
108
111
  }
109
112
  // Drizzle DbConnection (legacy compat): $client = postgres-js Sql.
110
113
  const $client = dbAny["$client"];
@@ -118,7 +121,7 @@ export function asRawClient(db: unknown): RawClient {
118
121
  return sessionClient as unknown as RawClient;
119
122
  }
120
123
  throw new Error(
121
- "bun-db: db argument has no .unsafe() — pass Bun.SQL, postgres-js Sql, TenantDb, or a transaction handle.",
124
+ "bun-db: db argument has no .unsafe() — pass Bun.SQL, postgres-js Sql, or a transaction handle.",
122
125
  );
123
126
  }
124
127
 
@@ -199,35 +202,35 @@ type TenantDbDelegate = {
199
202
  deleteMany(table: TableLike, where: WhereObject): Promise<void>;
200
203
  };
201
204
 
202
- function tenantDbDelegate(db: unknown): TenantDbDelegate | undefined {
203
- if (typeof db !== "object" || db === null) return undefined;
205
+ // Duck-types TenantDb: five scoped methods + tenantId, no `.unsafe` of its own.
206
+ // A throwing systemScope guard Proxy fails closed here too — intended, not a bug.
207
+ function isTenantDbShape(db: unknown): boolean {
208
+ if (typeof db !== "object" || db === null) return false;
204
209
  const d = db as Record<string, unknown>;
205
- const raw = d["raw"];
206
- if (
207
- raw &&
208
- typeof (raw as Record<string, unknown>)["unsafe"] === "function" &&
210
+ if (typeof d["unsafe"] === "function") return false;
211
+ return (
209
212
  typeof d["selectMany"] === "function" &&
210
213
  typeof d["fetchOne"] === "function" &&
211
214
  typeof d["insertOne"] === "function" &&
212
215
  typeof d["updateMany"] === "function" &&
213
216
  typeof d["deleteMany"] === "function" &&
214
217
  "tenantId" in d
215
- ) {
216
- return db as TenantDbDelegate;
217
- }
218
- return undefined;
218
+ );
219
+ }
220
+
221
+ function tenantDbDelegate(db: unknown): TenantDbDelegate | undefined {
222
+ return isTenantDbShape(db) ? (db as TenantDbDelegate) : undefined;
219
223
  }
220
224
 
221
- // Guard for helpers that do NOT delegate to TenantDb (upsert/increment/insertMany):
222
- // passing a TenantDb here would silently run unscoped against `.raw`, bypassing
223
- // the tenant filter. Fail loudly so the caller picks `ctx.db.<method>` (scoped)
224
- // or `ctx.db.raw` (explicit cross-tenant) on purpose.
225
+ // Fails loudly for helpers that don't delegate to TenantDb (upsert/increment/insertMany) —
226
+ // passing one here would otherwise reach asRawClient and throw with a less specific message.
225
227
  // @wrapper-known semantic-alias
226
228
  function assertNotTenantScoped(db: unknown, fnName: string): void {
227
229
  if (tenantDbDelegate(db) !== undefined) {
228
230
  throw new Error(
229
231
  `${fnName}: received a tenant-scoped db but this helper does not apply the tenant filter. ` +
230
- `Pass ctx.db.raw for an explicit cross-tenant op, or use a scoped TenantDb method.`,
232
+ "Pass ctx.db.unsafeRaw(reason) (with escapeHatch: { reason } on the handler/hook) for an " +
233
+ "explicit cross-tenant op, or use a scoped TenantDb method.",
231
234
  );
232
235
  }
233
236
  }
package/src/changes.json CHANGED
@@ -1,4 +1,95 @@
1
1
  [
2
+ {
3
+ "version": "0.272.0",
4
+ "type": "breaking",
5
+ "title": "r.step.read.findOne/read.findMany now tenant-filter by default; cross-tenant reads need unsafeAllTenants + escapeHatch (fw#2914).",
6
+ "detail": "`r.step.read.findOne`/`r.step.read.findMany` no longer read through the raw `DbRunner` bound to `ctx.db` (`tenantDbRunner(ctx.db)`, which bypassed every tenant filter). They now call `selectMany(ctx.db, table, where, opts)` — `ctx.db` is a `TenantDb`, so the same `TenantDb.selectMany` tenant filter that `ctx.db` method-form reads already apply now also applies here: own tenant + `SYSTEM_TENANT_ID` reference rows, and a caller-supplied `where.tenantId` outside that scope is narrowed away instead of passed through. Both steps gain an optional `unsafeAllTenants: { reason: string }` argument; when set, the step reads through `ctx.systemDb.unsafeRaw(reason)` (`r.systemScope()` features, already granted by the feature's own systemScope) or `ctx.db.unsafeRaw(reason)` otherwise — the latter throws `AccessDeniedError` unless the write/query handler declares `escapeHatch: { reason }` — and reports an `\"unsafe-raw\"` escape-hatch audit event, same as any other `ctx.db.unsafeRaw` use. Inside an `r.systemScope()` handler `ctx.db` stays a fail-closed guard, so a read step there without `unsafeAllTenants` still throws `InternalError` (the message now points at `ctx.systemDb`).",
7
+ "migration": "No change needed for a read step that only ever reads within the caller's own tenant — it now gets the same tenant filter `ctx.db` method-form reads already had, and a `where.tenantId` for a foreign tenant is silently narrowed to the caller's own scope instead of leaking rows. A read step that intentionally reads across tenants must add `unsafeAllTenants: { reason: \"<why the tenant filter cannot apply>\" }` to the step AND `escapeHatch: { reason }` (same reason) to the owning write/query handler (or run the step inside an `r.systemScope()` feature, where the grant already exists). No codemod — cross-tenant read-step usage is expected to be rare and needs a real reason per call site."
8
+ },
9
+ {
10
+ "version": "0.272.0",
11
+ "type": "breaking",
12
+ "title": "ctx.queryAsMember runs the queried handler inside a Postgres READ ONLY transaction — ctx.db and ctx.db.unsafeRaw writes fail (fw#2902).",
13
+ "detail": "`executeQuery` (`pipeline/dispatch-query.ts`) runs every query whose SessionUser has `origin: \"member-resolution\"` through `runInMemberReadOnlyTransaction` (`pipeline/member-read-only-transaction.ts`): the handler context, the handler, its postQuery hooks and the field-read filter execute inside a savepoint whose first statement is `SET TRANSACTION READ ONLY`. The savepoint is always rolled back — a released one would leave the caller's transaction read-only. With a caller transaction (write handler, in-transaction hook) the savepoint nests into it; without one (query handler, job, `dispatcher.createMemberReader`) the framework opens a pool transaction around the savepoint, holding one connection for the handler's runtime — nested queries' feature/trial gates and rate limits still use their own resolvers, so many concurrent member reads need pool headroom. Inside the subtransaction Postgres rejects switching back via `SET TRANSACTION READ WRITE` (SQLSTATE 25001). Nested `ctx.query` calls inherit the read-only handle; `ctx.queryAs` is now denied for a resolved member principal like the other identity switches (`member_resolution_read_only`), so it cannot reach a context with a writable `dbOutsideTransaction`. Every write — `ctx.db.insertOne`/`updateMany`/`deleteMany`, `ctx.db.unsafeRaw(...)` SQL, `SELECT ... FOR UPDATE/SHARE`, `nextval()`, temp tables — fails with SQLSTATE 25006, surfaced as `AccessDeniedError` (`details.reason` `member_resolution_read_only`, the PG error as `cause`). Feature gate, rate limit, access check and payload validation still run before the transaction opens; membership, principal and auth-claims resolution keep running on the root connection outside it. Handler code that deliberately issues transaction control (`COMMIT`, `RELEASE SAVEPOINT`, `SET SESSION CHARACTERISTICS`) through `unsafeRaw` is not covered — `unsafeRaw` already requires an `escapeHatch` declaration. Top-level `dispatcher.query` and HTTP queries of ordinary users are unchanged.",
14
+ "migration": "Find query handlers reached via `ctx.queryAsMember(userId, qn, ...)` (including handlers they call with `ctx.query`) and check them for database side effects: `ctx.db` inserts/updates/deletes, write SQL through `ctx.db.unsafeRaw`, row locks, sequences, temp tables, `ctx.queryAs` calls, or catching a failed statement and continuing (a failed statement now aborts the enclosing transaction). Move such side effects into a write handler called by the original caller, or read the data without writing. Code branching on the raw Postgres error from such a write must branch on `details.reason === \"member_resolution_read_only\"` instead. Audit at release: bundled-features 0 sites, solon 0, offlot-app 0, money-horse 0, kumiko-enterprise 0 (no consumer calls ctx.queryAsMember yet)."
15
+ },
16
+ {
17
+ "version": "0.272.0",
18
+ "type": "improvement",
19
+ "title": "Follow-up screens (secretMint, actionForm, entityEdit) return to the screen the navigation came from, including the dashboard hosting an embedded screen.",
20
+ "detail": "Screen-target navigate actions (list toolbar and row actions, default \"+ New\" and edit row actions, projectionDetail and entityEdit header actions, projectionDetail metric navigate, the create-app entityEdit row-click fallback) add a `returnTo` search param naming the outermost rendered KumikoScreen (`<shortScreenId>` or `<shortScreenId>/<entityId>`); a screen embedded via a dashboard `kind: \"screen\"` panel reports the dashboard. secretMint cancel/confirm, actionForm cancel/submit (not drawer-hosted) and entityEdit create/update submit, cancel and delete navigate to that target instead of their declared `redirect`/`cancelTarget` or the default entity list. A declared redirect that targets an entityEdit or projectionDetail screen (a forward navigation to the result) keeps precedence; forms without any declared navigation still stay put; delete never returns onto the deleted record. The value is validated on read: it must be one or two `/`-separated segments naming a registered short screen id (no `:`, not the form itself) the current user can access, an entityId is only accepted for entityEdit/projectionDetail targets and may not contain `\\ ? #` or whitespace, a non-singleton projectionDetail requires one; any other value is ignored. Visible behavior change: a form reached from a non-embedded list whose id-less redirect names another screen now returns to that list. Not covered: navigations issued by custom screen code via `nav.navigate`, restoring the host screen tab/filter params, and nested return chains. New renderer exports: `RETURN_TO_PARAM`, `ReturnHost`, `ReturnHostProvider`, `useReturnHost`, `useReturnTarget`, `resolveReturnTarget`, `navigateWithReturnTo`."
21
+ },
22
+ {
23
+ "version": "0.272.0",
24
+ "type": "improvement",
25
+ "title": "Entity-convention handlers accept escapeHatch: { reason } for cross-tenant access; crossTenant: true is deprecated (fw#2915).",
26
+ "detail": "`defineEntityCreateHandler`/`UpdateHandler`/`DeleteHandler`/`RestoreHandler`/`ListHandler`/`DetailHandler`, `defineEntityWriteHandler`/`defineEntityQueryHandler`, and `registerEntityCrud`'s `write`/`read` options now accept `escapeHatch: { reason }` (`EntityHandlerOptions.escapeHatch`, `@cosmicdrift/kumiko-types/entity-handlers`): the ONE declaring handler gets a system-mode `TenantDb` via a new framework-private `acknowledgeConventionCrossTenant(tenantDb, reason)` in `db/tenant-db.ts` (not exported from `db/index.ts`); write verbs (update/delete/restore) still address the target row's own tenant stream; `access` stays the only caller gate. Every use reports an `acknowledge-cross-tenant` escape-hatch audit event, same as the hand-written-handler escape hatches — unlike those, it grants neither `ctx.db.unsafeRaw`, `db.global()` writes, nor an identity switch. `crossTenant?: boolean` on `EntityHandlerOptions`/`EntityCrudHandlerDefaults` is now `@deprecated`: it keeps working, but `pipeline/dispatcher.ts` now logs `DEPRECATED_CROSS_TENANT_SIGNAL` (`\"deprecation:entity-handler-cross-tenant\"`) once per handler when `createDispatcher` boots, for a handler `isDeprecatedCrossTenantHandler` (new export, `engine/entity-handlers.ts`) identifies as built from `crossTenant: true`, and its use is audited the same `acknowledge-cross-tenant` way. `escapeHatch` set on an `EntityHandlerOptions`/`EntityCrudHandlerDefaults` object is NOT passed through to the produced `WriteHandlerDef`/`QueryHandlerDef`'s own `escapeHatch` field — it only gates the internal `TenantDb`, so the two `escapeHatch` fields (options-level vs. handler-def-level) are independent grants. `crossTenant: true` is planned for removal in a later breaking release.",
27
+ "migration": "Replace `crossTenant: true` with `escapeHatch: { reason: \"...\" }` on the same entity-convention handler call. Run `bun packages/framework/src/scripts/codemod/migrate-cross-tenant.ts [--dry-run] <path...>` first — it rewrites every `defineEntity*Handler`/`defineEntityWriteHandler`/`defineEntityQueryHandler` call whose entity name and verb it can read off the call itself, with a generated reason to replace with the concrete operator use case. Sites it lists as manual (a `crossTenant` on a shared/spread access object, a `registerEntityCrud` `write`/`read` block covering several verbs, a non-boolean-literal value, or an object that already declares `escapeHatch`) need a hand-written `escapeHatch: { reason }` per handler/verb.",
28
+ "codemod": "scripts/codemod/migrate-cross-tenant.ts"
29
+ },
30
+ {
31
+ "version": "0.271.0",
32
+ "type": "improvement",
33
+ "title": "Entity create handlers accept a caller-chosen id for idempotent creates from jobs/hooks.",
34
+ "detail": "`buildInsertSchema` — and every write handler built through `defineEntityCreateHandler`/`defineEntityWriteHandler`'s `create` verb — now accepts an optional `id` field validated as a UUID string. The create handler only honors it when the acting `SessionUser` is a system identity (`createSystemUser(tenantId, extraRoles)`; `extraRoles` is still needed to clear the handler's normal `hasAccess` role gate, and carrying `SYSTEM_ROLE` is what `isSystemIdentity` checks). An HTTP-authenticated end user's payload `id` is silently ignored and a fresh `generateId()` is minted, exactly as before this field existed — `SYSTEM_ROLE` is reserved and never assignable through tenant membership, so an ordinary caller can never present that identity. `executor.create()` already honored an explicit `id` (the seed/SCIM-import pattern); this only stops the generic create-handler schema from stripping it for a trusted caller. A same-id retry within the same tenant returns `version_conflict` (no duplicate row, no overwrite). A same id already used by a different tenant returns `unique_violation` instead — every entity's projection table has a plain `id` primary key that is not tenant-scoped, so the id space is shared per entity table across tenants; a deterministic id must fold the tenant id into its key to stay collision-free. New `generateDeterministicId(namespace, key)` (`@cosmicdrift/kumiko-framework/utils`, UUIDv5) sits next to `generateId()` for deriving such an id. The agent-tool manifest now lists `id` as an optional parameter on every entity's create tool; supplying it has no effect for a non-system caller, since agent-tool dispatch always passes the real caller's identity, never a system one.",
35
+ "migration": "No action for existing callers — the field is additive and inert for anyone but a system-identity caller, so no existing create request changes behavior. To create idempotently from an event-triggered job or hook: derive an id with `generateDeterministicId(namespace, key)` (fold the tenant id into `key` so two tenants can't collide), dispatch via `ctx.writeAs(createSystemUser(tenantId, [<a role your handler's access rule allows>]), qn, { ...payload, id })` instead of matching on description text or other business fields, and treat a `version_conflict` response as \"already created\"."
36
+ },
37
+ {
38
+ "version": "0.271.0",
39
+ "type": "breaking",
40
+ "title": "ctx.queryProjection(..., { unsafeAllTenants: true }) needs r.systemScope() or escapeHatch; EntityTableMeta projection tables are now tenant-filtered (fw#2913).",
41
+ "detail": "`ctx.queryProjection(qualifiedName, { unsafeAllTenants: true })` previously lifted the tenant filter for anyone who passed the option, with no declaration required. It now goes through the same gate as `ctx.queryAs`/`ctx.writeAs`/`ctx.queryAsMember` (`pipeline/system-identity-switch.ts`): a new `createGatedProjectionReader(callerLabel, hasGrant, ungated, audit)` wraps the reader `dispatch-shared.ts`'s `buildHandlerContext` builds, and only checks `unsafeAllTenants === true` — a call without that option is never gated. Without a grant it throws `AccessDeniedError` with the new reason `unsafe_all_tenants_denied` (`FrameworkReasons.unsafeAllTenantsDenied`, docs entry in `errors/i18n/{en,de}.yaml`). With a grant — the handler's `r.systemScope()` feature or a declared `escapeHatch: { reason }` — it reports exactly one new `EscapeHatchKind` `\"unsafe-all-tenants\"` event through the existing escape-hatch audit sink (added to `escapeHatchUsedSchema.kind` in `bundled-features/audit`). Inside a lifecycle hook, `withHookEscapeHatchGrant`/`bindHookEscapeHatchGrant` re-gate `ctx.queryProjection` the same way they already re-gate `queryAs`/`writeAs`/`queryAsMember` — only the hook's own `escapeHatch` grants it, never the enclosing handler's, and (like the identity-switch gate) not even the handler's own `r.systemScope()` feature carries into a hook without its own `escapeHatch`. New internal helpers in `pipeline/system-identity-switch.ts` — `ProjectionReader` (`HandlerContext[\"queryProjection\"]`), `createGatedProjectionReader`, `unsafeAllTenantsDenied` — are not re-exported from `pipeline/index.ts`. `defineProjectionQueryHandler` (`engine/entity-handlers.ts`) gained an `escapeHatch?: EscapeHatchDeclaration` option, forwarded onto the returned `QueryHandlerDef`. Separately, `hasTenantColumn` (`db/tenant-db.ts`, already used by `TenantDb`'s other escape hatches) is now exported from that module — an internal framework/pipeline helper, not re-exported from `db/index.ts` — and `ctx.queryProjection`'s auto-filter uses it instead of reading `projTable[\"tenantId\"]` directly — a projection table built from a plain `EntityTableMeta` (`defineUnmanagedTable`/`deriveEntityTableMeta`, no drizzle `SchemaTable` symbols) has no `tenantId` JS property, so its rows were never tenant-filtered before this fix even though the table has a `tenant_id` column.",
42
+ "migration": "A handler or hook that calls `ctx.queryProjection(qualifiedName, { unsafeAllTenants: true })`, or registers `defineProjectionQueryHandler(name, qn, { unsafeAllTenants: true, ... })`, now needs `r.systemScope()` on its feature or `escapeHatch: { reason: \"<why this handler needs every tenant's rows>\" }` declared on the write/query/stream handler (or on the `r.hook(...)` call, if the read happens inside a hook) — code branching on `details.reason` for the resulting denial must also accept `unsafe_all_tenants_denied`. A hook inside an `r.systemScope()` feature does NOT inherit that grant for `unsafeAllTenants` either (same rule as `ctx.queryAs`/`ctx.writeAs`): it needs its own `escapeHatch: { reason }` on the `r.hook(...)` call. Separately, any projection built from `defineUnmanagedTable`/`deriveEntityTableMeta` (an `EntityTableMeta` with a `tenant_id` column, not wrapped in `table()`'s `SchemaTable`) is now tenant-scoped on every plain `ctx.queryProjection(qualifiedName)` call, where it previously returned every tenant's rows unfiltered. A handler that relied on that bug to read cross-tenant now needs the `unsafeAllTenants: true` opt-in plus a grant, same as above. No codemod — both call sites are rare enough to review by hand."
43
+ },
44
+ {
45
+ "version": "0.271.0",
46
+ "type": "breaking",
47
+ "title": "systemScope handlers default to a per-tenant+handler rate limit; nested-write refuses a foreign-tenant/foreign-owner parent row (fw#2861).",
48
+ "detail": "`r.systemScope()` write/query/stream handlers that declare no `rateLimit` now default to `{ per: \"tenant+handler\", limit: 600, windowSeconds: 60 }` — only when `context.rateLimit` is already configured, and never for a SYSTEM-identity caller. `per: \"tenant+handler\"` (not `\"tenant\"`) so one hot handler cannot starve every other systemScope handler's shared tenant quota. `RateLimitDeclaration` (`RateLimitOption | RateLimitDisabled`) replaces `RateLimitOption` on `WriteHandlerDef`/`QueryHandlerDef`/`StreamHandlerDef` (and their `*Definition`/inline-options counterparts): `rateLimit: { disabled: true, reason: \"...\" }` opts a handler out of both the explicit and default limit; the boot validator rejects an empty reason. `enforceRateLimit` (`pipeline/dispatch-shared.ts`) takes a new `isSystemScope: boolean` 5th param; the three dispatch call sites now call it unconditionally. `computeHasRateLimitedHandler`/`wantsL3` stay limited to explicit, non-`disabled` `rateLimit` declarations on purpose. 40 bundled systemScope handlers newly get the default; 7 self-scoped read handlers hit on every page load (traffic scales with signed-in users, not tenant ops) declare `rateLimit: { disabled: true, reason }` instead — `user:query:user:me`, `tenant:query:me`, `config:query:{cascade,values,schema,readiness}`, `delivery:query:preferences` (full per-feature list in the systemscope-rate-limit-nested-ownership changeset). None of the 47 was anonymous-accessible, so no opt-out was required for that reason. Separately, `executeNestedWrite` now refuses to attach nested children to a parent row a custom (non-executor) `:create` handler returned without independently verifying ownership: a new `isForeignTenantParentRow` check refuses a returned row from another tenant (skipped for `r.systemScope()` handlers), and `checkWriteFieldOwnership(parentEntity, parentRow, user)` refuses a same-tenant row whose ownership-bound field does not resolve to the caller — both roll back the whole nested write.",
49
+ "migration": "If your app configures a RateLimitResolver (the rate-limiting feature, an explicit `context.rateLimit`, or L1/L2 middleware options that auto-wire one), every `r.systemScope()` write/query/stream handler without its own `rateLimit` is now limited to 600 calls per tenant per handler per 60s for non-SYSTEM callers. Declare an explicit `rateLimit: { per, limit, windowSeconds }` on a handler that legitimately needs more, or `rateLimit: { disabled: true, reason: \"...\" }` for a per-user self-scoped read whose traffic scales with signed-in users rather than tenant operations (L1 IP limits still apply). Separately, a custom `<entity>:create` handler used under a `nestedWrite: true` relation must return the row it just created for the calling user: returning another tenant's row, or a row whose ownership-bound field(s) do not resolve to the caller, now fails the whole nested write with `access_denied` before any child rows are written."
50
+ },
51
+ {
52
+ "version": "0.271.0",
53
+ "type": "improvement",
54
+ "title": "Escape-hatch uses emit security:escape-hatch-used (audit event or warn log) (fw#2861).",
55
+ "detail": "Every framework escape hatch now reports its use through a new `EscapeHatchReporter`: `ctx.db.unsafeRaw(reason)`/`ctx.dbOutsideTransaction.unsafeRaw(reason)`, `ctx.systemDb.unsafeRaw(reason)`, `ctx.systemDb.acknowledgeCrossTenant(reason)` (including `outsideTransaction.acknowledgeCrossTenant`), `db.global(table)` write methods (`insertOne`/`updateMany`/`deleteMany`), and a granted non-self identity switch via `ctx.queryAs`/`ctx.writeAs`/`ctx.queryAsMember`. Each use emits `{ handler, kind, reason, tenantId, actor, target? }` (`kind` is one of `\"unsafe-raw\"`, `\"acknowledge-cross-tenant\"`, `\"global-write\"`, `\"identity-switch\"`) through the dispatcher-scoped `AppContext._escapeHatchAuditSink` when wired (set by the `audit` feature at boot), or logged as `security:escape-hatch-used` via `log.warn(...)` otherwise. Identical `(handler, kind, reason, tenantId, actor, target)` tuples are deduplicated per dispatcher within a 60s window (`createEscapeHatchReportWindow`, `DispatchContext.escapeHatchReportWindow`) so hot systemScope queries don't write one events row per request. `createTenantDb`/`createUncheckedSystemDb` built without an explicit reporter fall back to `fallbackEscapeHatchReporter`, which warn-logs attributed to handler `\"<unattributed>\"` / actor `\"<unattributed>\"`. New optional params: `TenantDbGrants.report`, `createUncheckedSystemDb`'s 3rd `report` param, `createGatedIdentitySwitch`'s 5th `audit` param. New pipeline exports: `createEscapeHatchReporter`, `createEscapeHatchReportWindow`, `fallbackEscapeHatchReporter`, `reportEscapeHatchUse`, `ESCAPE_HATCH_USED_SIGNAL`. New types: `EscapeHatchKind`, `EscapeHatchTarget`, `EscapeHatchUseEvent`, `EscapeHatchAuditSink`, `EscapeHatchReporter`, `AppContext._escapeHatchAuditSink`."
56
+ },
57
+ {
58
+ "version": "0.271.0",
59
+ "type": "breaking",
60
+ "title": "`TenantDb.raw` is removed — framework infrastructure gets the connection injected (fw#2860).",
61
+ "detail": "`TenantDb.raw` (`@cosmicdrift/kumiko-types/tenant-db-types`) no longer exists. Framework infrastructure that used to read it — the event-store executor's CRUD verbs, `read.findMany`/`read.findOne`/`unsafeProjectionDelete`/`unsafeProjectionUpsert` engine steps, the entity-convention `crossTenant` handlers, `UncheckedSystemDb.unsafeRaw`, the MSP consumer's per-event `apply()` in `api/server.ts`, and `db/assert-exists-in.ts`'s TenantDb duck-type — now resolves the bound `DbRunner` through a new framework-private module `db/tenant-db-runner.ts` (`bindTenantDbRunner`/`tenantDbRunner`, a `WeakMap<TenantDb, DbRunner>` keyed by the exact object `createTenantDb` built; not re-exported from `db/index.ts` or any package-exports entry, so feature code cannot import it). `asRawClient` (`bun-db/query.ts`) no longer unwraps a `.raw`-shaped TenantDb; it throws when handed one, same as the raw-SQL helpers built on it (`countWhere`, `transaction`, `runInSavepoint`/`runInSavepointIfSupported`, `executeRawQuery`/`executeRawQueryRead`, `upsertOnConflict`, `incrementCounter`, `insertMany`, `deleteManyBatched`) — they no longer accept a `TenantDb` at all. `tenantDbDelegate`'s TenantDb duck-type check no longer looks for `.raw`. `pipeline/projections-runner.ts`'s `runProjections(result, registry, runner)` takes an explicitly-resolved `DbRunner | undefined` (the dispatcher's `resolveDbSource(ctx, tx)`, threaded through `runLifecycle`) instead of reaching into `ctx.db.raw` / `ctx.systemDb.acknowledgeCrossTenant(...).raw` itself; it throws `InternalError` if `result.event` is set but no runner was resolved. `bundled-features/tenant/seeding.ts`'s `fireEntityPostSave`'s optional 4th argument changed from a bare `tenantId` to `{ tenantId, db }` — the caller (already holding a legitimately-declared runner) hands it in instead of the helper trying to pull one out of `context.db`. The boot validator now also rejects a `tenancy: \"global\"` entity whose owning feature does not declare `r.systemScope()` — a convention write on such an entity would otherwise still dispatch in tenant-mode. `packages/bundled-features/src/jobs/handlers/list.query.ts` switched from `ctx.systemDb.acknowledgeCrossTenant(reason)` to `ctx.systemDb.unsafeRaw(reason)` since it calls `countWhere` directly.",
62
+ "migration": "`ctx.db.raw` → `ctx.db.global(table)` for `tenancy: \"global\"` tables, otherwise `ctx.db.unsafeRaw(reason)` with `escapeHatch: { reason }` on the handler/hook; r.systemScope() features `ctx.systemDb.unsafeRaw(reason)`. Raw SQL helpers (countWhere, transaction, runInSavepoint*, executeRawQuery*, upsert*, incrementCounter, insertMany, deleteManyBatched) no longer accept a TenantDb. Hand-built TenantDb objects passed to the EventStoreExecutor must be created via createTenantDb. `tenancy: \"global\"` entities must live in an r.systemScope() feature. Custom callers of fireEntityPostSave pass `{ tenantId, db }` as 4th argument.",
63
+ "codemod": "scripts/codemod/migrate-db-raw.ts"
64
+ },
65
+ {
66
+ "version": "0.271.0",
67
+ "type": "fix",
68
+ "title": "Projection/MSP rebuild aborts instead of silently dropping row level security from the live table (fw#2907).",
69
+ "detail": "New `assertLiveTableHasNoRowLevelSecurity(tx, tableName, projectionName?)` in `db/queries/shadow-swap.ts` checks `pg_class.relrowsecurity`/`relforcerowsecurity` and `pg_policy` (public schema) for the live table before the shadow swap. `rebuildProjection` and `rebuildMultiStreamProjection` now call it before `buildShadowTable`, alongside the existing `assertLiveColumnsMatchMeta` fence, and `swapShadowIntoLive` re-checks it again right before the cutover under the fence. Previously the shadow, built from `EntityTableMeta` (which carries no RLS/policy information), would swap over a live table with RLS enabled/forced or any policy and silently drop all of it. `replayMigrationsDir`'s shape-neutral ALTER-clause recognizer (`db/replay-migration-sql.ts`) now also accepts `FORCE`/`NO FORCE ROW LEVEL SECURITY` instead of throwing \"unparsed table-DDL statement\".",
70
+ "migration": "No action for tables without RLS. A table with RLS enabled/forced or any policy can no longer be rebuilt online — rebuild fails with the table name; drop the RLS/policies or keep the table out of rebuild (see kumiko-platform docs/plans/rls-evaluation.md)."
71
+ },
72
+ {
73
+ "version": "0.271.0",
74
+ "type": "breaking",
75
+ "title": "Boot validator requires every screen to be reachable from the app's nav tree.",
76
+ "detail": "`validateScreens` now rejects a screen that has no own `nav`, no standalone `r.nav()` pointed at it anywhere in the composed feature set, and no resolvable parent list (`listScreenId`, or a rowAction/toolbarAction/drawer navigate target from an `entityList`/`projectionList`, or — for `entityEdit` — a same-entity `entityList`) — unless it declares `dormant: true`. `dormant` (previously only on `CustomScreenDefinition`) is now on every screen type. The resolution logic is the same `resolveNavParentScreen` the renderer's breadcrumb/NavTree already used, moved into `@cosmicdrift/kumiko-framework/engine/screen-helpers.ts` (re-exported through `ui-types`) instead of a second copy, and widened to also recognize toolbarActions and drawer-kind targets, not just entityList rowActions. The check is skipped when the composed feature set registers no nav entries anywhere at all (an isolated feature/recipe/test boot has no nav tree to be orphaned from).",
77
+ "migration": "A screen that boots inside an app with at least one nav entry somewhere, but isn't itself reachable, now fails boot with \"has no nav entry and no resolvable list\". Fix by declaring `listScreenId` (or a rowAction/toolbarAction navigate target from a list screen pointed at it), adding a standalone `r.nav()` for it, or setting `dormant: true` if it's intentionally reachable only via a direct link/redirect or a consuming app's own `r.nav()` (the established pattern for app-placed settings-area screens like `tenant-list`/`user-list`/`tier-admin`/personal-access-tokens' list/`profile`, now applied consistently across bundled-features)."
78
+ },
79
+ {
80
+ "version": "0.271.0",
81
+ "type": "improvement",
82
+ "title": "projectionDetail tabs mode (\"Akte\") gets a more consistent layout — visible changes, no API break.",
83
+ "detail": "A `projectionDetail` screen's tabs layout now renders header actions on the right of the title row instead of trailing after the metrics band, centers the status badge vertically next to the title, and no longer doubles the screen's `description` as a subtitle under the tabs (it still renders as the form subtitle outside tabs mode). A `relatedList` section in tabs mode keeps the same table frame, search box and facet toolbar a plain list screen has instead of dropping its card chrome. A `fields`-kind section inside a tabs layout now renders as its own titled card instead of a bare, title-less grid. The bundled `notesHistory` client's `NotesSection` renders as two cards (\"New note\" and \"History\") instead of individually bordered entries.",
84
+ "migration": "No action required — every change here is visual, with no schema or API break. Apps with e2e assertions pinned to `render-edit-form-subtitle` in tabs mode, or to the previous Stammdaten testId structure, must update those assertions to the new testIds."
85
+ },
86
+ {
87
+ "version": "0.271.0",
88
+ "type": "improvement",
89
+ "title": "A relatedList tab section can reuse the generic <entity>:list query via parentFilter instead of a bespoke child-rows handler.",
90
+ "detail": "`EditRelatedListSection` gains `parentFilter?: { field: string }` — an alternative to `parentParam` that sends the parent record's id as `payload.filter: { field, op: \"eq\", value: parentId }` instead of a bespoke top-level payload key, so a tab section can bind directly to the generic `<entity>:list` query and get its search (including the search index and reference-label search), facets and paging for free. `parentFilter` and `parentParam` are mutually exclusive. It composes with `toolbarActions` (including `visible`/`params` evaluated against the parent record), reference facets, `EditFieldsSection.groups`, and `ActionFormScreenDefinition.fieldLabels` — all from the same release.",
91
+ "migration": "Not required for existing screens — `parentFilter` is opt-in and `parentParam` keeps working unchanged. To migrate a bespoke tab query, replace its handler with the entity's own `<entity>:list` query and set `parentFilter: { field: \"<fk>\" }` on the section; move any \"add row for this tab\" action from a header action into the section's own `toolbarActions`. Before: `{ kind: \"relatedList\", query: \"leases:query:lease-items:list\", parentParam: \"leaseId\" }` backed by a custom `lease-items:list` handler that filters by `leaseId` itself. After: `{ kind: \"relatedList\", query: \"leases:query:item:list\", parentFilter: { field: \"leaseId\" } }` backed by `r.queryHandler(defineEntityListHandler(\"item\", itemEntity, { access }))` — search/filters/paging now run in the DB through the same query a plain list screen uses."
92
+ },
2
93
  {
3
94
  "version": "0.269.0",
4
95
  "type": "breaking",
@@ -385,6 +385,83 @@ describe("event-store-executor write-verbs — version_conflict edge cases", ()
385
385
  });
386
386
  });
387
387
 
388
+ // =============================================================================
389
+ // Explicit-id create: cross-tenant isolation, no resurrection of a deleted row
390
+ // =============================================================================
391
+
392
+ const deletedIdEntity = createEntity({
393
+ table: "read_es_write_deleted_id",
394
+ fields: {
395
+ email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
396
+ },
397
+ softDelete: true,
398
+ });
399
+ const deletedIdTable = buildEntityTable("esWriteDeletedId", deletedIdEntity);
400
+
401
+ describe("event-store-executor write-verbs — explicit-id tenant isolation + soft-delete", () => {
402
+ const crud = createEventStoreExecutor(deletedIdTable, deletedIdEntity, {
403
+ entityName: "esWriteDeletedId",
404
+ });
405
+ let otherTenantDb: TenantDb;
406
+
407
+ beforeAll(async () => {
408
+ await unsafeCreateEntityTable(testDb.db, deletedIdEntity, "esWriteDeletedId");
409
+ otherTenantDb = createTenantDb(testDb.db, TestUsers.otherTenant.tenantId);
410
+ });
411
+
412
+ beforeEach(async () => {
413
+ await asRawClient(testDb.db).unsafe(
414
+ `TRUNCATE kumiko_events, read_es_write_deleted_id RESTART IDENTITY CASCADE`,
415
+ );
416
+ });
417
+
418
+ test("create with an id already used by another tenant → conflict, tenant A's row untouched, no leak", async () => {
419
+ // The event log itself is tenant-scoped (UNIQUE (tenant_id, aggregate_id,
420
+ // version)) and would happily let tenant B open its own stream at the
421
+ // same id — but every entity's projection table has a plain `id` primary
422
+ // key (table-builder.ts), shared across all tenants for that entity
423
+ // type. So a same-id create for a different tenant fails at the
424
+ // projection-insert step with `unique_violation`, not `version_conflict`
425
+ // — a different, still-typed error, and the mapped error's details carry
426
+ // only entityName/constraintName, nothing that identifies tenant A's row.
427
+ const first = await crud.create({ email: "tenant-a@test.de" }, admin, tdb);
428
+ if (!first.isSuccess) throw new Error("setup failed");
429
+
430
+ const other = await crud.create(
431
+ { id: first.data.id, email: "tenant-b@test.de" },
432
+ TestUsers.otherTenant,
433
+ otherTenantDb,
434
+ );
435
+ expect(other.isSuccess).toBe(false);
436
+ if (other.isSuccess) return;
437
+ expect(other.error.code).toBe("unique_violation");
438
+ expect(JSON.stringify(other.error.details)).not.toContain(admin.tenantId);
439
+
440
+ const row = await asRawClient(testDb.db).unsafe(
441
+ `SELECT email FROM read_es_write_deleted_id WHERE id = $1`,
442
+ [first.data.id],
443
+ );
444
+ expect((row as unknown as { email: string }[])[0]?.email).toBe("tenant-a@test.de");
445
+ });
446
+
447
+ test("create with the id of a soft-deleted row → version_conflict, no resurrection", async () => {
448
+ const created = await crud.create({ email: "gone@test.de" }, admin, tdb);
449
+ if (!created.isSuccess) throw new Error("setup failed");
450
+
451
+ const deleted = await crud.delete({ id: created.data.id }, admin, tdb);
452
+ expect(deleted.isSuccess).toBe(true);
453
+
454
+ const resurrect = await crud.create(
455
+ { id: created.data.id, email: "back-from-the-dead@test.de" },
456
+ admin,
457
+ tdb,
458
+ );
459
+ expect(resurrect.isSuccess).toBe(false);
460
+ if (resurrect.isSuccess) return;
461
+ expect(resurrect.error.code).toBe("version_conflict");
462
+ });
463
+ });
464
+
388
465
  // =============================================================================
389
466
  // Concurrent update race → EventStoreVersionConflict catch + entityCache.del
390
467
  // on forget/restore (create/update/delete already exercise cache in the
@@ -807,7 +807,7 @@ describe("event-store-executor — pii subject encryption", () => {
807
807
  0,
808
808
  );
809
809
  if (!storedEvent) throw new Error("no persisted event for the aggregate");
810
- const applied = await applyEntityEvent(storedEvent, piiTable, piiEntity, tdb.raw);
810
+ const applied = await applyEntityEvent(storedEvent, piiTable, piiEntity, testDb.db);
811
811
  expect(applied.kind).toBe("applied");
812
812
 
813
813
  const rawRows = (await asRawClient(testDb.db).unsafe(
@@ -365,6 +365,23 @@ ALTER TABLE "widgets" OWNER TO app_user;`,
365
365
  }
366
366
  });
367
367
 
368
+ test("FORCE / NO FORCE ROW LEVEL SECURITY is shape-neutral (fw#2907)", () => {
369
+ const dir = tmpMigrationsDir();
370
+ try {
371
+ write(
372
+ dir,
373
+ "0001_init.sql",
374
+ `CREATE TABLE IF NOT EXISTS "widgets" ("id" uuid PRIMARY KEY, "title" text);
375
+ ALTER TABLE "widgets" FORCE ROW LEVEL SECURITY;
376
+ ALTER TABLE widgets NO FORCE ROW LEVEL SECURITY;`,
377
+ );
378
+ const replayed = replayMigrationsDir(dir);
379
+ expect([...(replayed.get("widgets")?.columns ?? [])].sort()).toEqual(["id", "title"]);
380
+ } finally {
381
+ rmSync(dir, { recursive: true, force: true });
382
+ }
383
+ });
384
+
368
385
  test("unexpected table: migrations create a table the snapshot doesn't know about", () => {
369
386
  const dir = tmpMigrationsDir();
370
387
  try {
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { EscapeHatchKind } from "@cosmicdrift/kumiko-types/handlers";
3
+ import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
4
+ import { InternalError } from "../../errors";
5
+ import { testTenantId } from "../../stack";
6
+ import type { DbRunner } from "../connection";
7
+ import * as dbIndex from "../index";
8
+ import { acknowledgeConventionCrossTenant, createTenantDb, withUnsafeRawGrant } from "../tenant-db";
9
+ import { tenantDbRunner } from "../tenant-db-runner";
10
+
11
+ const tenantId = testTenantId(1);
12
+ const REASON = "operator scan across every tenant";
13
+
14
+ function fakeRunner(): DbRunner {
15
+ return {
16
+ unsafe: async () => [],
17
+ begin: async () => {
18
+ throw new Error("begin not used in these tests");
19
+ },
20
+ } as unknown as DbRunner;
21
+ }
22
+
23
+ describe("acknowledgeConventionCrossTenant", () => {
24
+ test("returns a system-mode TenantDb with the same tenantId", () => {
25
+ const tdb = createTenantDb(fakeRunner(), tenantId);
26
+ const result = acknowledgeConventionCrossTenant(tdb, REASON);
27
+ expect(result.mode).toBe("system");
28
+ expect(result.tenantId).toBe(tenantId);
29
+ });
30
+
31
+ test("reports acknowledge-cross-tenant exactly once via grants.report", () => {
32
+ const reports: Array<{ kind: EscapeHatchKind; reason: string }> = [];
33
+ const tdb = createTenantDb(fakeRunner(), tenantId, "tenant", undefined, undefined, undefined, {
34
+ report: (kind, reason) => {
35
+ reports.push({ kind, reason });
36
+ },
37
+ });
38
+ acknowledgeConventionCrossTenant(tdb, REASON);
39
+ expect(reports).toEqual([{ kind: "acknowledge-cross-tenant", reason: REASON }]);
40
+ });
41
+
42
+ test("throws for an empty reason", () => {
43
+ const tdb = createTenantDb(fakeRunner(), tenantId);
44
+ expect(() => acknowledgeConventionCrossTenant(tdb, "")).toThrow();
45
+ expect(() => acknowledgeConventionCrossTenant(tdb, " ")).toThrow();
46
+ });
47
+
48
+ test("throws InternalError for a TenantDb not built by createTenantDb", () => {
49
+ const handBuilt = { tenantId, mode: "tenant" } as unknown as TenantDb;
50
+ expect(() => acknowledgeConventionCrossTenant(handBuilt, REASON)).toThrow(InternalError);
51
+ });
52
+
53
+ test("still resolves after withUnsafeRawGrant rebinds the TenantDb", () => {
54
+ const runner = fakeRunner();
55
+ const tdb = createTenantDb(runner, tenantId);
56
+ const regranted = withUnsafeRawGrant(tdb, { reason: "unrelated unsafeRaw grant" });
57
+ const result = acknowledgeConventionCrossTenant(regranted, REASON);
58
+ expect(result.mode).toBe("system");
59
+ expect(tenantDbRunner(result)).toBe(runner);
60
+ });
61
+
62
+ test("tenantDbRunner(result) resolves to the original runner", () => {
63
+ const runner = fakeRunner();
64
+ const tdb = createTenantDb(runner, tenantId);
65
+ const result = acknowledgeConventionCrossTenant(tdb, REASON);
66
+ expect(tenantDbRunner(result)).toBe(runner);
67
+ });
68
+
69
+ test("db/index.ts does not export acknowledgeConventionCrossTenant", () => {
70
+ expect("acknowledgeConventionCrossTenant" in dbIndex).toBe(false);
71
+ });
72
+ });