@cosmicdrift/kumiko-framework 0.289.0 → 0.290.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 (41) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/field-access.integration.test.ts +7 -3
  3. package/src/__tests__/ownership-where-write-path.integration.test.ts +1 -1
  4. package/src/__tests__/ownership.integration.test.ts +1 -1
  5. package/src/api/__tests__/server-boot-guards.test.ts +42 -0
  6. package/src/api/request-context.ts +28 -0
  7. package/src/api/server.ts +30 -4
  8. package/src/changes.json +18 -0
  9. package/src/crypto/__tests__/blind-index.test.ts +1 -1
  10. package/src/crypto/__tests__/pii-field-encryption.test.ts +2 -2
  11. package/src/crypto/__tests__/subject-resolver.test.ts +2 -2
  12. package/src/db/__tests__/blind-index.integration.test.ts +1 -1
  13. package/src/db/__tests__/eagerload.integration.test.ts +12 -2
  14. package/src/db/__tests__/entity-field-encryption.test.ts +2 -2
  15. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
  16. package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +7 -2
  17. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +3 -1
  18. package/src/db/__tests__/event-store-executor.integration.test.ts +15 -5
  19. package/src/db/__tests__/list-filter-field-access.integration.test.ts +6 -2
  20. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +32 -0
  21. package/src/engine/__tests__/boot-validator-boot-check.test.ts +1 -1
  22. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +26 -15
  23. package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +6 -2
  24. package/src/engine/__tests__/factories-long-text.test.ts +6 -1
  25. package/src/engine/boot-validator/__tests__/record-owned.test.ts +12 -2
  26. package/src/engine/boot-validator/action-wiring.ts +2 -1
  27. package/src/engine/boot-validator/screens.ts +4 -12
  28. package/src/engine/index.ts +1 -0
  29. package/src/engine/qualified-name.ts +9 -0
  30. package/src/event-store/__tests__/backfill-pii.integration.test.ts +2 -2
  31. package/src/event-store/__tests__/event-attribution.integration.test.ts +186 -0
  32. package/src/event-store/event-store.ts +23 -2
  33. package/src/jobs/job-runner.ts +10 -2
  34. package/src/pipeline/active-membership.ts +10 -4
  35. package/src/pipeline/append-event-core.ts +2 -11
  36. package/src/pipeline/dispatch-shared.ts +17 -5
  37. package/src/pipeline/event-dispatcher-delivery.ts +15 -3
  38. package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +1 -1
  39. package/src/testing/__tests__/e2e-generator.test.ts +50 -0
  40. package/src/testing/e2e-generator.ts +4 -3
  41. package/src/ui-types/index.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.289.0",
3
+ "version": "0.290.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,8 +198,8 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-http": "0.289.0",
202
- "@cosmicdrift/kumiko-types": "0.289.0",
201
+ "@cosmicdrift/kumiko-http": "0.290.0",
202
+ "@cosmicdrift/kumiko-types": "0.290.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "bun-types": "^1.3.13",
205
205
  "hono": "^4.13.1",
@@ -215,7 +215,7 @@
215
215
  "zod": "^4.4.3"
216
216
  },
217
217
  "devDependencies": {
218
- "@cosmicdrift/kumiko-dispatcher-live": "0.289.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.290.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
@@ -26,10 +26,14 @@ import {
26
26
  const employeeEntity = createEntity({
27
27
  table: "fa_employees",
28
28
  fields: {
29
- email: createTextField({ required: true }),
30
- firstName: createTextField(),
29
+ email: createTextField({ personal: false, reason: "test_fixture", required: true }),
30
+ firstName: createTextField({ personal: false, reason: "test_fixture" }),
31
31
  salary: createNumberField({ access: { read: ["Admin", "Accounting"], write: ["Admin"] } }),
32
- notes: createTextField({ access: { read: ["Admin"], write: ["Admin"] } }),
32
+ notes: createTextField({
33
+ personal: false,
34
+ reason: "test_fixture",
35
+ access: { read: ["Admin"], write: ["Admin"] },
36
+ }),
33
37
  },
34
38
  });
35
39
 
@@ -45,7 +45,7 @@ const memoEntity = createEntity({
45
45
  table: "fw2626_memos",
46
46
  softDelete: true,
47
47
  fields: {
48
- ownerId: createTextField({ required: true }),
48
+ ownerId: createTextField({ personal: false, reason: "test_fixture", required: true }),
49
49
  title: createTextField({ personal: false, reason: "test_fixture", required: true }),
50
50
  },
51
51
  access: {
@@ -37,7 +37,7 @@ const contractEntity = createEntity({
37
37
  softDelete: true,
38
38
  fields: {
39
39
  teamId: createTextField({ personal: false, reason: "test_fixture", required: true }),
40
- assigneeId: createTextField(),
40
+ assigneeId: createTextField({ personal: false, reason: "test_fixture" }),
41
41
  title: createTextField({ personal: false, reason: "test_fixture", required: true }),
42
42
  // propA: public on read + write
43
43
  propA: createTextField({ personal: false, reason: "test_fixture" }),
@@ -10,6 +10,7 @@ import {
10
10
  defineFeature,
11
11
  defineQueryHandler,
12
12
  EXT_PRINCIPAL_STATUS,
13
+ EXT_TENANT_LIFECYCLE_STATUS,
13
14
  } from "../../engine";
14
15
  import { createInMemorySearchAdapter } from "../../search";
15
16
  import { buildServer } from "../server";
@@ -263,3 +264,44 @@ describe("buildServer — auth membershipQuery requires a principalStatus provid
263
264
  ).not.toThrow();
264
265
  });
265
266
  });
267
+
268
+ describe("buildServer — tenant-lifecycle gate derivation", () => {
269
+ const lifecyclePlugin = { resolveStatus: async () => null };
270
+ const providerFeature = defineFeature("lifecycle-provider", (r) => {
271
+ r.extendsRegistrar(EXT_TENANT_LIFECYCLE_STATUS, {});
272
+ r.useExtension(EXT_TENANT_LIFECYCLE_STATUS, "lifecycle-provider", lifecyclePlugin);
273
+ });
274
+ const secondProviderFeature = defineFeature("lifecycle-provider-2", (r) => {
275
+ r.useExtension(EXT_TENANT_LIFECYCLE_STATUS, "lifecycle-provider-2", lifecyclePlugin);
276
+ });
277
+
278
+ test("throws when a lifecycle provider is mounted but context.db is missing", () => {
279
+ expect(() =>
280
+ buildServer({
281
+ registry: createRegistry([providerFeature]),
282
+ context: {},
283
+ jwtSecret: JWT_SECRET,
284
+ }),
285
+ ).toThrow(/tenantLifecycleStatus provider is mounted .* but context\.db is missing/);
286
+ });
287
+
288
+ test("throws when two lifecycle providers are registered", () => {
289
+ expect(() =>
290
+ buildServer({
291
+ registry: createRegistry([providerFeature, secondProviderFeature]),
292
+ context: {},
293
+ jwtSecret: JWT_SECRET,
294
+ }),
295
+ ).toThrow(/multiple "tenantLifecycleStatus" providers registered/);
296
+ });
297
+
298
+ test("no provider mounted leaves the gate unwired", () => {
299
+ expect(() =>
300
+ buildServer({
301
+ registry: createRegistry([]),
302
+ context: {},
303
+ jwtSecret: JWT_SECRET,
304
+ }),
305
+ ).not.toThrow();
306
+ });
307
+ });
@@ -38,6 +38,12 @@ export type RequestContextData = {
38
38
  // request-locale.ts. Undefined when neither header carried a valid tag;
39
39
  // callers fall back further (dispatch-shared.ts's ctx.locale chain).
40
40
  readonly locale?: string;
41
+ // Attribution of the currently executing scope (#3043): the feature that
42
+ // owns it and the qualified name of the handler / MSP-consumer / job
43
+ // inside it. event-store.append() reads both and stamps them onto every
44
+ // event written under this scope.
45
+ readonly feature?: string;
46
+ readonly handler?: string;
41
47
  };
42
48
 
43
49
  const storage = new AsyncLocalStorage<RequestContextData>();
@@ -55,3 +61,25 @@ export const requestContext = {
55
61
  return generateId();
56
62
  },
57
63
  };
64
+
65
+ // Enter a scope that attributes every event written inside it. Keeps the
66
+ // surrounding request's ids so correlation survives, and mints fresh ones
67
+ // when there is no surrounding request (job-runner, event-dispatcher) —
68
+ // requestId/correlationId are mandatory, `get()` may be undefined.
69
+ export function runWithOrigin<T>(
70
+ origin: { readonly feature?: string; readonly handler?: string },
71
+ fn: () => T,
72
+ ): T {
73
+ const current = requestContext.get();
74
+ const requestId = current?.requestId ?? requestContext.generateId();
75
+ return requestContext.run(
76
+ {
77
+ ...current,
78
+ requestId,
79
+ correlationId: current?.correlationId ?? requestId,
80
+ feature: origin.feature,
81
+ handler: origin.handler,
82
+ },
83
+ fn,
84
+ );
85
+ }
package/src/api/server.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  registerStandardMetrics,
20
20
  wrapRedisClient,
21
21
  } from "../observability";
22
+ import { resolveTenantLifecyclePlugin } from "../pipeline/active-membership";
22
23
  import type { DispatcherOptions } from "../pipeline/dispatcher";
23
24
  import { createDispatcher, type Dispatcher } from "../pipeline/dispatcher";
24
25
  import { SHARED_INSTANCE_SENTINEL } from "../pipeline/event-consumer-state";
@@ -43,7 +44,12 @@ import {
43
44
  import type { SearchAdapter } from "../search/types";
44
45
  import { assertUnreachable, generateId } from "../utils";
45
46
  import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS } from "./api-constants";
46
- import { type AnonymousAccessResolved, authMiddleware, getUser } from "./auth-middleware";
47
+ import {
48
+ type AnonymousAccessResolved,
49
+ authMiddleware,
50
+ getUser,
51
+ type TenantLifecycleStatusResolver,
52
+ } from "./auth-middleware";
47
53
  import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
48
54
  import { csrfMiddleware } from "./csrf-middleware";
49
55
  import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
@@ -670,12 +676,16 @@ export function buildServer(options: ServerOptions): KumikoServer {
670
676
  // a token (or, when anonymousAccess is wired, falls through as anonymous).
671
677
  // A session-checker is forwarded when the auth-config wires one, so the
672
678
  // middleware can reject revoked sids on every request.
679
+ // Mounting a tenantLifecycleStatus provider is what turns the 410 on, so no
680
+ // entrypoint can forget the wiring; `??` short-circuits, so an explicit
681
+ // auth.resolveTenantLifecycleStatus remains the override.
682
+ const tenantLifecycleResolver =
683
+ options.auth?.resolveTenantLifecycleStatus ??
684
+ deriveTenantLifecycleResolver(options.registry, baseDb);
673
685
  const jwtGuard = authMiddleware(jwt, {
674
686
  ...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
675
687
  ...(options.auth?.tokenVerifier ? { tokenVerifier: options.auth.tokenVerifier } : {}),
676
- ...(options.auth?.resolveTenantLifecycleStatus
677
- ? { resolveTenantLifecycleStatus: options.auth.resolveTenantLifecycleStatus }
678
- : {}),
688
+ ...(tenantLifecycleResolver ? { resolveTenantLifecycleStatus: tenantLifecycleResolver } : {}),
679
689
  ...(options.anonymousAccess ? { anonymousAccess: options.anonymousAccess } : {}),
680
690
  });
681
691
  app.use("/api/*", async (c, next) => {
@@ -890,6 +900,22 @@ export function buildServer(options: ServerOptions): KumikoServer {
890
900
  };
891
901
  }
892
902
 
903
+ function deriveTenantLifecycleResolver(
904
+ registry: Registry,
905
+ db: DbConnection | undefined,
906
+ ): TenantLifecycleStatusResolver | undefined {
907
+ const plugin = resolveTenantLifecyclePlugin(registry, "buildServer");
908
+ if (!plugin) return undefined;
909
+ if (!db) {
910
+ throw new Error(
911
+ "[kumiko] a tenantLifecycleStatus provider is mounted (tenant-lifecycle) but context.db is " +
912
+ "missing — the request-level teardown gate needs a DbConnection. Pass context.db, or wire " +
913
+ "auth.resolveTenantLifecycleStatus explicitly.",
914
+ );
915
+ }
916
+ return (tenantId) => plugin.resolveStatus(tenantId, { db });
917
+ }
918
+
893
919
  // Scans every feature's entities for a file/image/files/images field. Short-
894
920
  // circuits on the first hit — no need to build a full inventory, we only want
895
921
  // the yes/no answer for the boot check.
package/src/changes.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.290.0",
4
+ "type": "improvement",
5
+ "title": "Event-Attribution: metadata.feature + metadata.handler auf jedem Event",
6
+ "detail": "event-store.append() stempelt Feature und Handler-Namen aus dem Ausfuehrungsscope (requestContext) auf jedes geschriebene Event. Dispatch-Handler, Entity-Executor-Writes, MSP-Applies und Jobs sind damit ohne Signaturaenderung attribuiert; Appends ausserhalb eines Scopes tragen UNATTRIBUTED_ORIGIN. appendRaw/appendRawBatch bleiben unveraendert."
7
+ },
8
+ {
9
+ "version": "0.290.0",
10
+ "type": "fix",
11
+ "title": "Section readers see groups[].fields",
12
+ "detail": "Boot-guard and E2E-generator now flatten section.groups via sectionFieldSpecs instead of iterating section.fields only: a function renderer inside groups[].fields fails at boot, and generated E2E specs cover group fields (required fields, fill ops, text assertions)."
13
+ },
14
+ {
15
+ "version": "0.290.0",
16
+ "type": "improvement",
17
+ "title": "The tenant-teardown 410 gate is derived from the mounted tenantLifecycleStatus provider (fw#2881).",
18
+ "detail": "buildServer now resolves the EXT_TENANT_LIFECYCLE_STATUS extension point itself, so every server with tenant-lifecycle mounted rejects requests against a tenant in teardown with 410 tenant_unavailable — prod, dev and test stacks can no longer drift because one entrypoint forgot the wiring. An explicit auth.resolveTenantLifecycleStatus still takes precedence and stays the escape hatch. A mounted provider without context.db is a boot error instead of a silently skipped gate."
19
+ },
2
20
  {
3
21
  "version": "0.289.0",
4
22
  "type": "breaking",
@@ -25,7 +25,7 @@ const TEST_KEY = decodeBlindIndexKey(TEST_KEY_B64);
25
25
  const userLikeEntity = createEntity({
26
26
  fields: {
27
27
  email: createTextField({ required: true, personal: "self", find: "exact" }),
28
- role: createTextField(),
28
+ role: createTextField({ personal: false, reason: "test_fixture" }),
29
29
  },
30
30
  table: "bidx_users",
31
31
  });
@@ -28,7 +28,7 @@ const ENTITY_NAME = "pii-entity";
28
28
  const userLikeEntity = createEntity({
29
29
  fields: {
30
30
  email: createTextField({ required: true, personal: "self", find: "none" }),
31
- role: createTextField(),
31
+ role: createTextField({ personal: false, reason: "test_fixture" }),
32
32
  },
33
33
  table: "pii_users",
34
34
  });
@@ -39,7 +39,7 @@ const commentEntity = createEntity({
39
39
  personal: { of: "authorId" },
40
40
  find: "none",
41
41
  }),
42
- authorId: createTextField({ required: true }),
42
+ authorId: createTextField({ personal: false, reason: "test_fixture", required: true }),
43
43
  },
44
44
  table: "pii_comments",
45
45
  });
@@ -11,7 +11,7 @@ import {
11
11
  const userLikeEntity = createEntity({
12
12
  fields: {
13
13
  email: createTextField({ required: true, personal: "self", find: "none" }),
14
- role: createTextField(),
14
+ role: createTextField({ personal: false, reason: "test_fixture" }),
15
15
  },
16
16
  table: "resolver_users",
17
17
  idType: "uuid",
@@ -23,7 +23,7 @@ const commentEntity = createEntity({
23
23
  personal: { of: "authorId" },
24
24
  find: "none",
25
25
  }),
26
- authorId: createTextField({ required: true }),
26
+ authorId: createTextField({ personal: false, reason: "test_fixture", required: true }),
27
27
  },
28
28
  table: "resolver_comments",
29
29
  });
@@ -37,7 +37,7 @@ const personEntity = createEntity({
37
37
  table: "read_bidx_persons",
38
38
  fields: {
39
39
  email: createTextField({ required: true, personal: "self", find: "exact" }),
40
- firstName: createTextField(),
40
+ firstName: createTextField({ personal: false, reason: "test_fixture" }),
41
41
  },
42
42
  });
43
43
  const personFeature = defineFeature("bidxtest", (r) => {
@@ -50,7 +50,12 @@ const contactEntity = createEntity({
50
50
  fields: {
51
51
  name: createTextField({ required: true, personal: false, reason: "test_fixture" }),
52
52
  email: createTextField({ required: true, personal: "tenant", find: "none" }),
53
- iban: createTextField({ required: true, encrypted: true }),
53
+ iban: createTextField({
54
+ personal: false,
55
+ reason: "test_fixture",
56
+ required: true,
57
+ encrypted: true,
58
+ }),
54
59
  },
55
60
  });
56
61
  const leadEntity = createEntity({
@@ -72,7 +77,12 @@ const ownedContactEntity = createEntity({
72
77
  fields: {
73
78
  name: createTextField({ required: true, personal: false, reason: "test_fixture" }),
74
79
  email: createTextField({ required: true, personal: "tenant", find: "none" }),
75
- iban: createTextField({ required: true, encrypted: true }),
80
+ iban: createTextField({
81
+ personal: false,
82
+ reason: "test_fixture",
83
+ required: true,
84
+ encrypted: true,
85
+ }),
76
86
  },
77
87
  access: { read: { admin: from("user:id", "ownerId") } },
78
88
  });
@@ -13,8 +13,8 @@ describe("entity-field-encryption", () => {
13
13
  const entity = createEntity({
14
14
  table: "read_enc_test",
15
15
  fields: {
16
- email: createTextField({ required: true }),
17
- secretNote: createTextField({ encrypted: true }),
16
+ email: createTextField({ personal: false, reason: "test_fixture", required: true }),
17
+ secretNote: createTextField({ personal: false, reason: "test_fixture", encrypted: true }),
18
18
  },
19
19
  });
20
20
  const encryptedFields = collectEncryptedFieldNames(entity);
@@ -16,7 +16,7 @@ describe("event-store-executor-context — encryptForStorage/decryptForRead laye
16
16
  const entity = createEntity({
17
17
  table: "pii_roundtrip_test",
18
18
  fields: {
19
- userId: createTextField({ required: true }),
19
+ userId: createTextField({ personal: false, reason: "test_fixture", required: true }),
20
20
  // Both markers at once — the auth-mfa.totpSecret/recoveryCodes shape
21
21
  // that first surfaced the ordering bug (pii-subject-encryption
22
22
  // integration test).
@@ -34,9 +34,14 @@ const cipher = createTestEnvelopeCipher(TEST_KEY);
34
34
  const entity = createEntity({
35
35
  table: "read_money_orders",
36
36
  fields: {
37
- ownerId: createTextField({ required: true }),
37
+ ownerId: createTextField({ personal: false, reason: "test_fixture", required: true }),
38
38
  grossTotal: createMoneyField(),
39
- billingIban: createTextField({ required: true, encrypted: true }),
39
+ billingIban: createTextField({
40
+ personal: false,
41
+ reason: "test_fixture",
42
+ required: true,
43
+ encrypted: true,
44
+ }),
40
45
  },
41
46
  // A non-"all" read rule forces buildOwnershipClause into the
42
47
  // ownership.kind==="sql" raw-SQL branch that list()/detail() read through.
@@ -227,8 +227,10 @@ describe("event-store-executor write-verbs — restore without softDelete", () =
227
227
  const ownedFieldEntity = createEntity({
228
228
  table: "read_es_write_owned_field",
229
229
  fields: {
230
- authorId: createTextField({ required: true }),
230
+ authorId: createTextField({ personal: false, reason: "test_fixture", required: true }),
231
231
  note: createTextField({
232
+ personal: false,
233
+ reason: "test_fixture",
232
234
  access: { write: { Admin: "all", User: from("user:id", "authorId") } },
233
235
  }),
234
236
  },
@@ -139,8 +139,18 @@ const sensitiveEntity = createEntity({
139
139
  table: "read_es_exec_sensitive",
140
140
  fields: {
141
141
  email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
142
- passwordHash: createTextField({ sensitive: true, encrypted: true }),
143
- apiToken: createTextField({ sensitive: true, encrypted: true }),
142
+ passwordHash: createTextField({
143
+ personal: false,
144
+ reason: "test_fixture",
145
+ sensitive: true,
146
+ encrypted: true,
147
+ }),
148
+ apiToken: createTextField({
149
+ personal: false,
150
+ reason: "test_fixture",
151
+ sensitive: true,
152
+ encrypted: true,
153
+ }),
144
154
  },
145
155
  softDelete: true,
146
156
  });
@@ -343,7 +353,7 @@ const encryptedEntity = createEntity({
343
353
  table: "read_es_exec_encrypted",
344
354
  fields: {
345
355
  email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
346
- secretNote: createTextField({ encrypted: true }),
356
+ secretNote: createTextField({ personal: false, reason: "test_fixture", encrypted: true }),
347
357
  },
348
358
  });
349
359
  const encryptedTable = buildEntityTable("esExecEncrypted", encryptedEntity);
@@ -352,7 +362,7 @@ const encryptedSoftDeleteEntity = createEntity({
352
362
  table: "read_es_exec_enc_soft",
353
363
  fields: {
354
364
  email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
355
- secretNote: createTextField({ encrypted: true }),
365
+ secretNote: createTextField({ personal: false, reason: "test_fixture", encrypted: true }),
356
366
  },
357
367
  softDelete: true,
358
368
  });
@@ -703,7 +713,7 @@ const piiEntity = createEntity({
703
713
  personal: { of: "authorId" },
704
714
  find: "none",
705
715
  }),
706
- authorId: createTextField(),
716
+ authorId: createTextField({ personal: false, reason: "test_fixture" }),
707
717
  plain: createTextField({ personal: false, reason: "test_fixture" }),
708
718
  },
709
719
  });
@@ -17,14 +17,18 @@ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } fr
17
17
  const filterNoteEntity = createEntity({
18
18
  table: "fa_filter_notes",
19
19
  fields: {
20
- title: createTextField({ required: true }),
21
- ownerId: createTextField({ required: true }),
20
+ title: createTextField({ personal: false, reason: "test_fixture", required: true }),
21
+ ownerId: createTextField({ personal: false, reason: "test_fixture", required: true }),
22
22
  secret: createTextField({
23
+ personal: false,
24
+ reason: "test_fixture",
23
25
  filterable: true,
24
26
  sortable: true,
25
27
  access: { read: { Admin: "all" } },
26
28
  }),
27
29
  ownedNote: createTextField({
30
+ personal: false,
31
+ reason: "test_fixture",
28
32
  filterable: true,
29
33
  access: { read: { Admin: "all", User: from("user:id", "ownerId") } },
30
34
  }),
@@ -229,6 +229,38 @@ describe("validateBoot — action wiring (no function values)", () => {
229
229
  expect(() => validateBoot([feature])).toThrow(/field "name" renderer is a function/);
230
230
  });
231
231
 
232
+ test("entityEdit field renderer as function inside groups → Throw", () => {
233
+ const feature = defineFeature("shop", (r) => {
234
+ r.entity(
235
+ "product",
236
+ createEntity({
237
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
238
+ }),
239
+ );
240
+ r.screen({
241
+ id: "product-edit",
242
+ type: "entityEdit",
243
+ entity: "product",
244
+ layout: {
245
+ sections: [
246
+ {
247
+ columns: 1,
248
+ fields: [],
249
+ groups: [
250
+ {
251
+ title: "Basis",
252
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
253
+ fields: [{ field: "name", renderer: ((v: unknown) => String(v)) as any }],
254
+ },
255
+ ],
256
+ },
257
+ ],
258
+ },
259
+ });
260
+ });
261
+ expect(() => validateBoot([feature])).toThrow(/field "name" renderer is a function/);
262
+ });
263
+
232
264
  test("entityEdit field with declarative visible/readOnly/required → kein Throw", () => {
233
265
  const feature = defineFeature("shop", (r) => {
234
266
  r.entity(
@@ -62,7 +62,7 @@ describe("r.bootCheck / validateFeatureBootChecks", () => {
62
62
  });
63
63
 
64
64
  test("no PII field → boot succeeds even without the companion", () => {
65
- const noPiiFields = { text: createTextField() };
65
+ const noPiiFields = { text: createTextField({ personal: false, reason: "test_fixture" }) };
66
66
  const noPii = defineFeature("prompt-store-no-pii", (r) => {
67
67
  r.entity("note", createEntity({ fields: noPiiFields }));
68
68
  r.bootCheck(({ features }) => {
@@ -40,6 +40,17 @@ import type { LongTextFieldDef, TextFieldDef } from "../types";
40
40
  const rawField = <T>(f: T) => f as unknown as TextFieldDef;
41
41
  const rawLongTextField = <T>(f: T) => f as unknown as LongTextFieldDef;
42
42
 
43
+ // The heuristics under test only fire without a personal stance; after #2810
44
+ // the factories can no longer produce that shape.
45
+ const unannotatedText: TextFieldDef = {
46
+ type: "text",
47
+ maxLength: 200,
48
+ required: false,
49
+ searchable: false,
50
+ sortable: false,
51
+ };
52
+ const unannotatedLongText: LongTextFieldDef = { type: "longText", required: false };
53
+
43
54
  // Stubt einen leeren `<entity>:list`-Query-Handler damit der reference-
44
55
  // Field-Boot-Validator den Audit-Fix-#2-Check durchläßt. Wird gebraucht
45
56
  // wenn ein Test ein reference-Feld benutzt — sonst liefert der validator
@@ -136,7 +147,7 @@ describe("validateBoot — PII annotations", () => {
136
147
  createEntity({
137
148
  fields: {
138
149
  confused: rawField({
139
- ...createTextField(),
150
+ ...unannotatedText,
140
151
  pii: true,
141
152
  tenantOwned: true,
142
153
  }),
@@ -153,7 +164,7 @@ describe("validateBoot — PII annotations", () => {
153
164
  "vault",
154
165
  createEntity({
155
166
  fields: {
156
- apiToken: createTextField({ sensitive: true }),
167
+ apiToken: { ...unannotatedText, sensitive: true },
157
168
  },
158
169
  }),
159
170
  );
@@ -188,7 +199,7 @@ describe("validateBoot — PII annotations", () => {
188
199
  "vault",
189
200
  createEntity({
190
201
  fields: {
191
- apiToken: createTextField({ sensitive: true, encrypted: true }),
202
+ apiToken: { ...unannotatedText, sensitive: true, encrypted: true },
192
203
  },
193
204
  }),
194
205
  );
@@ -228,7 +239,7 @@ describe("validateBoot — PII annotations", () => {
228
239
  personal: { of: "authorId" },
229
240
  find: "none",
230
241
  }),
231
- authorId: createTextField(),
242
+ authorId: { ...unannotatedText },
232
243
  },
233
244
  }),
234
245
  );
@@ -258,7 +269,7 @@ describe("validateBoot — PII annotations", () => {
258
269
 
259
270
  test("userOwned.ownerField referencing non-user entity warns", () => {
260
271
  const feature = defineFeature("test", (r) => {
261
- r.entity("employee", createEntity({ fields: { name: createTextField() } }));
272
+ r.entity("employee", createEntity({ fields: { name: { ...unannotatedText } } }));
262
273
  stubListHandler(r, "employee");
263
274
  r.entity(
264
275
  "personalNote",
@@ -286,7 +297,7 @@ describe("validateBoot — PII annotations", () => {
286
297
  "thing",
287
298
  createEntity({
288
299
  fields: {
289
- email: createTextField(),
300
+ email: { ...unannotatedText },
290
301
  },
291
302
  }),
292
303
  );
@@ -304,7 +315,7 @@ describe("validateBoot — PII annotations", () => {
304
315
  "thing",
305
316
  createEntity({
306
317
  fields: {
307
- body: createLongTextField(),
318
+ body: { ...unannotatedLongText },
308
319
  },
309
320
  }),
310
321
  );
@@ -322,7 +333,7 @@ describe("validateBoot — PII annotations", () => {
322
333
  "thing",
323
334
  createEntity({
324
335
  fields: {
325
- authorId: createTextField(),
336
+ authorId: { ...unannotatedText },
326
337
  },
327
338
  }),
328
339
  );
@@ -563,7 +574,7 @@ describe("validateBoot — PII annotations", () => {
563
574
  "product",
564
575
  createEntity({
565
576
  fields: {
566
- name: createTextField(),
577
+ name: { ...unannotatedText },
567
578
  },
568
579
  }),
569
580
  );
@@ -581,7 +592,7 @@ describe("validateBoot — PII annotations", () => {
581
592
  "person",
582
593
  createEntity({
583
594
  fields: {
584
- displayName: createTextField(),
595
+ displayName: { ...unannotatedText },
585
596
  },
586
597
  }),
587
598
  );
@@ -626,7 +637,7 @@ describe("validateBoot — retention", () => {
626
637
  "auditEvent",
627
638
  createEntity({
628
639
  fields: {
629
- note: createTextField(),
640
+ note: { ...unannotatedText },
630
641
  },
631
642
  retention: { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
632
643
  }),
@@ -641,7 +652,7 @@ describe("validateBoot — retention", () => {
641
652
  "thing",
642
653
  createEntity({
643
654
  fields: {
644
- note: createTextField(),
655
+ note: { ...unannotatedText },
645
656
  },
646
657
  retention: { keepFor: "30d", strategy: "hardDelete", reference: "ghostField" },
647
658
  }),
@@ -852,7 +863,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
852
863
  createEntity({
853
864
  fields: {
854
865
  slug: rawField({
855
- ...createTextField(),
866
+ ...unannotatedText,
856
867
  lookupable: true,
857
868
  }),
858
869
  },
@@ -872,7 +883,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
872
883
  ...createLongTextField({ personal: { of: "ownerId" }, find: "none" }),
873
884
  lookupable: true,
874
885
  }),
875
- ownerId: createTextField({ required: true }),
886
+ ownerId: { ...unannotatedText, required: true },
876
887
  },
877
888
  }),
878
889
  );
@@ -922,7 +933,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
922
933
  createEntity({
923
934
  fields: {
924
935
  passwordHash: rawField({
925
- ...createTextField(),
936
+ ...unannotatedText,
926
937
  pii: true,
927
938
  sensitive: true,
928
939
  searchable: true,