@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
@@ -1,12 +1,15 @@
1
- import { QnTypes, qualifyEntityName } from "../qualified-name";
1
+ import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
2
+ import { parseQn, QnTypes, qualifyEntityName } from "../qualified-name";
2
3
  import { isEditLayoutScreen } from "../screen-helpers";
3
4
  import type {
4
5
  EditRelatedListSection,
6
+ EntityDefinition,
5
7
  FeatureDefinition,
6
8
  ProjectionListScreenDefinition,
7
9
  QueryHandlerDef,
8
10
  } from "../types";
9
11
  import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens";
12
+ import { findEntity } from "./parent-ref";
10
13
  import { getZodObjectShape } from "./zod-shape";
11
14
 
12
15
  // Sibling to entity-list-screens.ts rather than an extension of it:
@@ -154,6 +157,65 @@ function validateRelatedListFacets(
154
157
  );
155
158
  }
156
159
 
160
+ // parentFilter resolves an entity field map to
161
+ // check `field` against ONLY when the query is one of the entity-convention
162
+ // factories (defineEntityListHandler et al., branded by
163
+ // ENTITY_CONVENTION_QUERY_BRAND) — a hand-written handler carries no entity,
164
+ // same "capability absent, no throw" policy as schemaAccepts above. The
165
+ // entity name comes from the handler's own `<entityName>:list` naming
166
+ // convention (parseHandlerName in entity-handlers.ts), recovered here from
167
+ // the query's fully qualified name.
168
+ function resolveEntityBehindQuery(
169
+ queryQn: string,
170
+ handler: QueryHandlerDef | undefined,
171
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
172
+ ): EntityDefinition | undefined {
173
+ if (handler === undefined || handler[ENTITY_CONVENTION_QUERY_BRAND] !== true) return undefined;
174
+ let entityName: string;
175
+ try {
176
+ entityName = parseQn(queryQn).name.split(":")[0] ?? "";
177
+ } catch {
178
+ return undefined;
179
+ }
180
+ return entityName === "" ? undefined : findEntity(featureMap, entityName);
181
+ }
182
+
183
+ // parentFilter lets a relatedList reuse the
184
+ // generic `<entity>:list` query instead of a bespoke child-rows handler —
185
+ // same 422 footgun as facets/filter above, plus its own mutual-exclusivity
186
+ // and field-existence checks.
187
+ function validateRelatedListParentFilter(
188
+ prefix: string,
189
+ section: EditRelatedListSection,
190
+ schema: QueryHandlerDef["schema"] | undefined,
191
+ handler: QueryHandlerDef | undefined,
192
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
193
+ ): void {
194
+ // skip: no parentFilter declared — nothing to reject.
195
+ if (section.parentFilter === undefined) return;
196
+ if (section.parentParam !== undefined) {
197
+ throw new Error(
198
+ `${prefix}: declares both parentFilter and parentParam — parentFilter replaces the implicit ` +
199
+ `{ [parentParam]: parentId } payload key with a server-side "filter" clause, so the two can't ` +
200
+ `be combined. Remove parentParam or drop parentFilter.`,
201
+ );
202
+ }
203
+ if (!schemaAccepts(schema, "filter")) {
204
+ throw new Error(
205
+ `${prefix}: declares parentFilter but query "${section.query}" has no "filter" parameter in its ` +
206
+ `Zod schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","in"]), ` +
207
+ `value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`,
208
+ );
209
+ }
210
+ const entity = resolveEntityBehindQuery(section.query, handler, featureMap);
211
+ if (entity !== undefined && entity.fields[section.parentFilter.field] === undefined) {
212
+ throw new Error(
213
+ `${prefix}: parentFilter.field "${section.parentFilter.field}" is not a declared field on the ` +
214
+ `entity behind query "${section.query}". Known fields: ${Object.keys(entity.fields).sort().join(", ")}.`,
215
+ );
216
+ }
217
+ }
218
+
157
219
  // relatedList sections declare search/facets the same way a projectionList
158
220
  // screen does, and hit the same 422 footgun: definePagedQueryHandler doesn't
159
221
  // auto-merge params into the handler's own Zod schema (fw#2165/#2224), so an
@@ -163,15 +225,18 @@ function validateRelatedListFacets(
163
225
  // doesn't quietly skip these checks.
164
226
  export function validateRelatedListSectionQueries(features: readonly FeatureDefinition[]): void {
165
227
  const queryHandlers = buildQueryHandlerMap(features);
228
+ const featureMap = new Map(features.map((f) => [f.name, f] as const));
166
229
  for (const feature of features) {
167
230
  for (const screen of Object.values(feature.screens)) {
168
231
  if (!isEditLayoutScreen(screen)) continue;
169
232
  for (const section of screen.layout.sections) {
170
233
  if (section.kind !== "relatedList") continue;
171
234
  const prefix = `[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) relatedList section "${section.title}"`;
172
- const schema = queryHandlers.get(section.query)?.schema;
235
+ const handler = queryHandlers.get(section.query);
236
+ const schema = handler?.schema;
173
237
  validateRelatedListSearchable(prefix, section, schema);
174
238
  validateRelatedListFacets(prefix, section, schema);
239
+ validateRelatedListParentFilter(prefix, section, schema, handler, featureMap);
175
240
  }
176
241
  }
177
242
  }
@@ -14,6 +14,7 @@ import type {
14
14
  } from "../types";
15
15
  import { metricField } from "../types";
16
16
  import { buildQueryHandlerMap } from "./projection-list-screens";
17
+ import { validateActionFieldRefs } from "./screens";
17
18
  import { getZodObjectShape, getZodRowShape } from "./zod-shape";
18
19
 
19
20
  // fw#2493: query handlers can now declare `outputSchema` (the Zod shape of
@@ -139,6 +140,38 @@ function checkProjectionDetailOutputFields(
139
140
  `${prefix} metrics references field "${field}" which is not present in query "${screen.query}"'s outputSchema.`,
140
141
  );
141
142
  }
143
+ checkRelatedListToolbarActionFields(recordShape, featureName, screenId, screen);
144
+ }
145
+
146
+ // relatedList toolbarActions' visible/params evaluate against the ENCLOSING
147
+ // projectionDetail's record, not the relatedList's own row — reuses the
148
+ // same per-action field-ref check as rowActions/toolbarActions elsewhere
149
+ // (validateActionFieldRefs), pinned to this screen's own outputSchema shape
150
+ // instead of an entity's field map. Same additive policy as the rest of this
151
+ // module: no introspectable outputSchema → no-op, not a throw.
152
+ function checkRelatedListToolbarActionFields(
153
+ recordShape: ShapeLookup | undefined,
154
+ featureName: string,
155
+ screenId: string,
156
+ screen: ProjectionDetailScreenDefinition,
157
+ ): void {
158
+ // skip: no introspectable outputSchema — same additive policy as the rest of this module.
159
+ if (recordShape === undefined) return;
160
+ const fieldNames = new Set(Object.keys(recordShape));
161
+ for (const section of screen.layout.sections) {
162
+ if (section.kind !== "relatedList") continue;
163
+ for (const action of section.toolbarActions ?? []) {
164
+ validateActionFieldRefs(
165
+ featureName,
166
+ screenId,
167
+ "toolbarAction",
168
+ action.id,
169
+ action,
170
+ fieldNames,
171
+ new Set(),
172
+ );
173
+ }
174
+ }
142
175
  }
143
176
 
144
177
  // Stat panels' query contract is a flat record (not the paged `{ rows }`
@@ -7,6 +7,7 @@
7
7
  import { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
8
8
  import { rowMetaFieldNames } from "../../db/table-builder";
9
9
  import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta";
10
+ import { parseRefTarget } from "../parse-ref-target";
10
11
  import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name";
11
12
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
12
13
  import {
@@ -14,6 +15,7 @@ import {
14
15
  isWriteFormEditSection,
15
16
  normalizeEditField,
16
17
  normalizeListColumn,
18
+ resolveNavParentScreen,
17
19
  } from "../screen-helpers";
18
20
  import type { EntityDefinition, FeatureDefinition, FieldDefinition } from "../types";
19
21
  import { metricField } from "../types";
@@ -31,6 +33,7 @@ import type {
31
33
  FieldCondition,
32
34
  ListColumnSpec,
33
35
  ListFacetSpec,
36
+ RelatedListToolbarAction,
34
37
  RowAction,
35
38
  RowActionNavigateBase,
36
39
  RowFieldExtractor,
@@ -270,13 +273,15 @@ function validateWizardLayout(
270
273
  // Felder pinnen — ein Tippfehler in pick/map-Quellfeldern oder
271
274
  // visible.field erzeugte sonst still `undefined` im Payload bzw. dauerhaft
272
275
  // falsche Sichtbarkeit (gleiche "Typo fällt erst beim Klick"-Klasse wie
273
- // navigate/handler).
274
- function validateActionFieldRefs(
276
+ // navigate/handler). Exported for query-output-columns.ts, which reuses it
277
+ // against a projectionDetail's outputSchema-derived record shape instead of
278
+ // an entity's field map (relatedList toolbarActions' visible/params).
279
+ export function validateActionFieldRefs(
275
280
  featureName: string,
276
281
  screenId: string,
277
282
  actionKind: "rowAction" | "toolbarAction",
278
283
  actionId: string,
279
- action: RowAction | ToolbarAction,
284
+ action: RowAction | ToolbarAction | RelatedListToolbarAction,
280
285
  fieldNames: ReadonlySet<string>,
281
286
  rowMeta: ReadonlySet<string>,
282
287
  ): void {
@@ -581,6 +586,37 @@ function validateFormFieldsMap(
581
586
  // (an input-less secretMint declares BOTH `fields: {}` and
582
587
  // `layout.sections: []`; fields declared with no layout to show them stays
583
588
  // an error).
589
+ // `fields`/`groups` are mutually exclusive on a fields-kind section; shared by
590
+ // every EditLayout-walking screen type instead of four hand-rolled copies.
591
+ function validateFieldsXorGroups(
592
+ errorPrefix: string,
593
+ section: { readonly title?: string; readonly fields: readonly EditFieldSpec[] } & {
594
+ readonly groups?: readonly { readonly fields: readonly EditFieldSpec[] }[];
595
+ },
596
+ ): void {
597
+ if (section.fields.length > 0 && section.groups !== undefined) {
598
+ throw new Error(
599
+ `${errorPrefix} section "${section.title}" declares both fields and groups — pass fields: [] ` +
600
+ `when using groups.`,
601
+ );
602
+ }
603
+ if (section.fields.length === 0 && section.groups === undefined) {
604
+ throw new Error(
605
+ `${errorPrefix} has a section "${section.title}" with zero fields — drop the section or add ` +
606
+ `fields (or groups) to it.`,
607
+ );
608
+ }
609
+ }
610
+
611
+ function flattenFieldsOrGroups(section: {
612
+ readonly fields: readonly EditFieldSpec[];
613
+ readonly groups?: readonly { readonly fields: readonly EditFieldSpec[] }[];
614
+ }): readonly EditFieldSpec[] {
615
+ return section.groups !== undefined
616
+ ? section.groups.flatMap((group) => group.fields)
617
+ : section.fields;
618
+ }
619
+
584
620
  function validateFormLayoutSections(
585
621
  featureName: string,
586
622
  screenId: string,
@@ -620,13 +656,8 @@ function validateFormLayoutSections(
620
656
  `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
621
657
  );
622
658
  }
623
- if (section.fields.length === 0) {
624
- throw new Error(
625
- `[Feature ${featureName}] Screen "${screenId}" (${context}) has a section "${section.title}" ` +
626
- `with zero fields — drop the section or add fields to it.`,
627
- );
628
- }
629
- for (const fieldSpec of section.fields) {
659
+ validateFieldsXorGroups(`[Feature ${featureName}] Screen "${screenId}" (${context})`, section);
660
+ for (const fieldSpec of flattenFieldsOrGroups(section)) {
630
661
  const normalized = normalizeEditField(fieldSpec);
631
662
  if (!fieldNames.has(normalized.field)) {
632
663
  throw new Error(
@@ -792,6 +823,52 @@ function validateSecretMintConfirm(
792
823
  }
793
824
  }
794
825
 
826
+ // nav.screen is a full QN (cross-feature), so a standalone r.nav() pointing
827
+ // at `targetQn` must be found by scanning every feature's nav entries and actions.
828
+ function hasStandaloneNavEntry(
829
+ targetQn: string,
830
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
831
+ ): boolean {
832
+ for (const f of featureMap.values()) {
833
+ for (const nav of Object.values(f.navs)) {
834
+ if (nav.screen === targetQn) return true;
835
+ if (nav.createAction?.screen === targetQn) return true;
836
+ if (nav.actions?.some((a) => a.screen === targetQn)) return true;
837
+ }
838
+ }
839
+ return false;
840
+ }
841
+
842
+ // Every screen must resolve nav via `nav`, `r.nav()`, a parent list, or
843
+ // `dormant: true`. Skipped when the composed set has no nav entries at all.
844
+ function validateScreenHasNavArea(
845
+ feature: FeatureDefinition,
846
+ screenId: string,
847
+ screen: ScreenDefinition,
848
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
849
+ ): void {
850
+ // skip: the screen already declares its own nav entry.
851
+ if (screen.nav !== undefined) return;
852
+ // skip: explicitly opted out via `dormant: true`.
853
+ if (screen.dormant === true) return;
854
+ const anyNavRegistered = [...featureMap.values()].some((f) => Object.keys(f.navs).length > 0);
855
+ // skip: no nav entries exist anywhere in the composed set — a feature/
856
+ // recipe/test fixture booted without an app shell, not an omission.
857
+ if (!anyNavRegistered) return;
858
+ const targetQn = qualifyEntityName(feature.name, "screen", screenId);
859
+ // skip: a standalone r.nav() elsewhere already points at this screen.
860
+ if (hasStandaloneNavEntry(targetQn, featureMap)) return;
861
+ const allScreens = [...featureMap.values()].flatMap((f) => Object.values(f.screens));
862
+ // skip: resolves to a parent list via listScreenId/rowAction-target/same-entity.
863
+ if (resolveNavParentScreen(allScreens, screen, (s) => s.id) !== undefined) return;
864
+ throw new Error(
865
+ `[Feature ${feature.name}] Screen "${targetQn}" has no nav entry and no resolvable list — ` +
866
+ `declare listScreenId (or a rowAction/toolbarAction navigate target from a list screen), add ` +
867
+ `a standalone r.nav() pointing at it, or set dormant: true if it's intentionally reachable ` +
868
+ `only via a direct link/redirect or a consuming app's own r.nav().`,
869
+ );
870
+ }
871
+
795
872
  export function validateScreens(
796
873
  feature: FeatureDefinition,
797
874
  featureMap: ReadonlyMap<string, FeatureDefinition>,
@@ -816,6 +893,7 @@ export function validateScreens(
816
893
  // #1946) — kurze IDs bleiben same-feature wie zuvor.
817
894
  const navTargetShortIds = screenShortIdsFrom(allScreenQns);
818
895
  for (const [screenId, screen] of Object.entries(feature.screens)) {
896
+ validateScreenHasNavArea(feature, screenId, screen, featureMap);
819
897
  if (screen.type === "custom") {
820
898
  if (!screen.renderer.react && !screen.renderer.native) {
821
899
  throw new Error(
@@ -863,6 +941,8 @@ export function validateScreens(
863
941
  `[Feature ${feature.name}] Screen "${screenId}" (projectionList)`,
864
942
  screen.facets,
865
943
  screen.columns,
944
+ feature.name,
945
+ featureMap,
866
946
  );
867
947
  }
868
948
  if (screen.rowActions !== undefined) {
@@ -1158,8 +1238,26 @@ export function validateScreens(
1158
1238
  `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) relatedList section "${section.title}"`,
1159
1239
  section.facets,
1160
1240
  section.columns,
1241
+ feature.name,
1242
+ featureMap,
1161
1243
  );
1162
1244
  }
1245
+ // Only drawer-kind is validated here — navigate/writeHandler
1246
+ // toolbarActions have no boot check yet, same gap as above.
1247
+ if (section.toolbarActions !== undefined) {
1248
+ for (const action of section.toolbarActions) {
1249
+ if (action.kind === "drawer") {
1250
+ validateDrawerTargetAction(
1251
+ feature.name,
1252
+ screenId,
1253
+ "projectionDetail",
1254
+ "toolbarAction",
1255
+ action,
1256
+ feature.screens,
1257
+ );
1258
+ }
1259
+ }
1260
+ }
1163
1261
  continue;
1164
1262
  }
1165
1263
  if (isWriteFormEditSection(section)) {
@@ -1202,12 +1300,10 @@ export function validateScreens(
1202
1300
  }
1203
1301
  continue;
1204
1302
  }
1205
- if (section.fields.length === 0) {
1206
- throw new Error(
1207
- `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has a section "${section.title}" ` +
1208
- `with zero fields — drop the section or add fields to it.`,
1209
- );
1210
- }
1303
+ validateFieldsXorGroups(
1304
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail)`,
1305
+ section,
1306
+ );
1211
1307
  }
1212
1308
  // Header actions reuse RowAction (the displayed record stands in for
1213
1309
  // the row), so the same navigate/writeHandler existence checks as
@@ -1320,13 +1416,11 @@ export function validateScreens(
1320
1416
  `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
1321
1417
  );
1322
1418
  }
1323
- if (section.fields.length === 0) {
1324
- throw new Error(
1325
- `[Feature ${feature.name}] Screen "${screenId}" (configEdit) has a section "${section.title}" ` +
1326
- `with zero fields — drop the section or add fields to it.`,
1327
- );
1328
- }
1329
- for (const fieldSpec of section.fields) {
1419
+ validateFieldsXorGroups(
1420
+ `[Feature ${feature.name}] Screen "${screenId}" (configEdit)`,
1421
+ section,
1422
+ );
1423
+ for (const fieldSpec of flattenFieldsOrGroups(section)) {
1330
1424
  const normalized = normalizeEditField(fieldSpec);
1331
1425
  if (!fieldNames.has(normalized.field)) {
1332
1426
  throw new Error(
@@ -1739,13 +1833,11 @@ export function validateScreens(
1739
1833
  `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
1740
1834
  );
1741
1835
  }
1742
- if (section.fields.length === 0) {
1743
- throw new Error(
1744
- `[Feature ${feature.name}] Screen "${screenId}" (entityEdit) has a section "${section.title}" ` +
1745
- `with zero fields — drop the section or add fields to it.`,
1746
- );
1747
- }
1748
- for (const fieldSpec of section.fields) {
1836
+ validateFieldsXorGroups(
1837
+ `[Feature ${feature.name}] Screen "${screenId}" (entityEdit)`,
1838
+ section,
1839
+ );
1840
+ for (const fieldSpec of flattenFieldsOrGroups(section)) {
1749
1841
  const normalized = normalizeEditField(fieldSpec);
1750
1842
  if (!fieldNames.has(normalized.field)) {
1751
1843
  throw new Error(
@@ -2052,10 +2144,25 @@ export function validateColumnRendererForm(
2052
2144
  // typo (the user never sees the field anywhere), so this is hard-checked
2053
2145
  // rather than just documented. Shared by projectionList and relatedList
2054
2146
  // (fw#2740) — `prefix` carries the caller's own screen/section message lead-in.
2147
+ //
2148
+ // `type: "reference"` is exempt from the same-name-column rule (fw akte-
2149
+ // bedienkonzept-2): it filters by an id field (e.g. "propertyId") but
2150
+ // displays a different, human-readable column (e.g. "propertyLabel") — a
2151
+ // column named after the filter field would almost never exist, and
2152
+ // requiring one anyway would force apps to add a column no design calls
2153
+ // for just to satisfy this check. Its own `entity` is mandatory on the type
2154
+ // (TS, not just this validator) and is resolved below — that explicit,
2155
+ // checked declaration is this facet type's field inventory, filling the
2156
+ // same "not just a typo" role the column-name check plays for select/
2157
+ // boolean facets. select/boolean facets keep the column requirement
2158
+ // unchanged: both filter and display the same field, so a facet with no
2159
+ // matching column is still almost always a typo.
2055
2160
  function validateListFacets(
2056
2161
  prefix: string,
2057
2162
  facets: readonly ListFacetSpec[],
2058
2163
  columns: readonly ListColumnSpec[],
2164
+ currentFeatureName: string,
2165
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
2059
2166
  ): void {
2060
2167
  const columnFieldNames = new Set(columns.map((col) => normalizeListColumn(col).field));
2061
2168
  const seenFacetFields = new Set<string>();
@@ -2064,7 +2171,7 @@ function validateListFacets(
2064
2171
  throw new Error(`${prefix} declares facet "${facet.field}" more than once.`);
2065
2172
  }
2066
2173
  seenFacetFields.add(facet.field);
2067
- if (!columnFieldNames.has(facet.field)) {
2174
+ if (facet.type !== "reference" && !columnFieldNames.has(facet.field)) {
2068
2175
  throw new Error(
2069
2176
  `${prefix} facet references field "${facet.field}" which is not a declared column. ` +
2070
2177
  `Known columns: ${[...columnFieldNames].sort().join(", ")}`,
@@ -2076,6 +2183,22 @@ function validateListFacets(
2076
2183
  `declare at least one option.`,
2077
2184
  );
2078
2185
  }
2186
+ if (facet.type === "reference") {
2187
+ const target = parseRefTarget(facet.entity, currentFeatureName);
2188
+ const targetFeature = featureMap.get(target.featureName);
2189
+ if (targetFeature?.entities?.[target.entityName] === undefined) {
2190
+ throw new Error(
2191
+ `${prefix} facet "${facet.field}" (type "reference") targets entity "${facet.entity}", ` +
2192
+ `which does not resolve to a registered entity. Known entities in feature ` +
2193
+ `"${target.featureName}": ` +
2194
+ `${
2195
+ Object.keys(targetFeature?.entities ?? {})
2196
+ .sort()
2197
+ .join(", ") || "(none)"
2198
+ }.`,
2199
+ );
2200
+ }
2201
+ }
2079
2202
  }
2080
2203
  }
2081
2204
 
@@ -498,9 +498,6 @@ function minMaskOrder(keys: readonly MaskedKey[]): number {
498
498
  // opt-int, und zeigt user-scope (write `all`) jedem. `all` lässt sich in
499
499
  // AccessRule nur als openToAll ausdrücken; der Write bleibt server-seitig
500
500
  // per Key gegated.
501
- const ALL_ROLE_OPEN_TO_ALL_REASON =
502
- 'config key declares the "all" role, so every signed-in user of the tenant may read/write it';
503
-
504
501
  // Union der Navs-Access-Regeln: ein openToAll-Nav öffnet das ganze Gate, sonst
505
502
  // die Vereinigung der Rollen. undefined-access-Navs tragen nichts bei.
506
503
  function unionAccessRules(rules: readonly (AccessRule | undefined)[]): AccessRule {
@@ -519,6 +516,12 @@ function unionAccessRules(rules: readonly (AccessRule | undefined)[]): AccessRul
519
516
  // `all` lässt sich in AccessRule nur als openToAll ausdrücken; der Write bleibt
520
517
  // server-seitig per Key gegated.
521
518
  function rolesToAccess(roles: readonly string[]): AccessRule {
522
- if (roles.includes("all")) return { openToAll: { reason: ALL_ROLE_OPEN_TO_ALL_REASON } };
519
+ if (roles.includes("all"))
520
+ return {
521
+ openToAll: {
522
+ reason:
523
+ 'config key declares the "all" role, so every signed-in user of the tenant may read/write it',
524
+ },
525
+ };
523
526
  return { roles: [...new Set(roles)] };
524
527
  }
@@ -8,7 +8,8 @@ import {
8
8
  } from "../db/eagerload";
9
9
  import { createEventStoreExecutor, type EventStoreExecutor } from "../db/event-store-executor";
10
10
  import { buildEntityTable, type EntityTable } from "../db/table-builder";
11
- import { createTenantDb, type TenantDb } from "../db/tenant-db";
11
+ import { acknowledgeConventionCrossTenant, type TenantDb } from "../db/tenant-db";
12
+ import { isSystemIdentity } from "../pipeline/system-identity-switch";
12
13
  import { assertUnreachable } from "../utils";
13
14
  import { PAGED_QUERY_HANDLER_BRAND } from "./define-handler";
14
15
  import { buildInsertSchema, buildUpdateSchema } from "./schema-builder";
@@ -16,6 +17,7 @@ import type {
16
17
  AccessRule,
17
18
  DeriveContext,
18
19
  EntityDefinition,
20
+ EscapeHatchDeclaration,
19
21
  HandlerContext,
20
22
  QueryHandlerDef,
21
23
  SessionUser,
@@ -107,6 +109,33 @@ type ListPayload = {
107
109
 
108
110
  const idSchema = z.object({ id: z.uuid() });
109
111
 
112
+ // Keyed by handler function: the registrar rebuilds the def object but keeps the function reference.
113
+ const deprecatedCrossTenantHandlers = new WeakSet<object>();
114
+
115
+ export function isDeprecatedCrossTenantHandler(handler: object): boolean {
116
+ return deprecatedCrossTenantHandlers.has(handler);
117
+ }
118
+
119
+ export const DEPRECATED_CROSS_TENANT_SIGNAL = "deprecation:entity-handler-cross-tenant";
120
+
121
+ function resolveCrossTenantReason(name: string, options: EntityHandlerOptions): string | undefined {
122
+ if (options.escapeHatch !== undefined && options.crossTenant === true) {
123
+ throw new Error(
124
+ `"${name}": declare either escapeHatch or the deprecated crossTenant, not both.`,
125
+ );
126
+ }
127
+ if (options.escapeHatch !== undefined) {
128
+ if (options.escapeHatch.reason.trim().length === 0) {
129
+ throw new Error(`"${name}": escapeHatch requires a non-empty reason.`);
130
+ }
131
+ return options.escapeHatch.reason;
132
+ }
133
+ if (options.crossTenant === true) {
134
+ return `deprecated crossTenant: true on entity convention handler ${name}`;
135
+ }
136
+ return undefined;
137
+ }
138
+
110
139
  // Upper bound on entity-list `limit`: unbounded/fractional values ran
111
140
  // straight into the raw `LIMIT ${limit}` SQL string (event-store-executor-
112
141
  // read.ts), letting a client demand a full-table materialisation or trip a
@@ -194,10 +223,10 @@ export function defineEntityWriteHandler(
194
223
  // behavior-preserving: ctx.db was already an unfiltered "system"-mode
195
224
  // TenantDb for these handlers before the ctx.db cutover.
196
225
  //
197
- // options.crossTenant mirrors the query-handler branch below: this ONE
198
- // write handler reads/writes across every tenant without making the
199
- // whole feature r.systemScope().
200
- const crossTenant = options?.crossTenant === true;
226
+ // crossTenantReason (escapeHatch or the deprecated crossTenant) mirrors the
227
+ // query-handler branch below: this ONE write handler reads/writes across
228
+ // every tenant without making the whole feature r.systemScope().
229
+ const crossTenantReason = resolveCrossTenantReason(name, options);
201
230
 
202
231
  const dbFor = (ctx: HandlerContext): TenantDb => {
203
232
  if (ctx.systemDb) {
@@ -205,7 +234,9 @@ export function defineEntityWriteHandler(
205
234
  `entity convention handler for r.systemScope() feature (${name})`,
206
235
  );
207
236
  }
208
- return crossTenant ? createTenantDb(ctx.db.raw, ctx.db.tenantId, "system") : ctx.db;
237
+ return crossTenantReason !== undefined
238
+ ? acknowledgeConventionCrossTenant(ctx.db, crossTenantReason)
239
+ : ctx.db;
209
240
  };
210
241
 
211
242
  // The event stream is keyed by the acting user's tenantId (streamTenantFor in
@@ -219,7 +250,7 @@ export function defineEntityWriteHandler(
219
250
  id: unknown,
220
251
  db: TenantDb,
221
252
  ): Promise<SessionUser> => {
222
- if (!crossTenant || entity.systemStream) return user;
253
+ if (crossTenantReason === undefined || entity.systemStream) return user;
223
254
  const row = await db.fetchOne<Record<string, unknown>>(table, { id });
224
255
  const rowTenantId = row?.["tenantId"];
225
256
  if (typeof rowTenantId !== "string" || rowTenantId === user.tenantId) return user;
@@ -234,7 +265,14 @@ export function defineEntityWriteHandler(
234
265
  schema = buildInsertSchema(entity);
235
266
  handler = async (event, ctx) => {
236
267
  const { runPreSave } = ctx;
237
- return executor.create(event.payload as DbRow, event.user, dbFor(ctx), {
268
+ // A caller-chosen id (deterministic-id idempotent creates) is only
269
+ // honored for a system-identity caller (jobs/hooks via
270
+ // createSystemUser) — an HTTP-authenticated end user can never
271
+ // present that identity, so their payload.id is dropped exactly as
272
+ // before this field became part of the schema.
273
+ const { id: _callerId, ...payloadWithoutId } = event.payload as DbRow;
274
+ const payload = isSystemIdentity(event.user) ? (event.payload as DbRow) : payloadWithoutId;
275
+ return executor.create(payload, event.user, dbFor(ctx), {
238
276
  preSave:
239
277
  runPreSave &&
240
278
  ((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
@@ -288,6 +326,9 @@ export function defineEntityWriteHandler(
288
326
  assertUnreachable(verb, "write verb");
289
327
  }
290
328
 
329
+ if (options.crossTenant === true) deprecatedCrossTenantHandlers.add(handler);
330
+
331
+ // escapeHatch stays off the def: WriteHandlerDef.escapeHatch also grants unsafeRaw, global writes, identity switches.
291
332
  return {
292
333
  name,
293
334
  schema,
@@ -340,21 +381,22 @@ export function defineEntityQueryHandler(
340
381
  // useReferenceLookup stays as a fallback (for apps that write custom
341
382
  // handlers by hand without this wrapper).
342
383
  const hasRefFields = collectReferenceFields(entity).length > 0;
384
+ const crossTenantReason = resolveCrossTenantReason(name, options);
343
385
 
344
386
  // Preference order:
345
387
  // 1. ctx.systemDb — the feature declared r.systemScope() (whole-feature
346
388
  // cutover, dispatch-shared.ts), so ctx.db is fail-closed. This is
347
389
  // behavior-preserving: ctx.db was already an unfiltered "system"-mode
348
390
  // TenantDb for these handlers before that cutover.
349
- // 2. options.crossTenant — this ONE handler reads across every tenant
350
- // (e.g. a SystemAdmin-only operator inspector) without making the
351
- // whole feature r.systemScope() — that would drop tenant isolation
352
- // from every OTHER handler the feature registers too. The executor's
353
- // list()/detail() only add a tenant filter when db.mode === "tenant"
354
- // (see event-store-executor.ts), so handing them a "system"-mode
355
- // TenantDb built from the same raw connection is enough to skip it —
356
- // access-control (who may call this handler at all) is unaffected,
357
- // still gated by `options.access`.
391
+ // 2. crossTenantReason (escapeHatch or the deprecated crossTenant) — this
392
+ // ONE handler reads across every tenant (e.g. a SystemAdmin-only
393
+ // operator inspector) without making the whole feature r.systemScope()
394
+ // — that would drop tenant isolation from every OTHER handler the
395
+ // feature registers too. The executor's list()/detail() only add a
396
+ // tenant filter when db.mode === "tenant" (see event-store-executor.ts),
397
+ // so handing them a "system"-mode TenantDb built from the same raw
398
+ // connection is enough to skip it — access-control (who may call this
399
+ // handler at all) is unaffected, still gated by `options.access`.
358
400
  // 3. plain ctx.db — the common case, tenant-filtered.
359
401
  const dbFor = (ctx: HandlerContext): TenantDb => {
360
402
  if (ctx.systemDb) {
@@ -362,7 +404,9 @@ export function defineEntityQueryHandler(
362
404
  `entity convention handler for r.systemScope() feature (${entityName}:${verb})`,
363
405
  );
364
406
  }
365
- return options?.crossTenant ? createTenantDb(ctx.db.raw, ctx.db.tenantId, "system") : ctx.db;
407
+ return crossTenantReason !== undefined
408
+ ? acknowledgeConventionCrossTenant(ctx.db, crossTenantReason)
409
+ : ctx.db;
366
410
  };
367
411
 
368
412
  switch (verb) {
@@ -418,6 +462,9 @@ export function defineEntityQueryHandler(
418
462
  assertUnreachable(verb, "query verb");
419
463
  }
420
464
 
465
+ if (options.crossTenant === true) deprecatedCrossTenantHandlers.add(handler);
466
+
467
+ // escapeHatch stays off the def: QueryHandlerDef.escapeHatch also grants unsafeRaw and identity switches.
421
468
  return {
422
469
  name,
423
470
  schema,
@@ -543,7 +590,11 @@ export function createEntityExecutor(
543
590
  export function defineProjectionQueryHandler(
544
591
  name: string,
545
592
  projectionQualifiedName: string,
546
- options: { access: AccessRule; unsafeAllTenants?: boolean },
593
+ options: {
594
+ access: AccessRule;
595
+ unsafeAllTenants?: boolean;
596
+ escapeHatch?: EscapeHatchDeclaration;
597
+ },
547
598
  ): QueryHandlerDef {
548
599
  return {
549
600
  name,
@@ -558,6 +609,7 @@ export function defineProjectionQueryHandler(
558
609
  options.unsafeAllTenants ? { unsafeAllTenants: true } : undefined,
559
610
  ), // @wrapper-known semantic-alias
560
611
  access: options.access,
612
+ ...(options.escapeHatch && { escapeHatch: options.escapeHatch }),
561
613
  };
562
614
  }
563
615