@cosmicdrift/kumiko-framework 0.277.0 → 0.280.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-framework",
3
- "version": "0.277.0",
3
+ "version": "0.280.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.277.0",
201
+ "@cosmicdrift/kumiko-types": "0.280.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.277.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.280.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
package/src/changes.json CHANGED
@@ -1,4 +1,31 @@
1
1
  [
2
+ {
3
+ "version": "0.280.0",
4
+ "type": "breaking",
5
+ "title": "TenantDataHookCtx/UserDataHookCtx.db is a tenant-filtered TenantDb; unfiltered hook access needs r.useExtension({ escapeHatch }) (fw#2914 Zugang 3).",
6
+ "detail": "EXT_TENANT_DATA `destroy` hooks (`TenantDataHookCtx.db`) and EXT_USER_DATA `export`/`delete` hooks (`UserDataHookCtx.db`) no longer receive the unfiltered `DbRunner` that was bound to the extension's owning handler. Both are now a `TenantDb` in \"tenant\" mode, built via the public `createTenantDb(...)`, bound to the hook's `tenantId`: reads/writes go through `ctx.db.selectMany/fetchOne/insertOne/updateMany/deleteMany`, already scoped to that tenant plus `SYSTEM_TENANT_ID` reference rows. `r.useExtension(EXT_USER_DATA | EXT_TENANT_DATA, entityName, { ... })` gains an `escapeHatch?: { reason: string }` option, validated non-empty at registration time (throws at boot for an empty/whitespace reason). Declaring it grants `ctx.db.unsafeRaw(reason)` inside that entity's hooks — same reason string — which returns the real, unfiltered `DbRunner`; without it, `ctx.db.unsafeRaw(reason)` throws `AccessDeniedError`. Every granted use reports an `\"unsafe-raw\"` escape-hatch audit event (`{ handler: \"userData:<entity>\"|\"tenantData:<entity>\", kind, reason, tenantId, actor }`) through the caller's `_escapeHatchAuditSink` when wired, else a `security:escape-hatch-used` warn-log — same #2861 pattern PR #2934 already applied to the job runner, and the same one fw#2914's earlier JobContext.db and read-step changes already applied elsewhere in the framework. `run-forget-cleanup.ts`/`run-user-export.ts` (user-data-rights) thread `escapeHatchAuditSink`/`actor` from their owning `r.job`'s `ctx._escapeHatchAuditSink`/`ctx.systemUser.id` down to each hook's `TenantDb`, so a declared use is attributed to the real job run, not `\"<unattributed>\"`.\n`asRawClient(ctx.db)` inside a hook now throws immediately: `asRawClient` rejects a `TenantDb`-shaped argument (\"received a tenant-scoped TenantDb (ctx.db). Raw SQL helpers do not apply the tenant filter — use a ctx.db.<method>, or ctx.db.unsafeRaw(reason) with escapeHatch: { reason } ...\"). The framework's `DbRunner`-typed free functions (`selectMany(db, table, where)`, `fetchOne`, `insertOne`, `updateMany`, `deleteMany`, `createTenantDb(db, tenantId, mode)`, etc. from `@cosmicdrift/kumiko-framework/bun-db`/`/db`) now reject `ctx.db` as their first argument at compile time too — `TenantDb` does not structurally satisfy `DbRunner` (no `.unsafe`/`.begin`).\nA table built via `createEntity`+`buildEntityTable`+`r.entity(...)` carries the pre-existing `EXECUTOR_ONLY` brand, so `ctx.db.insertOne/updateMany/deleteMany(table, ...)` against it is a compile error by design (\"a managed projection is only writable through the executor\"); a hook that needs to write such a table directly (bulk hard-delete, jsonb key removal, an UPDATE expression `updateMany`'s literal-value `set` can't express) needs the `escapeHatch` + `ctx.db.unsafeRaw(reason)` route, filtering by tenant/owner in the SQL text itself — the escape hatch removes automatic tenant-filtering, not the hook author's responsibility to filter. A table built via `defineUnmanagedTable` carries no such brand and stays writable through `ctx.db`'s typed methods directly.\nThis PR's own `custom-fields` user-data-rights wiring (`wire-user-data-rights.ts`) uses the escape hatch for exactly this reason: `selectCustomFieldsHostRows` runs against a dynamic host table name unknown at compile time, so it can't use `ctx.db`'s typed methods. CI's `AST-Guards (shared runner)` step (`@cosmicdriftgamestudio/kumiko-guards@^0.36.0`, pinned) does not yet recognize this call shape — `isEscapeHatchDeclaredFunction` in `guard-escape-hatch-declared.ts` only matches a property/method literally named `handler`, or a direct call to `hook`/`writeHandler`/`queryHandler`/`streamHandler`; `r.useExtension(EXT_USER_DATA, entityName, { export: exportHook, escapeHatch })` matches neither, because `useExtension` isn't in `ESCAPE_HATCH_CALL_METHODS` and the hook property here is `export`/`delete`, never `handler`. This also isn't fixable by baselining from kumiko-framework: `security-baselines/<repo>.json` ships inside the pinned `@cosmicdriftgamestudio/kumiko-guards` npm package itself, and `run-guards.ts`'s own comment states it is rewritten only by \"the infra baseline-maintenance run over all repos on main\" — never by a consuming repo's local run, and never persisted through a consuming repo's `bun install`. The fix belongs in the guard's owning repo (presumably `infra`, per this workspace's root `CLAUDE.md`): add `useExtension` to `ESCAPE_HATCH_CALL_METHODS`, or otherwise recognize the framework's actual hook-property names (`export`/`delete`/`destroy`), then release a new `@cosmicdriftgamestudio/kumiko-guards` version and bump the range in kumiko-framework's `package.json`. Until that lands, this PR's `AST-Guards (shared runner)` CI check stays red on this one finding; every other check is green.",
7
+ "migration": "A hook that only reads/writes its own tenant through `ctx.db` method calls (`ctx.db.selectMany/fetchOne/insertOne/updateMany/deleteMany`) needs no change — it now gets the same tenant filter every other `TenantDb` consumer already had. A hook that treated `ctx.db` as a raw connection (`asRawClient(ctx.db)`, `ctx.db` passed as the first argument to a free-function `selectMany`/`fetchOne`/`insertOne`/`updateMany`/`deleteMany`/`createTenantDb`, a cast to `DbRunner`/`DbConnection`, or `\"raw\" in db ? db.raw : db`) must add `escapeHatch: { reason: \"<why>\" }` to its `r.useExtension(EXT_USER_DATA | EXT_TENANT_DATA, entityName, { ... })` registration and fetch the runner explicitly with `ctx.db.unsafeRaw(\"<why>\")` (same reason string) at the point of use. Prefer converting to `ctx.db`'s typed methods first — reach for `unsafeRaw` only when the target table is `EXECUTOR_ONLY`-branded (a managed `r.entity(...)` table; typed writes reject it by design — see the `EXECUTOR_ONLY`/`WritableTable` note in Detail) or the SQL needs an expression the typed API can't produce (e.g. a jsonb `-` key-removal `UPDATE`). No codemod — cross-hook raw-SQL usage is expected to be rare enough, and tenant-filter-relevant enough, to review by hand per hook. Hand-built `TenantDataHookCtx`/`UserDataHookCtx` objects in tests need `db: createTenantDb(conn, tenantId, \"tenant\", undefined, undefined, undefined, { unsafeRaw: { reason: \"<why>\" } })` instead of a plain connection.\n\nAudit at release — kumiko-enterprise, 7 files across 5 packages, none touched by this PR (out of scope; must be migrated in kumiko-enterprise itself before it bumps its pinned `@cosmicdrift/kumiko-framework`/`@cosmicdrift/kumiko-types` version, or `tsc` fails):\n- `packages/kumiko-credit/src/hooks/userdata-hook-shared.ts` (`forgetMatchingRows`/`anonymizeMatchingRows`, shared by bauspar + credit's delete hooks): `createTenantDb(ctx.db, ctx.tenantId, \"system\")` and `selectMany(ctx.db, table, where)` both take `ctx.db` as a raw-`DbRunner`-typed argument — needs `escapeHatch` on both entities' `r.useExtension(...)` plus `createTenantDb(ctx.db.unsafeRaw(\"<why>\"), ctx.tenantId, \"system\")`; the `selectMany` call can drop the free-function form entirely and become `ctx.db.selectMany(table, where)` (already tenant-scoped to `ctx.tenantId`, no need for \"system\" mode there).\n- `packages/kumiko-credit/src/hooks/bauspar.userdata-hook.ts` and `.../credit.userdata-hook.ts`: each has its own inline `selectMany(ctx.db, table, { ... })` (export hook) — same fix, `ctx.db.selectMany(table, { ... })`.\n- `packages/ai-agent/src/hooks/ai-agent-turn.userdata-hook.ts`, `packages/ai-conversation/src/hooks/designer-conversation-turn.userdata-hook.ts`, `packages/pattern-storage/src/hooks/pattern-file.userdata-hook.ts`, `packages/prompt-store/src/hooks/prompt-template-version.userdata-hook.ts`: each has the same `createTenantDb(ctx.db, ctx.tenantId, \"system\")` + two `selectMany(ctx.db, table, { ... })` inline pair as `userdata-hook-shared.ts` above — same fix per file.\n\n`packages/ai-foundation/src/providers/provenance.ts` (`recordAiCall`) and `packages/ai-pipeline/src/steps/shared.ts` (`recordStepProvenance`) were checked and need NO change: neither registers an EXT_USER_DATA/EXT_TENANT_DATA hook (they run off `HandlerContext`/`WorkflowPipelineCtx`, unrelated to this PR — that context's `.db` was already `TenantDb` from an earlier fw#2914 change), and both already use exactly the pattern this PR expects from hooks too: `withUnsafeRawGrant(ctx.dbOutsideTransaction ?? ctx.db, { reason: PROVENANCE_UNSAFE_RAW_REASON }).unsafeRaw(reason)`.\n\nGuard-version gap: once each of these 7 files declares `escapeHatch` on its `r.useExtension(...)` registration, expect the same `AST-Guards (shared runner)` failure this PR hits on `wire-user-data-rights.ts` (see Detail above) — the currently-pinned `@cosmicdriftgamestudio/kumiko-guards@^0.36.0` doesn't recognize `escapeHatch` on a `useExtension` call. That guard bump has to land (and kumiko-enterprise's pinned guards range has to move) before or alongside this migration, not after."
8
+ },
9
+ {
10
+ "version": "0.279.0",
11
+ "type": "improvement",
12
+ "title": "Event-store gains appendProvenanceEvent(db, event), a declared entry point for provenance events (fw#2914).",
13
+ "detail": "`appendProvenanceEvent` (`@cosmicdrift/kumiko-framework/event-store`) takes a plain `TenantDb` and an event (`aggregateId`, `aggregateType`, `tenantId`, `expectedVersion` — a number or `\"current\"` to resolve the stream's current version inside the same savepoint, `type`, `payload`, `metadata`), grants itself a raw runner with a fixed, framework-owned reason, runs the append in a driver savepoint when one is available, and calls the public `append()`. The reason string is never caller-supplied — only the framework declares why this append is allowed to skip the ownership check. `event.type` must be owner-qualified (`\"<feature>:<name>\"`) and rejects the framework-internal `kumiko:system:` namespace. A `TenantDb` not built by `createTenantDb` fails closed.",
14
+ "migration": "No action required — purely additive. Existing self-granted `withUnsafeRawGrant(...).unsafeRaw(...)` provenance writers can migrate to `appendProvenanceEvent` in a follow-up; nothing is removed in this change."
15
+ },
16
+ {
17
+ "version": "0.278.0",
18
+ "type": "fix",
19
+ "title": "Fix urlPrefillFields allowlist missing relatedList toolbarActions",
20
+ "detail": "A screen reached only through a relatedList toolbar action never received its mapped id, so its hidden required field stayed empty and the declared redirect never ran."
21
+ },
22
+ {
23
+ "version": "0.278.0",
24
+ "type": "improvement",
25
+ "title": "TenantDb gains a count(table, where?) method for a tenant-scoped COUNT(*) (fw#2854).",
26
+ "detail": "`TenantDb.count(table, where?)` returns a tenant-scoped row count with the same tenant semantics as `selectMany`: \"tenant\" mode counts the caller's own tenant plus `SYSTEM_TENANT_ID` reference rows, a caller-supplied `where.tenantId` may only narrow that scope, and \"system\" mode counts unfiltered. Lets app/feature code compute stock-caps (`count(*) WHERE tenant_id = …`) without an `unsafeRaw`/`escapeHatch` detour. A hand-built `TenantDb` object literal (test fakes, mocks) must add a `count` method to keep satisfying the `TenantDb` type — `db.global()` is unaffected, `count` is only on the tenant-scoped surface.",
27
+ "migration": "No action required for existing `TenantDb` consumers — purely additive. Any hand-built object literal typed as `TenantDb` (not built via `createTenantDb`) needs a `count(table, where?)` method added; `bunCountWhere`/`countWhere` from `@cosmicdrift/kumiko-framework/db` implements the same query shape if you need equivalent logic outside a `TenantDb`."
28
+ },
2
29
  {
3
30
  "version": "0.277.0",
4
31
  "type": "improvement",
@@ -0,0 +1,92 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
3
+ import { createEntity, createTextField } from "../../engine";
4
+ import { createTestDb, type TestDb, TestUsers, unsafeCreateEntityTable } from "../../stack";
5
+ import type { TableColumns } from "../dialect";
6
+ import { buildEntityTable } from "../table-builder";
7
+ import { createTenantDb } from "../tenant-db";
8
+
9
+ const entity = createEntity({
10
+ table: "tenant_db_count_items",
11
+ fields: {
12
+ name: createTextField({ required: true, personal: false, reason: "test_fixture" }),
13
+ },
14
+ });
15
+
16
+ const table: TableColumns = buildEntityTable("tenantDbCountItem", entity);
17
+
18
+ let testDb: TestDb;
19
+ const tenantA = TestUsers.admin; // tenantId: 1
20
+ const tenantB = TestUsers.otherTenant; // tenantId: 2
21
+
22
+ beforeAll(async () => {
23
+ testDb = await createTestDb();
24
+ await unsafeCreateEntityTable(testDb.db, entity, "tenantDbCountItem");
25
+
26
+ const tdbA = createTenantDb(testDb.db, tenantA.tenantId);
27
+ const tdbB = createTenantDb(testDb.db, tenantB.tenantId);
28
+
29
+ await tdbA.insertOne(table, { name: "A1" });
30
+ await tdbA.insertOne(table, { name: "A2" });
31
+ await tdbA.insertOne(table, { name: "A3" });
32
+ await tdbB.insertOne(table, { name: "B1" });
33
+
34
+ await seedRow(testDb.db, table, {
35
+ name: "Reference",
36
+ tenantId: "00000000-0000-4000-8000-000000000000",
37
+ version: 1,
38
+ insertedAt: Temporal.Now.instant(),
39
+ });
40
+ });
41
+
42
+ afterAll(async () => {
43
+ await testDb.cleanup();
44
+ });
45
+
46
+ describe("TenantDb.count() — tenant mode", () => {
47
+ test("B's count returns only B's own rows plus the reference row, never A's rows", async () => {
48
+ const tdbB = createTenantDb(testDb.db, tenantB.tenantId);
49
+ const count = await tdbB.count(table);
50
+ expect(count).toBe(2); // B1 + Reference
51
+ });
52
+
53
+ test("A's count returns only A's own rows plus the reference row", async () => {
54
+ const tdbA = createTenantDb(testDb.db, tenantA.tenantId);
55
+ const count = await tdbA.count(table);
56
+ expect(count).toBe(4); // A1, A2, A3 + Reference
57
+ });
58
+
59
+ test("where filter narrows the count", async () => {
60
+ const tdbA = createTenantDb(testDb.db, tenantA.tenantId);
61
+ const count = await tdbA.count(table, { name: "A1" });
62
+ expect(count).toBe(1);
63
+ });
64
+
65
+ test("B's where: { tenantId: tenantA } cannot widen scope — falls back to B's own scope", async () => {
66
+ const tdbB = createTenantDb(testDb.db, tenantB.tenantId);
67
+ const count = await tdbB.count(table, { tenantId: tenantA.tenantId });
68
+ // The requested tenantId isn't in B's allowed set [B, SYSTEM], so it falls
69
+ // back to B's full enforced scope rather than widening to A.
70
+ expect(count).toBe(2); // B1 + Reference
71
+ });
72
+
73
+ test("where: { tenantId: B } excludes the reference row", async () => {
74
+ const tdbB = createTenantDb(testDb.db, tenantB.tenantId);
75
+ const count = await tdbB.count(table, { tenantId: tenantB.tenantId });
76
+ expect(count).toBe(1); // B1 only
77
+ });
78
+
79
+ test("count matches selectMany(...).length for the same scope", async () => {
80
+ const tdbA = createTenantDb(testDb.db, tenantA.tenantId);
81
+ const [count, rows] = await Promise.all([tdbA.count(table), tdbA.selectMany(table)]);
82
+ expect(count).toBe(rows.length);
83
+ });
84
+ });
85
+
86
+ describe("TenantDb.count() — system mode", () => {
87
+ test("counts all rows across every tenant, unfiltered", async () => {
88
+ const systemDb = createTenantDb(testDb.db, tenantA.tenantId, "system");
89
+ const count = await systemDb.count(table);
90
+ expect(count).toBe(5); // A1, A2, A3, B1, Reference
91
+ });
92
+ });
@@ -14,6 +14,7 @@ export async function executeRawQuery<T = Record<string, unknown>>(
14
14
 
15
15
  /** SELECT-only escape hatch with the #1163 closed-connection retry. Do not pass
16
16
  * INSERT/UPDATE/DELETE — retry re-executes the statement. */
17
+ // @wrapper-known semantic-alias
17
18
  export async function executeRawQueryRead<T = Record<string, unknown>>(
18
19
  db: AnyDb,
19
20
  sqlText: string,
@@ -15,6 +15,7 @@ import {
15
15
  import {
16
16
  asEntityTableMeta,
17
17
  asRawClient,
18
+ countWhere as bunCountWhere,
18
19
  deleteMany as bunDeleteMany,
19
20
  fetchOne as bunFetchOne,
20
21
  insertOne as bunInsertOne,
@@ -453,6 +454,11 @@ export function createTenantDb(
453
454
  return withDbSpan("select", table, async () => bunFetchOne<T>(db, table, filter));
454
455
  },
455
456
 
457
+ count(table: Table | EntityTableMeta, where?: WhereObject): Promise<number> {
458
+ const filter = readWhere(table, where);
459
+ return withDbSpan("select", table, async () => bunCountWhere(db, table, filter));
460
+ },
461
+
456
462
  insertOne<T = Record<string, unknown>>(
457
463
  table: Table,
458
464
  values: Record<string, unknown>,
@@ -70,7 +70,31 @@ const leasesFeature = defineFeature("leases", (r) => {
70
70
  id: "lease-detail",
71
71
  type: "projectionDetail",
72
72
  query: "leases:query:lease:detail",
73
- layout: { sections: [] },
73
+ layout: {
74
+ sections: [
75
+ {
76
+ kind: "relatedList",
77
+ title: "Items",
78
+ query: "leases:query:lease:items",
79
+ columns: ["name"],
80
+ toolbarActions: [
81
+ {
82
+ kind: "navigate",
83
+ id: "quick-note",
84
+ label: "Quick note",
85
+ screen: "item-toolbar-note",
86
+ params: { map: { leaseId: "id" } },
87
+ },
88
+ {
89
+ kind: "navigate",
90
+ id: "silent-add",
91
+ label: "Silent add",
92
+ screen: "item-toolbar-silent",
93
+ },
94
+ ],
95
+ },
96
+ ],
97
+ },
74
98
  metrics: [
75
99
  {
76
100
  field: "openItems",
@@ -85,6 +109,18 @@ const leasesFeature = defineFeature("leases", (r) => {
85
109
  entity: "item",
86
110
  layout: { sections: [{ title: "x", fields: ["leaseId", "note"] }] },
87
111
  });
112
+ r.screen({
113
+ id: "item-toolbar-note",
114
+ type: "entityEdit",
115
+ entity: "item",
116
+ layout: { sections: [{ title: "x", fields: ["leaseId", "note"] }] },
117
+ });
118
+ r.screen({
119
+ id: "item-toolbar-silent",
120
+ type: "entityEdit",
121
+ entity: "item",
122
+ layout: { sections: [{ title: "x", fields: ["leaseId", "note"] }] },
123
+ });
88
124
  r.screen({
89
125
  id: "record-payment",
90
126
  type: "actionForm",
@@ -150,4 +186,12 @@ describe("buildAppSchema — urlPrefillFields derived from navigate params", ()
150
186
  test("non-form screens carry no urlPrefillFields", () => {
151
187
  expect(urlPrefillFieldsOf("leases", "lease-list")).toBeUndefined();
152
188
  });
189
+
190
+ test("a relatedList toolbarAction's params.map target gets the mapped field", () => {
191
+ expect(urlPrefillFieldsOf("leases", "item-toolbar-note")).toEqual(["leaseId"]);
192
+ });
193
+
194
+ test("a relatedList toolbarAction without params contributes no fields — the allowlist stays an allowlist", () => {
195
+ expect(urlPrefillFieldsOf("leases", "item-toolbar-silent")).toEqual([]);
196
+ });
153
197
  });
@@ -118,6 +118,7 @@ function checkProjectionDetailOutputFields(
118
118
  ): void {
119
119
  const recordShape = getZodObjectShape(queryHandlers.get(screen.query)?.outputSchema);
120
120
  const prefix = `[Feature ${featureName}] Screen "${screenId}" (projectionDetail)`;
121
+ // @wrapper-known semantic-alias
121
122
  const checkHeaderField = (part: "title" | "subtitle" | "status", field: string): void => {
122
123
  checkFieldExists(
123
124
  recordShape,
@@ -52,6 +52,9 @@ function projectionDetailNavigateActions(
52
52
  ...screen.layout.sections.flatMap((section) =>
53
53
  section.kind === "relatedList" ? scopeActions(section.rowActions, undefined) : [],
54
54
  ),
55
+ ...screen.layout.sections.flatMap((section) =>
56
+ section.kind === "relatedList" ? scopeActions(section.toolbarActions, undefined) : [],
57
+ ),
55
58
  ...(screen.metrics ?? []).flatMap((metric) =>
56
59
  typeof metric === "string" || metric.navigate === undefined
57
60
  ? []
@@ -0,0 +1,76 @@
1
+ // fw#2914 — TenantDataHookCtx.db / UserDataHookCtx.db are TenantDb, not a raw
2
+ // DbRunner. A hook that hasn't declared `escapeHatch: { reason }` on its
3
+ // `r.useExtension(...)` registration must not be able to reach raw SQL
4
+ // through `ctx.db` at all — these are compile-time guarantees, not runtime
5
+ // checks (the runtime denial is covered by escape-hatch-declaration
6
+ // .integration.test.ts in bundled-features/user-data-rights).
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import type { DbRunner } from "@cosmicdrift/kumiko-types/db-connection";
10
+ import { createTenantDb } from "../../../db/tenant-db";
11
+ import { testTenantId } from "../../../stack";
12
+ import type { TenantDataHookCtx } from "../tenant-data";
13
+ import type { UserDataHookCtx } from "../user-data";
14
+
15
+ const tenantId = testTenantId(1);
16
+
17
+ function fakeRunner(): DbRunner {
18
+ return {
19
+ unsafe: async () => [],
20
+ begin: async () => {
21
+ throw new Error("begin not used in this test");
22
+ },
23
+ } as unknown as DbRunner;
24
+ }
25
+
26
+ describe("TenantDataHookCtx.db / UserDataHookCtx.db are TenantDb, not DbRunner", () => {
27
+ test("compile-time: ctx.db has no raw .unsafe()/.begin() surface", () => {
28
+ const tenantCtx: TenantDataHookCtx = {
29
+ db: createTenantDb(fakeRunner(), tenantId),
30
+ registry: {} as TenantDataHookCtx["registry"],
31
+ tenantId,
32
+ };
33
+ const userCtx: UserDataHookCtx = {
34
+ db: createTenantDb(fakeRunner(), tenantId),
35
+ registry: {} as UserDataHookCtx["registry"],
36
+ tenantId,
37
+ userId: "user-1",
38
+ };
39
+
40
+ // @ts-expect-error TenantDb has no `.unsafe()` — only DbRunner does.
41
+ expect(tenantCtx.db.unsafe).toBeUndefined();
42
+ // @ts-expect-error TenantDb has no `.begin()` — only DbRunner does.
43
+ expect(tenantCtx.db.begin).toBeUndefined();
44
+ // @ts-expect-error TenantDb has no `.unsafe()` — only DbRunner does.
45
+ expect(userCtx.db.unsafe).toBeUndefined();
46
+ // @ts-expect-error TenantDb has no `.begin()` — only DbRunner does.
47
+ expect(userCtx.db.begin).toBeUndefined();
48
+ });
49
+
50
+ test("compile-time: ctx.db does not structurally satisfy DbRunner", () => {
51
+ const tenantCtx: TenantDataHookCtx = {
52
+ db: createTenantDb(fakeRunner(), tenantId),
53
+ registry: {} as TenantDataHookCtx["registry"],
54
+ tenantId,
55
+ };
56
+
57
+ // @ts-expect-error TenantDb is not assignable to DbRunner — a hook can't
58
+ // smuggle ctx.db into a helper typed to expect a raw connection.
59
+ const asRunner: DbRunner = tenantCtx.db;
60
+ expect(asRunner).toBeDefined();
61
+ });
62
+
63
+ test("declared escape hatch: ctx.db.unsafeRaw(reason) returns a real DbRunner", () => {
64
+ const runner = fakeRunner();
65
+ const tenantCtx: TenantDataHookCtx = {
66
+ db: createTenantDb(runner, tenantId, "tenant", undefined, undefined, undefined, {
67
+ unsafeRaw: { reason: "test: declared escape hatch" },
68
+ }),
69
+ registry: {} as TenantDataHookCtx["registry"],
70
+ tenantId,
71
+ };
72
+
73
+ const raw: DbRunner = tenantCtx.db.unsafeRaw("test: declared escape hatch");
74
+ expect(raw).toBe(runner);
75
+ });
76
+ });
@@ -0,0 +1,16 @@
1
+ // fw#2914 — reads the { escapeHatch: { reason } } convention off a
2
+ // `r.useExtension(...)` registration's untyped options bag. Registration-time
3
+ // validation (feature-ui-extensions.ts's useExtension) already rejects an
4
+ // empty reason, but options stays `Record<string, unknown>`, so orchestrators
5
+ // re-narrow at read time instead of trusting the shape.
6
+
7
+ import type { RegistrarExtensionRegistration } from "../types/config";
8
+
9
+ export function extensionUsageEscapeHatchReason(
10
+ usage: Pick<RegistrarExtensionRegistration, "options">,
11
+ ): string | undefined {
12
+ const escapeHatch = usage.options?.["escapeHatch"];
13
+ if (typeof escapeHatch !== "object" || escapeHatch === null) return undefined;
14
+ const reason = (escapeHatch as { reason?: unknown }).reason;
15
+ return typeof reason === "string" && reason.trim().length > 0 ? reason : undefined;
16
+ }
@@ -3,11 +3,13 @@
3
3
  // Mirror of engine/extensions/user-data.ts at tenant granularity.
4
4
  // tenant-lifecycle orchestrates destroy via registry.getExtensionUsages(EXT_TENANT_DATA).
5
5
 
6
- import type { DbRunner } from "../../db/connection";
6
+ import type { TenantDb } from "../../db/tenant-db";
7
7
  import type { Registry, TenantId } from "../types";
8
8
 
9
+ // fw#2914 — db is tenant-filtered; unfiltered access needs
10
+ // `escapeHatch: { reason }` on the owning `r.useExtension(...)` registration.
9
11
  export interface TenantDataHookCtx {
10
- readonly db: DbRunner;
12
+ readonly db: TenantDb;
11
13
  readonly registry: Registry;
12
14
  readonly tenantId: TenantId;
13
15
  }
@@ -1,19 +1,16 @@
1
- // Hook-Signatur-Types für die EXT_USER_DATA-Extension (DSGVO Art. 15+17+20).
1
+ // Hook signature types for the EXT_USER_DATA extension (GDPR Art. 15+17+20).
2
2
  //
3
- // Sprint 1.9 Z1: Bisher gibt der Boot-Validator JEDES Hook-Shape durch
4
- // — useExtension(EXT_USER_DATA, "X", { export: ... }) wird nicht gegen
5
- // eine erwartete Signatur geprüft. Diese Types sind die canonical
6
- // Schema-Sicht; Sprint 2 user-data-rights wird sie via
7
- // `r.extendsRegistrar(EXT_USER_DATA, { hooks: ... })`-Doku exposen.
3
+ // The boot validator does not check useExtension(EXT_USER_DATA, "X", { export: ... })
4
+ // hook shapes against an expected signature — these types are the canonical
5
+ // schema view; app authors get compile-time hints only, no runtime validation.
8
6
  //
9
- // Boot-Time-Schape-Check (Runtime) ist orthogonal und kommt in Sprint
10
- // 2 wenn die exportRunner-/forgetRunner-Pipelines stehen — bis dahin
11
- // sind diese Types Compile-Time-Hints für App-Authors, keine Runtime-
12
- // Validation.
7
+ // The runner pipelines that consume these hooks are built and live: see
8
+ // packages/bundled-features/src/user-data-rights/run-user-export.ts,
9
+ // run-forget-cleanup.ts, run-export-jobs.ts.
13
10
  //
14
- // Siehe docs/plans/datenschutz/user-data-rights.md.
11
+ // See docs/plans/datenschutz/user-data-rights.md.
15
12
 
16
- import type { DbRunner } from "../../db/connection";
13
+ import type { TenantDb } from "../../db/tenant-db";
17
14
  import type { Registry, TenantId } from "../types";
18
15
 
19
16
  // SessionUser.id ist plattformweit `string` (kein Brand-Type). Wenn
@@ -49,13 +46,10 @@ export type UserDataDeleteStrategy = "delete" | "anonymize";
49
46
  export type TenantUserModel = "single-user" | "multi-user";
50
47
 
51
48
  /**
52
- * Context-Snapshot der dem Hook übergeben wird. Sprint 2 erweitert
53
- * das ggf. um cancel-/timeout-Marker; aktuell minimaler Schnitt.
54
- *
55
- * `db` ist `DbRunner` (DbConnection | DbTx) damit der Cleanup-Runner
56
- * (S2.U5b) den Hook in einer Per-User-Sub-Tx callen kann. Hooks die
57
- * raw-DB-Operationen machen funktionieren auf beiden Shapes via
58
- * Drizzle's polymorphem select/insert/update/delete-Chain.
49
+ * fw#2914 — `db` is a tenant-filtered `TenantDb`, bound to `tenantId`
50
+ * (or the per-user sub-tx in the forget path). Unfiltered access needs
51
+ * `escapeHatch: { reason }` on the `r.useExtension(...)` registration,
52
+ * then `ctx.db.unsafeRaw(reason)`.
59
53
  */
60
54
  /**
61
55
  * Minimal storage surface a file-aware forget hook needs to erase binaries.
@@ -73,7 +67,7 @@ export interface UserDataStorageProvider {
73
67
  }
74
68
 
75
69
  export interface UserDataHookCtx {
76
- readonly db: DbRunner;
70
+ readonly db: TenantDb;
77
71
  /**
78
72
  * The app registry. A forget hook that must erase CHILD read-model rows past
79
73
  * the entity's own row — m:n join projections, per-parent detail projections —
@@ -537,6 +537,7 @@ function matchArgString(call: CallExpression, index: number, expected: string):
537
537
  return readNameLiteral(arg) === expected;
538
538
  }
539
539
 
540
+ // @wrapper-known semantic-alias
540
541
  function matchFirstArgString(call: CallExpression, expected: string): boolean {
541
542
  return matchArgString(call, 0, expected);
542
543
  }
@@ -240,9 +240,27 @@ export function buildUiExtensionsMethods<TName extends string>(
240
240
  const { name, entity, ...rest } = extensionNameOrDefinition;
241
241
  return [name, entity, rest] as const;
242
242
  })();
243
+ const resolvedEntityName = resolveName(resolvedEntityRef);
244
+ // fw#2914 — cross-cutting escapeHatch convention for hook-context db
245
+ // access (mirrors r.hook's validation above). Not part of a typed
246
+ // per-extension options shape: useExtension's bag stays generic, but
247
+ // this one key is validated for every extension the same way.
248
+ const escapeHatch = resolvedOptions?.["escapeHatch"];
249
+ if (escapeHatch !== undefined) {
250
+ const reason =
251
+ typeof escapeHatch === "object" && escapeHatch !== null
252
+ ? (escapeHatch as { reason?: unknown }).reason
253
+ : undefined;
254
+ if (typeof reason !== "string" || reason.trim().length === 0) {
255
+ throw new Error(
256
+ `[Feature ${name}] r.useExtension("${extensionName}", "${resolvedEntityName}", ...) declares an invalid { escapeHatch } — ` +
257
+ `must be { reason: "<non-empty string>" } explaining why this usage needs unfiltered db access.`,
258
+ );
259
+ }
260
+ }
243
261
  state.extensionUsages.push({
244
262
  extensionName,
245
- entityName: resolveName(resolvedEntityRef),
263
+ entityName: resolvedEntityName,
246
264
  options: resolvedOptions,
247
265
  });
248
266
  },
@@ -112,6 +112,7 @@ export {
112
112
  FILE_PROVIDER_CONFIG_KEY,
113
113
  TENANT_MEMBERSHIPS_QUERY,
114
114
  } from "./extension-names";
115
+ export { extensionUsageEscapeHatchReason } from "./extensions/escape-hatch-usage";
115
116
  export type {
116
117
  StorageProviderDestroyTenantHook,
117
118
  StorageProviderExtensionHooks,
@@ -127,8 +127,9 @@ export function applyExtensionUsages(state: RegistryState): void {
127
127
  // to also be gated, store the registering-feature on
128
128
  // RegistrarExtensionRegistration and use that here.
129
129
  const extOwner = "*";
130
- // Extensions have no declaration site for escapeHatch, so SYSTEM is always denied
131
- // (system-identity-switch.ts) — bindHookEscapeHatchGrant(..., undefined) below.
130
+ // Unlike export/forget hooks (escapeHatch on r.useExtension(...)), these
131
+ // entity-lifecycle wrappers (postSave/preDelete/postDelete/preSave) have no
132
+ // escapeHatch declaration site, so SYSTEM stays denied here (system-identity-switch.ts).
132
133
  if (ext.hooks) {
133
134
  if (ext.hooks.postSave) {
134
135
  const wrapped = bindHookEscapeHatchGrant(
@@ -0,0 +1,109 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
3
+ import { asRawClient, transaction } from "../../db/query";
4
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
5
+ import { InternalError } from "../../errors";
6
+ import { ensureTemporalPolyfill } from "../../time/polyfill";
7
+ import { generateId as uuid } from "../../utils";
8
+ import { createEventsTable, loadAggregate, VersionConflictError } from "../index";
9
+ import { appendProvenanceEvent, type ProvenanceEventInput } from "../provenance-append";
10
+
11
+ let testDb: BunTestDb;
12
+ let tdb: TenantDb;
13
+
14
+ const tenantA = uuid();
15
+
16
+ function provenanceEvent(overrides: Partial<ProvenanceEventInput>): ProvenanceEventInput {
17
+ return {
18
+ aggregateId: uuid(),
19
+ aggregateType: "ai-call",
20
+ tenantId: tenantA,
21
+ expectedVersion: 0,
22
+ type: "ai-foundation:ai-call-recorded",
23
+ payload: { promptVersion: "abc123" },
24
+ metadata: { userId: uuid() },
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ beforeAll(async () => {
30
+ await ensureTemporalPolyfill();
31
+ testDb = await createTestDb();
32
+ await createEventsTable(testDb.db);
33
+ tdb = createTenantDb(testDb.db, tenantA);
34
+ });
35
+
36
+ afterAll(async () => {
37
+ await testDb.cleanup();
38
+ });
39
+
40
+ beforeEach(async () => {
41
+ await asRawClient(testDb.db).unsafe(`TRUNCATE kumiko_events RESTART IDENTITY`);
42
+ });
43
+
44
+ describe("appendProvenanceEvent", () => {
45
+ test("expectedVersion: 0 writes the event, readable via loadAggregate", async () => {
46
+ const event = provenanceEvent({ expectedVersion: 0 });
47
+
48
+ await appendProvenanceEvent(tdb, event);
49
+
50
+ const events = await loadAggregate(testDb.db, event.aggregateId, tenantA);
51
+ expect(events).toHaveLength(1);
52
+ expect(events[0]?.type).toBe(event.type);
53
+ expect(events[0]?.payload).toEqual(event.payload);
54
+ });
55
+
56
+ test('expectedVersion: "current" appends twice to the same aggregate without a version conflict', async () => {
57
+ const aggregateId = uuid();
58
+
59
+ await appendProvenanceEvent(tdb, provenanceEvent({ aggregateId, expectedVersion: "current" }));
60
+ await appendProvenanceEvent(tdb, provenanceEvent({ aggregateId, expectedVersion: "current" }));
61
+
62
+ const events = await loadAggregate(testDb.db, aggregateId, tenantA);
63
+ expect(events).toHaveLength(2);
64
+ expect(events[0]?.type).toBe("ai-foundation:ai-call-recorded");
65
+ expect(events[1]?.type).toBe("ai-foundation:ai-call-recorded");
66
+ });
67
+
68
+ test("a failing append does not poison the surrounding transaction", async () => {
69
+ const aggregateId = uuid();
70
+ await appendProvenanceEvent(tdb, provenanceEvent({ aggregateId, expectedVersion: 0 }));
71
+
72
+ await transaction(testDb.db, async (tx) => {
73
+ const txTdb = createTenantDb(tx, tenantA);
74
+
75
+ await expect(
76
+ appendProvenanceEvent(txTdb, provenanceEvent({ aggregateId, expectedVersion: 0 })),
77
+ ).rejects.toThrow(VersionConflictError);
78
+
79
+ const stillUsable = await asRawClient(tx).unsafe<{ one: number }>("SELECT 1 AS one");
80
+ expect(stillUsable[0]?.one).toBe(1);
81
+ });
82
+ });
83
+
84
+ test("a TenantDb not built by createTenantDb fails closed", async () => {
85
+ const handBuilt = { tenantId: tenantA, mode: "tenant" } as unknown as TenantDb;
86
+
87
+ await expect(appendProvenanceEvent(handBuilt, provenanceEvent({}))).rejects.toThrow(
88
+ InternalError,
89
+ );
90
+ });
91
+
92
+ test("rejects a kumiko:system: type and writes nothing", async () => {
93
+ const event = provenanceEvent({ type: "kumiko:system:deferred-dispatch" });
94
+
95
+ await expect(appendProvenanceEvent(tdb, event)).rejects.toThrow(InternalError);
96
+
97
+ const events = await loadAggregate(testDb.db, event.aggregateId, tenantA);
98
+ expect(events).toHaveLength(0);
99
+ });
100
+
101
+ test("rejects an unqualified type without a ':' and writes nothing", async () => {
102
+ const event = provenanceEvent({ type: "ai-call-recorded" });
103
+
104
+ await expect(appendProvenanceEvent(tdb, event)).rejects.toThrow(InternalError);
105
+
106
+ const events = await loadAggregate(testDb.db, event.aggregateId, tenantA);
107
+ expect(events).toHaveLength(0);
108
+ });
109
+ });
@@ -31,6 +31,7 @@ export {
31
31
  streamAllEventsByType,
32
32
  } from "./event-store";
33
33
  export { createEventsTable, eventsTable } from "./events-schema";
34
+ export { appendProvenanceEvent, type ProvenanceEventInput } from "./provenance-append";
34
35
  export {
35
36
  createRebuildDeadLetterTable,
36
37
  listRebuildDeadLetters,
@@ -0,0 +1,59 @@
1
+ import { runInSavepointIfSupported } from "../db/query";
2
+ import { type TenantDb, unsafeRawForDeclaredStep, withUnsafeRawGrant } from "../db/tenant-db";
3
+ import type { TenantId } from "../engine/types";
4
+ import { InternalError } from "../errors";
5
+ import { SYSTEM_EVENT_PREFIX } from "../pipeline/append-event-core";
6
+ import { append, type EventMetadata, getStreamVersion } from "./event-store";
7
+
8
+ // Fixed by the framework, not the caller — the point of this entry point is
9
+ // that no caller can invent its own escapeHatch reason for bypassing
10
+ // cross-feature event ownership (appendDomainEventCore).
11
+ const PROVENANCE_APPEND_REASON =
12
+ "event-store: provenance events append outside the calling feature's ownership — declared once, framework-wide, for provenance streams only.";
13
+
14
+ export type ProvenanceEventInput = {
15
+ readonly aggregateId: string;
16
+ readonly aggregateType: string;
17
+ readonly tenantId: TenantId;
18
+ readonly expectedVersion: number | "current";
19
+ readonly type: string;
20
+ readonly payload: Record<string, unknown>;
21
+ readonly metadata: EventMetadata;
22
+ };
23
+
24
+ // The only place provenance writers are allowed to reach a raw event-store append.
25
+ export async function appendProvenanceEvent(
26
+ db: TenantDb,
27
+ event: ProvenanceEventInput,
28
+ ): Promise<void> {
29
+ if (event.type.startsWith(SYSTEM_EVENT_PREFIX)) {
30
+ throw new InternalError({
31
+ message: `appendProvenanceEvent("${event.type}") — the "${SYSTEM_EVENT_PREFIX}" namespace is framework-internal and is not reachable through this entry point.`,
32
+ });
33
+ }
34
+ if (!event.type.includes(":")) {
35
+ throw new InternalError({
36
+ message: `appendProvenanceEvent("${event.type}") — event types must be owner-qualified ("<feature>:<name>"). Unowned types are not allowed here.`,
37
+ });
38
+ }
39
+ const granted = withUnsafeRawGrant(db, { reason: PROVENANCE_APPEND_REASON });
40
+ const runner = unsafeRawForDeclaredStep(granted, PROVENANCE_APPEND_REASON);
41
+ // Bun.SQL/postgres.js abort the whole surrounding begin() on a statement
42
+ // error (25P02) even when the caller's catch swallows it — the savepoint
43
+ // confines that to this scope. Pool connections have no ambient tx to poison.
44
+ await runInSavepointIfSupported(runner, async (sp) => {
45
+ const expectedVersion =
46
+ event.expectedVersion === "current"
47
+ ? await getStreamVersion(sp, event.aggregateId, event.tenantId)
48
+ : event.expectedVersion;
49
+ await append(sp, {
50
+ aggregateId: event.aggregateId,
51
+ aggregateType: event.aggregateType,
52
+ tenantId: event.tenantId,
53
+ expectedVersion,
54
+ type: event.type,
55
+ payload: event.payload,
56
+ metadata: event.metadata,
57
+ });
58
+ });
59
+ }
@@ -47,7 +47,7 @@ function eventOwnerFeature(qualifiedName: string): string | undefined {
47
47
  // on the literal type-string. Apps cannot write into "kumiko:system:*"
48
48
  // directly — only framework step implementations call unsafeAppendEvent
49
49
  // with these types.
50
- const SYSTEM_EVENT_PREFIX = "kumiko:system:";
50
+ export const SYSTEM_EVENT_PREFIX = "kumiko:system:";
51
51
 
52
52
  export async function appendDomainEventCore(
53
53
  deps: AppendDomainEventCoreDeps,