@cosmicdrift/kumiko-framework 0.146.3 → 0.147.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 (68) hide show
  1. package/package.json +6 -2
  2. package/src/api/auth-routes.ts +32 -67
  3. package/src/api/routes.ts +1 -3
  4. package/src/bun-db/__tests__/PATTERN.md +0 -1
  5. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
  6. package/src/db/__tests__/schema-inspection.test.ts +7 -0
  7. package/src/db/event-store-executor-context.ts +398 -0
  8. package/src/db/event-store-executor-read.ts +276 -0
  9. package/src/db/event-store-executor-write.ts +615 -0
  10. package/src/db/event-store-executor.ts +29 -1166
  11. package/src/db/queries/shadow-swap.ts +3 -1
  12. package/src/db/schema-inspection.ts +1 -1
  13. package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
  14. package/src/engine/__tests__/engine.test.ts +45 -1
  15. package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
  16. package/src/engine/__tests__/membership-roles.test.ts +4 -10
  17. package/src/engine/__tests__/screen.test.ts +26 -0
  18. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  19. package/src/engine/boot-validator/gdpr-storage.ts +1 -1
  20. package/src/engine/boot-validator/index.ts +12 -8
  21. package/src/engine/boot-validator/nav.ts +120 -0
  22. package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
  23. package/src/engine/boot-validator/workspaces.ts +68 -0
  24. package/src/engine/define-feature.ts +77 -1011
  25. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
  26. package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
  27. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
  28. package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
  29. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
  30. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
  31. package/src/engine/feature-ast/extractors/round1.ts +3 -3
  32. package/src/engine/feature-ast/extractors/round4.ts +45 -10
  33. package/src/engine/feature-ast/extractors/shared.ts +64 -6
  34. package/src/engine/feature-ast/parse.ts +123 -10
  35. package/src/engine/feature-ast/patterns.ts +14 -7
  36. package/src/engine/feature-ast/render.ts +28 -7
  37. package/src/engine/feature-builder-state.ts +165 -0
  38. package/src/engine/feature-config-events-jobs.ts +303 -0
  39. package/src/engine/feature-entity-handlers.ts +161 -0
  40. package/src/engine/feature-ui-extensions.ts +413 -0
  41. package/src/engine/index.ts +0 -2
  42. package/src/engine/pattern-library/library.ts +44 -1131
  43. package/src/engine/pattern-library/mixed-schemas.ts +484 -0
  44. package/src/engine/pattern-library/opaque-schemas.ts +124 -0
  45. package/src/engine/pattern-library/shared-fields.ts +80 -0
  46. package/src/engine/pattern-library/static-schemas.ts +456 -0
  47. package/src/engine/registry-facade.ts +349 -0
  48. package/src/engine/registry-ingest.ts +473 -0
  49. package/src/engine/registry-state.ts +388 -0
  50. package/src/engine/registry-validate.ts +660 -0
  51. package/src/engine/registry.ts +79 -1695
  52. package/src/engine/types/screen.ts +4 -2
  53. package/src/engine/types/tree-node.ts +2 -5
  54. package/src/event-store/snapshot.ts +7 -7
  55. package/src/i18n/required-surface-keys.ts +2 -0
  56. package/src/jobs/job-runner.ts +1 -1
  57. package/src/observability/standard-metrics.ts +4 -3
  58. package/src/pipeline/dispatch-batch.ts +3 -3
  59. package/src/pipeline/dispatch-shared.ts +17 -10
  60. package/src/pipeline/event-dispatcher-admin.ts +289 -0
  61. package/src/pipeline/event-dispatcher-delivery.ts +264 -0
  62. package/src/pipeline/event-dispatcher.ts +28 -540
  63. package/src/pipeline/projection-rebuild.ts +1 -1
  64. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
  65. package/src/stack/test-stack.ts +46 -1
  66. package/src/testing/boot-validator-fixture.ts +1 -2
  67. package/src/engine/__tests__/deep-link.test.ts +0 -35
  68. package/src/engine/deep-link.ts +0 -23
@@ -0,0 +1,473 @@
1
+ import { resolveTableName } from "../db/entity-table-meta";
2
+ import { buildMetricName, validateMetricName } from "../observability";
3
+ import type { RegistryState } from "./registry-state";
4
+ import { mergeHookList, mergeHookListQualified, qualify } from "./registry-state";
5
+ import type { FeatureDefinition } from "./types";
6
+
7
+ // Feature registration + entities (globally-unique, physical-table-checked) + relations
8
+ // (additive per entity, duplicate-per-name guarded).
9
+ export function populateFeatureCore(state: RegistryState, feature: FeatureDefinition): void {
10
+ if (state.featureMap.has(feature.name)) {
11
+ throw new Error(`Duplicate feature: "${feature.name}"`);
12
+ }
13
+ state.featureMap.set(feature.name, feature);
14
+
15
+ // Entities: NOT prefixed — entity names must be globally unique
16
+ for (const [name, entity] of Object.entries(feature.entities ?? {})) {
17
+ if (state.entityMap.has(name)) {
18
+ throw new Error(`Duplicate entity: "${name}" (registered by multiple features)`);
19
+ }
20
+ state.entityMap.set(name, entity);
21
+ const physical = resolveTableName(name, entity, feature.name);
22
+ const clash = state.physicalTableOwners.get(physical);
23
+ if (clash?.kind === "unmanaged") {
24
+ throw new Error(
25
+ `Entity "${name}" (feature "${feature.name}") has physical table "${physical}" which ` +
26
+ `collides with r.unmanagedTable("${physical}") (feature "${clash.featureName}"). ` +
27
+ `Pick a different tableName — both would emit CREATE TABLE "${physical}".`,
28
+ );
29
+ }
30
+ // Entity-vs-entity ist genauso fatal: zwei Entities mit explizitem,
31
+ // identischem tableName überschrieben sich hier vorher still —
32
+ // doppeltes CREATE TABLE bzw. eine Projektion frisst die andere.
33
+ if (clash?.kind === "entity") {
34
+ throw new Error(
35
+ `Entity "${name}" (feature "${feature.name}") has physical table "${physical}" which ` +
36
+ `collides with entity "${clash.owner}" (feature "${clash.featureName}"). ` +
37
+ `Pick a different tableName — both would project into "${physical}".`,
38
+ );
39
+ }
40
+ state.physicalTableOwners.set(physical, {
41
+ kind: "entity",
42
+ owner: name,
43
+ featureName: feature.name,
44
+ });
45
+ }
46
+
47
+ // Relations: entityName (not prefixed)
48
+ for (const [entityName, rels] of Object.entries(feature.relations ?? {})) {
49
+ const existing = state.relationMap.get(entityName) ?? {};
50
+ for (const [relName, relDef] of Object.entries(rels)) {
51
+ if (existing[relName]) {
52
+ throw new Error(
53
+ `Duplicate relation: "${entityName}.${relName}" (registered by multiple features)`,
54
+ );
55
+ }
56
+ existing[relName] = relDef;
57
+ }
58
+ state.relationMap.set(entityName, existing);
59
+ }
60
+ }
61
+
62
+ // Write + query handlers: qualified scope:type:name, duplicate-guarded.
63
+ export function populateHandlers(state: RegistryState, feature: FeatureDefinition): void {
64
+ // Write handlers: scope:write:name
65
+ for (const [name, handler] of Object.entries(feature.writeHandlers ?? {})) {
66
+ const qualified = qualify(feature.name, "write", name);
67
+ if (state.writeHandlerMap.has(qualified)) {
68
+ throw new Error(`Duplicate write handler: "${qualified}" (registered by multiple features)`);
69
+ }
70
+ state.writeHandlerMap.set(qualified, { ...handler, name: qualified });
71
+ state.handlerFeatureMap.set(qualified, feature.name);
72
+ }
73
+
74
+ // Query handlers: scope:query:name
75
+ for (const [name, handler] of Object.entries(feature.queryHandlers ?? {})) {
76
+ const qualified = qualify(feature.name, "query", name);
77
+ if (state.queryHandlerMap.has(qualified)) {
78
+ throw new Error(`Duplicate query handler: "${qualified}" (registered by multiple features)`);
79
+ }
80
+ state.queryHandlerMap.set(qualified, { ...handler, name: qualified });
81
+ state.handlerFeatureMap.set(qualified, feature.name);
82
+ }
83
+ }
84
+
85
+ // Config keys: scope:config:name, duplicate-guarded.
86
+ export function populateConfigKeys(state: RegistryState, feature: FeatureDefinition): void {
87
+ // Config keys: scope:config:name
88
+ for (const [key, keyDef] of Object.entries(feature.configKeys ?? {})) {
89
+ const qualifiedKey = qualify(feature.name, "config", key);
90
+ if (state.configKeyMap.has(qualifiedKey)) {
91
+ throw new Error(`Duplicate config key: "${qualifiedKey}" (registered by multiple features)`);
92
+ }
93
+ state.configKeyMap.set(qualifiedKey, keyDef);
94
+ }
95
+ }
96
+
97
+ // Jobs (runIn-pinned, duplicate-guarded) + notifications (trigger resolved later).
98
+ export function populateJobsAndNotifications(
99
+ state: RegistryState,
100
+ feature: FeatureDefinition,
101
+ ): void {
102
+ // Jobs: scope:job:name
103
+ for (const [name, jobDef] of Object.entries(feature.jobs ?? {})) {
104
+ const qualifiedName = qualify(feature.name, "job", name);
105
+ if (state.jobMap.has(qualifiedName)) {
106
+ throw new Error(`Duplicate job: "${qualifiedName}" (registered by multiple features)`);
107
+ }
108
+ // runIn runtime-check. TS's JobRunIn = Exclude<RunIn, "both"> already
109
+ // rejects "both" at compile time, but dynamically-constructed jobs
110
+ // (serialized config, plugin authors using `as any`) could slip it
111
+ // past the type system. Fail loud — "both" for jobs would mean "fan
112
+ // out to both lane-queues", which over-delivers; the routing assumes
113
+ // exactly one target queue per dispatch.
114
+ // @cast-boundary schema-walk — defensive runtime-check against bypassed type-system
115
+ const runIn = (jobDef as { runIn?: unknown }).runIn;
116
+ if (runIn !== undefined && runIn !== "api" && runIn !== "worker") {
117
+ throw new Error(
118
+ `Invalid runIn "${String(runIn)}" on job "${qualifiedName}" — jobs must be pinned to a single lane ("api" or "worker"). "both" is not allowed because BullMQ queues are lane-scoped.`,
119
+ );
120
+ }
121
+ state.jobMap.set(qualifiedName, { ...jobDef, name: qualifiedName });
122
+ }
123
+
124
+ // Notifications: scope:notify:name
125
+ for (const [name, notifDef] of Object.entries(feature.notifications ?? {})) {
126
+ const qualifiedName = qualify(feature.name, "notify", name);
127
+ state.notificationMap.set(qualifiedName, {
128
+ ...notifDef,
129
+ name: qualifiedName,
130
+ trigger: { on: notifDef.trigger.on },
131
+ });
132
+ state.notificationFeatureMap.set(qualifiedName, feature.name);
133
+ }
134
+ }
135
+
136
+ // Events: scope:event:name. Upcaster chains stitched after full ingest (see validateEventUpcasters).
137
+ export function populateEvents(state: RegistryState, feature: FeatureDefinition): void {
138
+ // Events: scope:event:name. Migrations stay keyed by feature+short-name
139
+ // in the FeatureDefinition and get stitched into the state.eventUpcasterMap
140
+ // below (after ALL features are ingested) so cross-feature validation has
141
+ // the complete picture.
142
+ for (const [eventName, eventDef] of Object.entries(feature.events ?? {})) {
143
+ const qualified = qualify(feature.name, "event", eventName);
144
+ state.eventMap.set(qualified, { ...eventDef, name: qualified });
145
+ }
146
+ }
147
+
148
+ // Translations prefixed with featureName: (i18next namespace convention).
149
+ export function populateTranslations(state: RegistryState, feature: FeatureDefinition): void {
150
+ // Translations prefixed with featureName: (i18next namespace convention)
151
+ for (const [key, value] of Object.entries(feature.translations ?? {})) {
152
+ state.mergedTranslations[`${feature.name}:${key}`] = value;
153
+ }
154
+ }
155
+
156
+ // Lifecycle hooks (handler-targeted, qualified) + entity hooks (entity-targeted,
157
+ // unprefixed) + search-payload-extensions (additive per entity).
158
+ export function populateHooks(state: RegistryState, feature: FeatureDefinition): void {
159
+ // Lifecycle hooks: keyed by handler QN. featureName rides along on each
160
+ // hook entry — defineFeature sets it, the registry just appends.
161
+ // Save/delete hooks target write handlers, query hooks target query handlers.
162
+ mergeHookListQualified(state.preSaveHooks, feature.hooks?.preSave, feature.name, "write");
163
+ mergeHookListQualified(state.postSaveHooks, feature.hooks?.postSave, feature.name, "write");
164
+ mergeHookListQualified(state.preDeleteHooks, feature.hooks?.preDelete, feature.name, "write");
165
+ mergeHookListQualified(state.postDeleteHooks, feature.hooks?.postDelete, feature.name, "write");
166
+ mergeHookListQualified(state.preQueryHooks, feature.hooks?.preQuery, feature.name, "query");
167
+ mergeHookListQualified(state.postQueryHooks, feature.hooks?.postQuery, feature.name, "query");
168
+
169
+ // Entity hooks: NOT prefixed, keyed by entity name
170
+ mergeHookList(state.entityPostSaveHooks, feature.entityHooks?.postSave);
171
+ mergeHookList(state.entityPreDeleteHooks, feature.entityHooks?.preDelete);
172
+ mergeHookList(state.entityPostDeleteHooks, feature.entityHooks?.postDelete);
173
+ mergeHookList(state.entityPostQueryHooks, feature.entityHooks?.postQuery);
174
+
175
+ // F3 search-payload-extensions: per-entity contributors merged additively
176
+ for (const [entityName, contributors] of Object.entries(feature.searchPayloadExtensions ?? {})) {
177
+ const existing = state.searchPayloadExtensions.get(entityName) ?? [];
178
+ for (const c of contributors) existing.push(c);
179
+ state.searchPayloadExtensions.set(entityName, existing);
180
+ }
181
+ }
182
+
183
+ // Registrar extension definitions + usages + selectors + reference-data + config-seeds.
184
+ export function populateExtensionsAndSeeds(state: RegistryState, feature: FeatureDefinition): void {
185
+ // Registrar extensions: collect definitions and usages
186
+ for (const [extName, extDef] of Object.entries(feature.registrarExtensions ?? {})) {
187
+ if (state.extensionMap.has(extName)) {
188
+ throw new Error(
189
+ `Duplicate registrar extension: "${extName}" (registered by multiple features)`,
190
+ );
191
+ }
192
+ state.extensionMap.set(extName, extDef);
193
+ }
194
+ // Annotate the owner so consumers (readiness gating) can map a
195
+ // registration back to the feature's config keys + secrets.
196
+ state.extensionUsages.push(
197
+ ...(feature.extensionUsages ?? []).map((u) => ({ ...u, featureName: feature.name })),
198
+ );
199
+ for (const sel of feature.extensionSelectors ?? []) {
200
+ if (state.extensionSelectorMap.has(sel.extensionName)) {
201
+ throw new Error(
202
+ `Duplicate extension selector for "${sel.extensionName}" ` +
203
+ `(feature "${feature.name}") — one owning feature declares the selector.`,
204
+ );
205
+ }
206
+ state.extensionSelectorMap.set(sel.extensionName, sel.qualifiedKey);
207
+ }
208
+ state.allReferenceData.push(...(feature.referenceData ?? []));
209
+ state.allConfigSeeds.push(...(feature.configSeeds ?? []));
210
+ }
211
+
212
+ // Metrics (name-validated, globally-unique) + secret keys (already qualified).
213
+ export function populateMetricsAndSecrets(state: RegistryState, feature: FeatureDefinition): void {
214
+ // Metrics: validate + qualify per feature. Collisions across features are
215
+ // rejected here — two features can't both register "created_total" under
216
+ // different shapes (labels/type) because the resulting fully qualified
217
+ // names differ, but same short+feature combo would already fail in
218
+ // defineFeature. This loop catches cross-feature/extension edge cases.
219
+ for (const [shortName, def] of Object.entries(feature.metrics ?? {})) {
220
+ const fullName = buildMetricName(feature.name, shortName);
221
+ validateMetricName(fullName, def.type);
222
+ if (state.metricMap.has(fullName)) {
223
+ throw new Error(
224
+ `[Kumiko Observability] Metric "${fullName}" registered multiple times ` +
225
+ `(Feature: ${feature.name}). Metric names must be globally unique.`,
226
+ );
227
+ }
228
+ state.metricMap.set(fullName, { ...def, featureName: feature.name });
229
+ }
230
+
231
+ // Secret keys: already qualified during defineFeature (same "<feature>:<short>"
232
+ // convention used elsewhere). Reject cross-feature duplicates — extensions
233
+ // could theoretically register on another feature's namespace.
234
+ for (const def of Object.values(feature.secretKeys ?? {})) {
235
+ if (state.secretKeyMap.has(def.qualifiedName)) {
236
+ throw new Error(
237
+ `[Kumiko Secrets] Secret key "${def.qualifiedName}" registered multiple times. ` +
238
+ "Secret names must be globally unique across features.",
239
+ );
240
+ }
241
+ state.secretKeyMap.set(def.qualifiedName, def);
242
+ }
243
+ }
244
+
245
+ // Explicit + multi-stream projections (source-entity indexed) + raw tables +
246
+ // unmanaged tables (both cross-feature-uniqueness-by-physical-name guarded).
247
+ export function populateProjectionsAndTables(
248
+ state: RegistryState,
249
+ feature: FeatureDefinition,
250
+ ): void {
251
+ // Projections: qualified by feature name. Build the source-entity index so
252
+ // the event-store-executor can fetch matching projections in O(1) per write.
253
+ for (const [projName, projDef] of Object.entries(feature.projections ?? {})) {
254
+ const qualified = qualify(feature.name, "projection", projName);
255
+ if (state.projectionMap.has(qualified)) {
256
+ throw new Error(`Duplicate projection: "${qualified}" (registered by multiple features)`);
257
+ }
258
+ const stored = { ...projDef, name: qualified };
259
+ state.projectionMap.set(qualified, stored);
260
+ const sources = Array.isArray(projDef.source) ? projDef.source : [projDef.source];
261
+ for (const src of sources) {
262
+ const existing = state.projectionsBySource.get(src) ?? [];
263
+ existing.push(stored);
264
+ state.projectionsBySource.set(src, existing);
265
+ }
266
+ }
267
+
268
+ // Multi-stream projections: qualified + stored for later wiring into
269
+ // event-dispatcher. Namespace is shared with single-stream projections —
270
+ // defineFeature already catches name collisions inside one feature, but
271
+ // we also guard the cross-feature case here.
272
+ for (const [mspName, mspDef] of Object.entries(feature.multiStreamProjections ?? {})) {
273
+ const qualified = qualify(feature.name, "projection", mspName);
274
+ if (state.projectionMap.has(qualified) || state.multiStreamProjectionMap.has(qualified)) {
275
+ throw new Error(`Duplicate projection: "${qualified}" (registered by multiple features)`);
276
+ }
277
+ // runIn runtime-check. TS's RunIn union already enforces the three
278
+ // values at compile time; this guards dynamically-constructed MSPs
279
+ // (config-driven, plugin authors) that could slip a typo through.
280
+ // @cast-boundary schema-walk — defensive runtime-check against bypassed type-system
281
+ const mspRunIn = (mspDef as { runIn?: unknown }).runIn;
282
+ if (
283
+ mspRunIn !== undefined &&
284
+ mspRunIn !== "api" &&
285
+ mspRunIn !== "worker" &&
286
+ mspRunIn !== "both"
287
+ ) {
288
+ throw new Error(
289
+ `Invalid runIn "${String(mspRunIn)}" on MSP "${qualified}" — must be "api", "worker", or "both".`,
290
+ );
291
+ }
292
+ state.multiStreamProjectionMap.set(qualified, { ...mspDef, name: qualified });
293
+ state.multiStreamProjectionFeatureMap.set(qualified, feature.name);
294
+ }
295
+
296
+ // Raw tables: aggregated by feature-local short name (unprefixed —
297
+ // these bypass the qualified-name namespace because they have no
298
+ // event-stream binding to disambiguate). Reject cross-feature
299
+ // duplicates at boot so the dev-server doesn't race two CREATE TABLE
300
+ // statements that target the same physical table name.
301
+ for (const [rawName, rawDef] of Object.entries(feature.rawTables ?? {})) {
302
+ const existing = state.rawTableMap.get(rawName);
303
+ if (existing) {
304
+ throw new Error(
305
+ `Raw-table "${rawName}" registered by both feature "${existing.featureName}" and ` +
306
+ `"${feature.name}". Pick a feature-prefixed name to disambiguate.`,
307
+ );
308
+ }
309
+ state.rawTableMap.set(rawName, { ...rawDef, featureName: feature.name });
310
+ }
311
+
312
+ // Unmanaged tables — same cross-feature uniqueness invariant as rawTables.
313
+ // Two features registering the same physical tableName would race two
314
+ // CREATE TABLE statements via migrate-runner.
315
+ for (const [umName, umDef] of Object.entries(feature.unmanagedTables ?? {})) {
316
+ const existing = state.unmanagedTableMap.get(umName);
317
+ if (existing) {
318
+ throw new Error(
319
+ `Unmanaged-table "${umName}" registered by both feature "${existing.featureName}" and ` +
320
+ `"${feature.name}". Pick a feature-prefixed tableName to disambiguate.`,
321
+ );
322
+ }
323
+ const physicalClash = state.physicalTableOwners.get(umName);
324
+ if (physicalClash?.kind === "entity") {
325
+ throw new Error(
326
+ `Unmanaged-table "${umName}" (feature "${feature.name}") collides with the physical ` +
327
+ `table of entity "${physicalClash.owner}" (feature "${physicalClash.featureName}"). ` +
328
+ `Pick a different tableName — both would emit CREATE TABLE "${umName}".`,
329
+ );
330
+ }
331
+ const piiFields = umDef.meta.piiSubjectFields ?? [];
332
+ if (piiFields.length > 0 && !umDef.piiEncryptedOnWrite) {
333
+ throw new Error(
334
+ `Unmanaged-table "${umName}" (feature "${feature.name}") has PII-annotated fields ` +
335
+ `(${piiFields.join(", ")}) but direct writes bypass the executor's PII encryption. ` +
336
+ `Encrypt those fields before every insert/update (encryptPiiFieldValues) and declare ` +
337
+ `{ piiEncryptedOnWrite: true }, or drop the subject annotations.`,
338
+ );
339
+ }
340
+ state.physicalTableOwners.set(umName, {
341
+ kind: "unmanaged",
342
+ owner: umName,
343
+ featureName: feature.name,
344
+ });
345
+ state.unmanagedTableMap.set(umName, { ...umDef, featureName: feature.name });
346
+ }
347
+ }
348
+
349
+ // Claim keys + auth-claims hooks (declaredShortNames threads the auto-prefix
350
+ // warning-set from claim-key declarations into the hooks registered right after —
351
+ // reordered next to each other; originally separated by the screens/nav/workspace
352
+ // block below, which has no dependency on either).
353
+ export function populateClaimsAndAuth(state: RegistryState, feature: FeatureDefinition): void {
354
+ // Claim keys: aggregated by qualified name. Two features cannot collide
355
+ // here (qualified by feature name), but we still guard for explicit
356
+ // correctness — the only way to hit this is a hand-built FeatureDefinition
357
+ // bypassing defineFeature's per-feature duplicate check.
358
+ const declaredShortNames = new Set<string>();
359
+ for (const def of Object.values(feature.claimKeys ?? {})) {
360
+ if (state.claimKeyMap.has(def.qualifiedName)) {
361
+ throw new Error(
362
+ `[Kumiko ClaimKeys] Claim key "${def.qualifiedName}" registered multiple times. ` +
363
+ "Claim short-names must be globally unique across features.",
364
+ );
365
+ }
366
+ state.claimKeyMap.set(def.qualifiedName, def);
367
+ declaredShortNames.add(def.shortName);
368
+ }
369
+ // Auth-claims hooks: order of registration is preserved. Feature name is
370
+ // captured alongside so the resolver can apply the auto-prefix at merge
371
+ // time — the feature author never ships pre-prefixed keys.
372
+ //
373
+ // If the feature declared ANY claim keys, every hook from that feature
374
+ // gets the declaredShortNames set attached. The resolver uses it to warn
375
+ // on undeclared inner-keys (typo / rename drift). Features that don't
376
+ // declare claimKeys skip the check entirely — it's opt-in.
377
+ const declaredKeys = declaredShortNames.size > 0 ? declaredShortNames : undefined;
378
+ for (const fn of feature.authClaimsHooks ?? []) {
379
+ state.authClaimsHooks.push({
380
+ featureName: feature.name,
381
+ fn,
382
+ ...(declaredKeys && { declaredKeys }),
383
+ });
384
+ }
385
+ }
386
+
387
+ // Screens + nav (qualified, entity/parent indexed) + workspaces (nav membership
388
+ // pass 1 — pass 2 folds self-assigned nav entries in after full ingest, see
389
+ // finalizeWorkspaceNavMembership) + tree-actions (at-most-one per feature).
390
+ export function populateScreensNavWorkspaces(
391
+ state: RegistryState,
392
+ feature: FeatureDefinition,
393
+ ): void {
394
+ // Screens: qualified + stored. Uniqueness per-feature is enforced in
395
+ // defineFeature; cross-feature collisions are impossible because the
396
+ // qualified name includes the feature-prefix. The separate state.featureMap
397
+ // entry lets the nav resolver pause screens owned by disabled features
398
+ // in O(1) without walking every screen.
399
+ for (const [screenId, screenDef] of Object.entries(feature.screens ?? {})) {
400
+ const qualified = qualify(feature.name, "screen", screenId);
401
+ // Stored version overwrites `id` with the qualified name so callers
402
+ // never need a reverse index (NavDef → qn) during tree-walking.
403
+ // Same pattern as state.writeHandlerMap/state.projectionMap/state.multiStreamProjectionMap
404
+ // (see `{ ...def, name: qualified }` above). Feature-side
405
+ // `feature.screens[shortId]` keeps the short id — only the registry
406
+ // surface flips.
407
+ const stored = { ...screenDef, id: qualified };
408
+ state.screenMap.set(qualified, stored);
409
+ state.screenFeatureMap.set(qualified, feature.name);
410
+ // entity-Index nur für Screens die direkt an einer Entity hängen.
411
+ // entityList/entityEdit haben `entity`; custom + actionForm haben
412
+ // keinen entity-Bezug (custom ist opaque, actionForm hat inline
413
+ // fields ohne Entity-Reference).
414
+ if (stored.type === "entityList" || stored.type === "entityEdit") {
415
+ const existing = state.screensByEntity.get(stored.entity) ?? [];
416
+ existing.push(stored);
417
+ state.screensByEntity.set(stored.entity, existing);
418
+ }
419
+ }
420
+
421
+ // Nav entries: same qualification pattern as screens. The parent/screen
422
+ // refs are boot-validated below (after all features are ingested, so
423
+ // cross-feature parents can resolve). parent-index is built in the same
424
+ // loop because `parent` refers to a qualified name that doesn't need
425
+ // resolution — just string equality with whatever's in the target
426
+ // entry's QN.
427
+ for (const [navId, navDef] of Object.entries(feature.navs ?? {})) {
428
+ const qualified = qualify(feature.name, "nav", navId);
429
+ // See screens above — stored version carries the qualified id so
430
+ // resolveNavigation can recurse via getNavsByParent(child.id) without
431
+ // hand-building a reverse index.
432
+ const stored = { ...navDef, id: qualified };
433
+ state.navMap.set(qualified, stored);
434
+ state.navFeatureMap.set(qualified, feature.name);
435
+ if (stored.parent === undefined) {
436
+ state.topLevelNavs.push(stored);
437
+ } else {
438
+ const existing = state.navsByParent.get(stored.parent) ?? [];
439
+ existing.push(stored);
440
+ state.navsByParent.set(stored.parent, existing);
441
+ }
442
+ }
443
+
444
+ // Workspaces: same qualification pattern as nav/screen. Step one stores
445
+ // the workspace itself + its explicit nav list; step two (after every
446
+ // feature has been ingested) folds nav-self-assigned QNs into the same
447
+ // member list. Doing it in two passes keeps cross-feature workspace
448
+ // refs valid — a nav entry can self-assign to a workspace whose feature
449
+ // hasn't been ingested yet.
450
+ for (const [wsId, wsDef] of Object.entries(feature.workspaces ?? {})) {
451
+ const qualified = qualify(feature.name, "workspace", wsId);
452
+ const stored = { ...wsDef, id: qualified };
453
+ state.workspaceMap.set(qualified, stored);
454
+ state.workspaceFeatureMap.set(qualified, feature.name);
455
+ // Seed the membership list with the workspace's explicit nav refs in
456
+ // declaration order. Boot-validator checks the QNs resolve.
457
+ state.navsByWorkspace.set(qualified, [...(stored.nav ?? [])]);
458
+ if (stored.default === true) {
459
+ // Boot-validator enforces uniqueness; here we just remember the
460
+ // first one and let validateBoot complain if there's a second.
461
+ if (state.defaultWorkspace === undefined) {
462
+ state.defaultWorkspace = stored;
463
+ }
464
+ }
465
+ }
466
+
467
+ // Tree-Actions slot — at-most-one per feature (only-once-guard im
468
+ // registrar). Erased Map für Runtime-Lookup; compile-time-typed
469
+ // Surface läuft über FeatureDefinition.exports (TreeActionsHandle).
470
+ if (feature.treeActions !== undefined) {
471
+ state.treeActionsMap.set(feature.name, feature.treeActions);
472
+ }
473
+ }