@cosmicdrift/kumiko-framework 0.28.0 → 0.31.1

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.
@@ -76,6 +76,11 @@ import type { SourceLocation } from "./source-location";
76
76
  // generates them as pure data. Round-trip without code spans.
77
77
  // =============================================================================
78
78
 
79
+ // `r.entity(name, definition)` — declares an event-sourced entity: field
80
+ // schema plus search/sort and PII metadata as one declarative object. The
81
+ // framework derives the aggregate table, CRUD events, and the read-side
82
+ // projection from it at boot. Fully static — the Designer renders it as a
83
+ // form, the AI patcher edits it as pure data.
79
84
  export type EntityPattern = {
80
85
  readonly kind: "entity";
81
86
  readonly source: SourceLocation;
@@ -83,6 +88,13 @@ export type EntityPattern = {
83
88
  readonly definition: EntityDefinition;
84
89
  };
85
90
 
91
+ // `r.relation(entity, relationName, definition)` — attaches a named
92
+ // relationship to an entity: `belongsTo`, `hasMany`, or `manyToMany`
93
+ // (discriminated by `type`). Each variant carries the target entity plus
94
+ // its own extras: foreign key or join table, cascade behaviour (`onDelete`
95
+ // — parent-side only, not on `belongsTo`), search includes, opt-in
96
+ // `nestedWrite` expansion. Boot-validation checks that every target
97
+ // resolves to a registered entity (cross-feature targets allowed).
86
98
  export type RelationPattern = {
87
99
  readonly kind: "relation";
88
100
  readonly source: SourceLocation;
@@ -91,59 +103,104 @@ export type RelationPattern = {
91
103
  readonly definition: RelationDefinition;
92
104
  };
93
105
 
106
+ // `r.nav(definition)` — registers a nav entry under the feature-local short
107
+ // id (qualified to `<feature>:nav:<id>`). Boot-validation checks that the
108
+ // referenced `screen` and `parent` exist (cross-feature QNs allowed) and
109
+ // that parent chains contain no cycles.
94
110
  export type NavPattern = {
95
111
  readonly kind: "nav";
96
112
  readonly source: SourceLocation;
97
113
  readonly definition: NavDefinition;
98
114
  };
99
115
 
116
+ // `r.workspace(definition)` — registers a workspace, a persona-/role-scoped
117
+ // UI surface (qualified to `<feature>:workspace:<id>`). Pure UI composition;
118
+ // boot-validation checks that nav refs exist and that at most one workspace
119
+ // per app declares `default: true`.
100
120
  export type WorkspacePattern = {
101
121
  readonly kind: "workspace";
102
122
  readonly source: SourceLocation;
103
123
  readonly definition: WorkspaceDefinition;
104
124
  };
105
125
 
126
+ // `r.config({ keys, seeds? })` — declares per-tenant config keys and returns
127
+ // a handle map; passing a handle to `ctx.config(handle)` narrows the value
128
+ // type by the key's declared `type`. Optional `seeds` write boot-time rows
129
+ // via the event-store executor (system-tenant by default, explicit
130
+ // `tenantId` per seed) — idempotent, skipped when the stream already exists.
106
131
  export type ConfigPattern = {
107
132
  readonly kind: "config";
108
133
  readonly source: SourceLocation;
109
134
  readonly keys: Readonly<Record<string, ConfigKeyDefinition<ConfigKeyType>>>;
110
135
  };
111
136
 
137
+ // `r.translations({ keys })` — registers locale-keyed string maps
138
+ // (`key → { locale → text }`). The registry namespaces every key by feature
139
+ // (`<feature>:<key>`), so short keys never collide across features;
140
+ // `createI18n` consumes the merged map with default-locale fallback.
141
+ // Multiple calls per feature merge, last write wins per key.
112
142
  export type TranslationsPattern = {
113
143
  readonly kind: "translations";
114
144
  readonly source: SourceLocation;
115
145
  readonly keys: TranslationKeys;
116
146
  };
117
147
 
148
+ // `r.requires(...featureNames)` — hard dependency on other features; boot
149
+ // fails when one is missing from the app composition. Callable-plus-
150
+ // namespace: `r.requires.projection(table)` allow-lists a read-side table
151
+ // for pipeline-step writes, `r.requires.step(kind)` opts into Tier-2 step
152
+ // kinds.
118
153
  export type RequiresPattern = {
119
154
  readonly kind: "requires";
120
155
  readonly source: SourceLocation;
121
156
  readonly featureNames: readonly string[];
122
157
  };
123
158
 
159
+ // `r.optionalRequires(...featureNames)` — soft dependency: the feature
160
+ // integrates with the named features when they are mounted but boots fine
161
+ // without them. For cross-cutting integrations (audit, notifications) that
162
+ // degrade gracefully.
124
163
  export type OptionalRequiresPattern = {
125
164
  readonly kind: "optionalRequires";
126
165
  readonly source: SourceLocation;
127
166
  readonly featureNames: readonly string[];
128
167
  };
129
168
 
169
+ // `r.systemScope()` — switches the feature's `TenantDb` to system mode: no
170
+ // tenant filter on reads/updates/deletes, and INSERT treats `tenantId` as a
171
+ // default the handler may override (tenant mode forces it). For features
172
+ // whose aggregates span tenants, e.g. user management or platform
173
+ // operations. Marker call — no arguments.
130
174
  export type SystemScopePattern = {
131
175
  readonly kind: "systemScope";
132
176
  readonly source: SourceLocation;
133
177
  };
134
178
 
179
+ // `r.toggleable({ default })` — declares the feature operator-switchable via
180
+ // the feature-toggles bundled feature; `default` is the effective state when
181
+ // no global-toggle row exists. At most once per feature. Don't declare it on
182
+ // always-on core features (auth, tenant, user) — that is a bug, and nothing
183
+ // catches it at boot.
135
184
  export type ToggleablePattern = {
136
185
  readonly kind: "toggleable";
137
186
  readonly source: SourceLocation;
138
187
  readonly default: boolean;
139
188
  };
140
189
 
190
+ // `r.describe(text)` — the one-to-three-sentence docs lead for the feature
191
+ // ("what it does + when you need it"). At most once per feature, must be
192
+ // non-empty. Flows through the feature manifest into the generated
193
+ // feature-reference pages.
141
194
  export type DescribePattern = {
142
195
  readonly kind: "describe";
143
196
  readonly source: SourceLocation;
144
197
  readonly text: string;
145
198
  };
146
199
 
200
+ // `r.metric(shortName, options)` — declares a metric under its short name
201
+ // (without the `kumiko_<feature>_` prefix; the framework qualifies it at
202
+ // boot and validates snake_case + type suffix). Runtime usage:
203
+ // `ctx.metrics.inc("created_total", { status: "new" })`.
147
204
  export type MetricPattern = {
148
205
  readonly kind: "metric";
149
206
  readonly source: SourceLocation;
@@ -151,6 +208,10 @@ export type MetricPattern = {
151
208
  readonly options: MetricOptions;
152
209
  };
153
210
 
211
+ // `r.secret(shortName, options)` — declares a tenant-scoped secret key,
212
+ // qualified to `<feature>:secret:<kebab-short>` via the QN helper. Returns a
213
+ // typed handle for `ctx.secrets.get`, so feature code never retypes the
214
+ // qualified string — same ergonomics as `r.config` handles.
154
215
  export type SecretPattern = {
155
216
  readonly kind: "secret";
156
217
  readonly source: SourceLocation;
@@ -158,6 +219,12 @@ export type SecretPattern = {
158
219
  readonly options: SecretOptions;
159
220
  };
160
221
 
222
+ // `r.claimKey(shortName, { type })` — declares a session-claim key,
223
+ // qualified to `<feature>:<shortName>` (no kebab conversion — it would
224
+ // break the claim round-trip), and returns a typed handle for
225
+ // `readClaim(user, handle)`. Declaring keys also enables typo-drift
226
+ // protection: `r.authClaims` hooks returning an undeclared inner key log a
227
+ // warning (the claim still lands in the JWT — this is not strict mode).
161
228
  export type ClaimKeyPattern = {
162
229
  readonly kind: "claimKey";
163
230
  readonly source: SourceLocation;
@@ -165,6 +232,12 @@ export type ClaimKeyPattern = {
165
232
  readonly claimType: ClaimKeyType;
166
233
  };
167
234
 
235
+ // `r.referenceData(entity, rows, options?)` — declares static lookup rows
236
+ // for an entity, upserted by `seedReferenceData` (the app or dev-server
237
+ // calls it at boot — not the framework itself): insert or update by
238
+ // `upsertKey` — which defaults to the first field of the first row, so
239
+ // declare it explicitly — and never delete. New rows land under
240
+ // `SYSTEM_TENANT_ID`, i.e. global reference data, not tenant rows.
168
241
  export type ReferenceDataPattern = {
169
242
  readonly kind: "referenceData";
170
243
  readonly source: SourceLocation;
@@ -173,12 +246,23 @@ export type ReferenceDataPattern = {
173
246
  readonly upsertKey?: string;
174
247
  };
175
248
 
249
+ // `r.readsConfig(...qualifiedKeys)` — declares that this feature reads
250
+ // config keys owned by other features, in dot notation
251
+ // (`featureName.shortKey`). Boot-validation throws when a declared key does
252
+ // not exist anywhere. Purely declarative beyond that boot-time safety net —
253
+ // runtime reads still go through `ctx.config(handle)`.
176
254
  export type ReadsConfigPattern = {
177
255
  readonly kind: "readsConfig";
178
256
  readonly source: SourceLocation;
179
257
  readonly qualifiedKeys: readonly string[];
180
258
  };
181
259
 
260
+ // `r.useExtension(extensionName, entity, options?)` — opts an entity into a
261
+ // registrar extension declared via `r.extendsRegistrar`: runs its
262
+ // `onRegister`, merges its extra schema fields, and installs its entity
263
+ // hooks at registry build time. The `options` bag is passed verbatim to
264
+ // `onRegister` (per-entity configuration). Boot-validation throws when the
265
+ // extension name does not exist.
182
266
  export type UseExtensionPattern = {
183
267
  readonly kind: "useExtension";
184
268
  readonly source: SourceLocation;
@@ -187,11 +271,12 @@ export type UseExtensionPattern = {
187
271
  readonly options?: Readonly<Record<string, unknown>>;
188
272
  };
189
273
 
190
- // r.treeActions({ ... }) — Schema-Map für Visual-Tree-Action-Verben.
191
- // Static: Args sind Type-Samples (kein Runtime-Validator), Designer
192
- // rendert das als nested form pro Action. Compile-Time-Validation
193
- // passiert via setup-export-Handle (TreeActionsHandle), nicht über
194
- // dieses Patterndas hier ist reine Runtime-Repräsentation.
274
+ // `r.treeActions({ ... })`the schema map for visual-tree action verbs
275
+ // (action name definition with optional typed args). Static: args are
276
+ // type samples, not runtime validators; the Designer renders a nested form
277
+ // per action. Compile-time validation happens via the exported
278
+ // `TreeActionsHandle`, not through this pattern this is the erased
279
+ // runtime representation.
195
280
  export type TreeActionsPattern = {
196
281
  readonly kind: "treeActions";
197
282
  readonly source: SourceLocation;
@@ -219,6 +304,11 @@ export type OpaquePropMap = Readonly<Record<string, SourceLocation>>;
219
304
  export const SCREEN_OPAQUE_MARKER = "$opaque" as const;
220
305
  export type ScreenOpaqueMarker = typeof SCREEN_OPAQUE_MARKER;
221
306
 
307
+ // `r.screen(definition)` — registers a screen under the feature-local short
308
+ // id (qualified to `<feature>:screen:<id>`). Boot-validation checks that
309
+ // entity-bound screens reference a registered entity and that column/form
310
+ // field refs name real fields. Closure-valued props (visibility conditions,
311
+ // row-action payloads, custom renderers) stay opaque — see `opaqueProps`.
222
312
  export type ScreenPattern = {
223
313
  readonly kind: "screen";
224
314
  readonly source: SourceLocation;
@@ -229,6 +319,9 @@ export type ScreenPattern = {
229
319
  readonly opaqueProps: OpaquePropMap;
230
320
  };
231
321
 
322
+ // `r.writeHandler(...)` — registers a command handler: name, Zod input
323
+ // schema, handler closure, plus optional `access` and `rateLimit` rules.
324
+ // The header is declarative; schema and body stay opaque source spans.
232
325
  export type WriteHandlerPattern = {
233
326
  readonly kind: "writeHandler";
234
327
  readonly source: SourceLocation;
@@ -245,6 +338,9 @@ export type WriteHandlerPattern = {
245
338
  readonly unsafeSkipTransitionGuard?: boolean;
246
339
  };
247
340
 
341
+ // `r.queryHandler(...)` — registers a read handler: name, Zod input schema,
342
+ // handler closure, plus optional `access` and `rateLimit` rules. Read-side
343
+ // counterpart of `r.writeHandler` with the same header/body split.
248
344
  export type QueryHandlerPattern = {
249
345
  readonly kind: "queryHandler";
250
346
  readonly source: SourceLocation;
@@ -255,6 +351,11 @@ export type QueryHandlerPattern = {
255
351
  readonly rateLimit?: RateLimitOption;
256
352
  };
257
353
 
354
+ // `r.hook(type, target, fn, options?)` — attaches a lifecycle hook
355
+ // (`validation`, `preSave`, `postSave`, `preDelete`, `postDelete`,
356
+ // `preQuery`, `postQuery`) to one or more target handlers. Post-hooks
357
+ // accept a `phase` option; `preDelete` always runs in-transaction — it
358
+ // guards the delete. The hook body is an opaque code span.
258
359
  export type HookPattern = {
259
360
  readonly kind: "hook";
260
361
  readonly source: SourceLocation;
@@ -266,6 +367,11 @@ export type HookPattern = {
266
367
  readonly phase?: HookPhase;
267
368
  };
268
369
 
370
+ // `r.entityHook(type, entity, fn, options?)` — like `r.hook`, but bound to
371
+ // an entity instead of individual handlers: `postSave`, `preDelete`, and
372
+ // `postDelete` fire on every matching write. The runtime API additionally
373
+ // accepts `postQuery` (fires for all query-handlers of the entity), but
374
+ // this pattern type only represents the three write-side hooks.
269
375
  export type EntityHookPattern = {
270
376
  readonly kind: "entityHook";
271
377
  readonly source: SourceLocation;
@@ -275,6 +381,13 @@ export type EntityHookPattern = {
275
381
  readonly phase?: HookPhase;
276
382
  };
277
383
 
384
+ // `r.job(name, options, handler)` — registers a background job, qualified
385
+ // to `<feature>:job:<short>` and executed on a BullMQ queue outside the
386
+ // request pipeline. `trigger` is `{ on: handlerRef(s) }` (fires after the
387
+ // handler commits), `{ cron: "..." }`, or `{ manual: true }`; options cover
388
+ // concurrency modes, retries/backoff, timeout, `perTenant` fan-out, and the
389
+ // `runIn` lane (`api`/`worker`). The handler body stays an opaque code
390
+ // span.
278
391
  export type JobPattern = {
279
392
  readonly kind: "job";
280
393
  readonly source: SourceLocation;
@@ -285,6 +398,13 @@ export type JobPattern = {
285
398
  readonly handlerBody: SourceLocation;
286
399
  };
287
400
 
401
+ // `r.notification(name, definition)` — declarative notification template,
402
+ // qualified to `<feature>:notify:<short>`. At registry build it becomes an
403
+ // after-commit postSave hook on the trigger handler that calls
404
+ // `ctx.notify(name, { to, data })`: `recipient` picks userId(s), a tenant
405
+ // broadcast, or `null` to skip; `data` builds the payload; per-channel
406
+ // `templates` (email, in-app, push) render it. Delivered by the `delivery`
407
+ // bundled feature — declare `r.requires("delivery")` alongside.
288
408
  export type NotificationPattern = {
289
409
  readonly kind: "notification";
290
410
  readonly source: SourceLocation;
@@ -296,22 +416,36 @@ export type NotificationPattern = {
296
416
  readonly templates?: Readonly<Record<string, SourceLocation>>;
297
417
  };
298
418
 
419
+ // `r.authClaims(fn)` — contributes claims into `SessionUser.claims` whenever
420
+ // a session is issued (login AND tenant switch — claims are recomputed to
421
+ // avoid stale leaks across tenancies).
422
+ // Multiple hooks merge; keys are auto-prefixed `<feature>:<key>`, so
423
+ // cross-feature collisions are impossible by construction. Best-effort by
424
+ // design: a throwing hook logs and drops only that feature's claims — login
425
+ // still succeeds (identity facts are convenience, not access gates).
299
426
  export type AuthClaimsPattern = {
300
427
  readonly kind: "authClaims";
301
428
  readonly source: SourceLocation;
302
429
  readonly fnBody: SourceLocation;
303
430
  };
304
431
 
305
- // r.tree(provider) — Top-Level-Tree-Provider-Function. Closure-only,
306
- // kein Header-Form. Designer rendert als read-only Code-Block, AI-
307
- // Patcher überschreibt span verbatim. Konsistent mit r.authClaims
308
- // auch da ist die Function-Body die einzige Information.
432
+ // `r.tree(provider)`the feature's top-level tree provider: a subscribe
433
+ // function (emit-fn in, unsubscribe-fn out) that feeds workspaces with
434
+ // `navigation: "tree"`. Features without `r.tree()` are invisible there
435
+ // provider presence IS the filter, there is no workspace mapping.
436
+ // Closure-only, no header form: the Designer renders a read-only code
437
+ // block, the AI patcher overwrites the span verbatim.
309
438
  export type TreePattern = {
310
439
  readonly kind: "tree";
311
440
  readonly source: SourceLocation;
312
441
  readonly providerBody: SourceLocation;
313
442
  };
314
443
 
444
+ // `r.httpRoute(definition)` — mounts an HTTP route owned by the feature,
445
+ // outside the dispatcher pipeline (not under `/api/write|query|batch`) —
446
+ // for RSS/Atom feeds, OG images, OpenAPI specs and similar. Duplicate
447
+ // method+path pairs are rejected per feature at setup time; nothing checks
448
+ // across features.
315
449
  export type HttpRoutePattern = {
316
450
  readonly kind: "httpRoute";
317
451
  readonly source: SourceLocation;
@@ -321,6 +455,11 @@ export type HttpRoutePattern = {
321
455
  readonly handlerBody: SourceLocation;
322
456
  };
323
457
 
458
+ // `r.projection(definition)` — registers a read-side projection driven by
459
+ // events of one or more source entities. Apply functions run inside the
460
+ // event-store's transaction, so the projection stays consistent with the
461
+ // events that feed it. Apply bodies are opaque code spans keyed by event
462
+ // type.
324
463
  export type ProjectionPattern = {
325
464
  readonly kind: "projection";
326
465
  readonly source: SourceLocation;
@@ -332,6 +471,12 @@ export type ProjectionPattern = {
332
471
  readonly applyBodies: Readonly<Record<string, SourceLocation>>;
333
472
  };
334
473
 
474
+ // `r.multiStreamProjection(definition)` — registers a cross-aggregate async
475
+ // projection. The event-dispatcher owns delivery via a dedicated cursor:
476
+ // at-least-once, strictly ordered by event id — handlers must be idempotent.
477
+ // For views spanning many aggregate types (billing summaries, audit views,
478
+ // saga state); omit the table for pure side-effect consumers (notifications,
479
+ // webhooks, external-system sync).
335
480
  export type MultiStreamProjectionPattern = {
336
481
  readonly kind: "multiStreamProjection";
337
482
  readonly source: SourceLocation;
@@ -342,6 +487,12 @@ export type MultiStreamProjectionPattern = {
342
487
  readonly delivery?: "shared" | "per-instance";
343
488
  };
344
489
 
490
+ // `r.defineEvent(name, schema, options?)` — registers an event payload
491
+ // shape and returns the qualified `EventDef`, so callers pass `.name` to
492
+ // `ctx.appendEvent` instead of hand-building `<feature>:event:<short>`.
493
+ // `options.version` declares the CURRENT schema generation (default 1);
494
+ // bump it together with an `r.eventMigration` step — the framework refuses
495
+ // to boot if the chain from 1 to the current version has gaps.
345
496
  export type DefineEventPattern = {
346
497
  readonly kind: "defineEvent";
347
498
  readonly source: SourceLocation;
@@ -350,6 +501,11 @@ export type DefineEventPattern = {
350
501
  readonly version?: number;
351
502
  };
352
503
 
504
+ // `r.eventMigration(eventName, fromVersion, toVersion, transform)` —
505
+ // registers a step-wise payload upcast for event-schema evolution.
506
+ // `toVersion` must be `fromVersion + 1`; chain larger jumps step by step.
507
+ // Transforms are pure old-payload-in/new-payload-out functions and run once
508
+ // per READ, not once per event persisted — keep them cheap.
353
509
  export type EventMigrationPattern = {
354
510
  readonly kind: "eventMigration";
355
511
  readonly source: SourceLocation;
@@ -359,6 +515,11 @@ export type EventMigrationPattern = {
359
515
  readonly transformBody: SourceLocation;
360
516
  };
361
517
 
518
+ // `r.extendsRegistrar(extensionName, def)` — declares a named, globally
519
+ // unique extension point that other features opt into per entity via
520
+ // `r.useExtension`. The def can contribute `onRegister`, extra schema
521
+ // fields (`extendSchema`), and entity hooks; wiring happens at registry
522
+ // build time.
362
523
  export type ExtendsRegistrarPattern = {
363
524
  readonly kind: "extendsRegistrar";
364
525
  readonly source: SourceLocation;
@@ -386,14 +547,7 @@ export type ExposesApiPattern = {
386
547
  readonly apiName: string;
387
548
  };
388
549
 
389
- // =============================================================================
390
- // Catch-all — r.* calls the visitor doesn't recognise. Designer renders
391
- // "unknown call (cannot edit)", AI patcher leaves them unchanged. When
392
- // an UnknownPattern shows up in the wild it's a signal that a new r.*
393
- // API exists and needs its own pattern type here.
394
- // =============================================================================
395
-
396
- // r.envSchema(z.object({...})) — the env-vars contract for a feature.
550
+ // `r.envSchema(z.object({...}))` — the env-vars contract for a feature.
397
551
  // Argument is a Zod-expression (computed); we keep the source-location of
398
552
  // the schema body so Designer / AI render the raw TS code (opaque).
399
553
  export type EnvSchemaPattern = {
@@ -402,6 +556,10 @@ export type EnvSchemaPattern = {
402
556
  readonly schemaBody: SourceLocation;
403
557
  };
404
558
 
559
+ // Catch-all — r.* calls the visitor doesn't recognise. Designer renders
560
+ // "unknown call (cannot edit)", AI patcher leaves them unchanged. When
561
+ // an UnknownPattern shows up in the wild it's a signal that a new r.*
562
+ // API exists and needs its own pattern type here.
405
563
  export type UnknownPattern = {
406
564
  readonly kind: "unknown";
407
565
  readonly source: SourceLocation;
@@ -143,6 +143,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
143
143
  const handlerFeatureMap = new Map<string, string>();
144
144
  const extensionMap = new Map<string, RegistrarExtensionDef>();
145
145
  const extensionUsages: RegistrarExtensionRegistration[] = [];
146
+ const extensionSelectorMap = new Map<string, string>();
146
147
  const allReferenceData: ReferenceDataDef[] = [];
147
148
  const allConfigSeeds: ConfigSeedDef[] = [];
148
149
  const mergedTranslations: Record<string, Record<string, string>> = {};
@@ -471,7 +472,20 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
471
472
  }
472
473
  extensionMap.set(extName, extDef);
473
474
  }
474
- extensionUsages.push(...(feature.extensionUsages ?? []));
475
+ // Annotate the owner so consumers (readiness gating) can map a
476
+ // registration back to the feature's config keys + secrets.
477
+ extensionUsages.push(
478
+ ...(feature.extensionUsages ?? []).map((u) => ({ ...u, featureName: feature.name })),
479
+ );
480
+ for (const sel of feature.extensionSelectors ?? []) {
481
+ if (extensionSelectorMap.has(sel.extensionName)) {
482
+ throw new Error(
483
+ `Duplicate extension selector for "${sel.extensionName}" ` +
484
+ `(feature "${feature.name}") — one owning feature declares the selector.`,
485
+ );
486
+ }
487
+ extensionSelectorMap.set(sel.extensionName, sel.qualifiedKey);
488
+ }
475
489
  allReferenceData.push(...(feature.referenceData ?? []));
476
490
  allConfigSeeds.push(...(feature.configSeeds ?? []));
477
491
 
@@ -742,6 +756,24 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
742
756
  }
743
757
  }
744
758
 
759
+ // Selector declarations point into the merged extension + config-key
760
+ // sets — a typo'd extension or dropped key must fail the boot, not
761
+ // silently un-gate readiness.
762
+ for (const [extensionName, qualifiedKey] of extensionSelectorMap) {
763
+ if (!extensionMap.has(extensionName)) {
764
+ throw new Error(
765
+ `extensionSelector("${extensionName}") declared but no feature ` +
766
+ `registers that extension via extendsRegistrar.`,
767
+ );
768
+ }
769
+ if (!configKeyMap.has(qualifiedKey)) {
770
+ throw new Error(
771
+ `extensionSelector("${extensionName}") points at unknown config key ` +
772
+ `"${qualifiedKey}" — no mounted feature declares it.`,
773
+ );
774
+ }
775
+ }
776
+
745
777
  // Process extension usages: call onRegister, apply extendSchema, register hooks
746
778
  for (const usage of extensionUsages) {
747
779
  const ext = extensionMap.get(usage.extensionName);
@@ -1411,6 +1443,10 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
1411
1443
  return extensionUsages.filter((u) => u.extensionName === extensionName);
1412
1444
  },
1413
1445
 
1446
+ getAllExtensionSelectors(): ReadonlyMap<string, string> {
1447
+ return extensionSelectorMap;
1448
+ },
1449
+
1414
1450
  getAllNotifications(): ReadonlyMap<string, NotificationDefinition> {
1415
1451
  return notificationMap;
1416
1452
  },
@@ -86,6 +86,11 @@ export type ConfigKeyDefinition<T extends ConfigKeyType = ConfigKeyType> = {
86
86
  // Nicht kombinierbar mit encrypted (Boot-Reject) — encrypted Keys
87
87
  // werden nicht transient aus Query-Strings heraus gelesen.
88
88
  readonly allowPerRequest?: boolean;
89
+ // Tenant must supply a real value before the owning feature works — for
90
+ // text keys an empty/whitespace value counts as unset. Surfaced by
91
+ // config:query:readiness; keep in sync with the feature's requireNonEmpty
92
+ // calls in its build-fn.
93
+ readonly required?: boolean;
89
94
  };
90
95
 
91
96
  export type ConfigDefinition = {
@@ -350,6 +355,17 @@ export type RegistrarExtensionRegistration = {
350
355
  readonly extensionName: string;
351
356
  readonly entityName: string;
352
357
  readonly options?: Record<string, unknown> | undefined;
358
+ // Owning feature — annotated by the registry at merge time so consumers
359
+ // (readiness gating) can map a registration back to the feature's keys.
360
+ readonly featureName?: string;
361
+ };
362
+
363
+ // Declared by the extension-point-owning foundation via r.extensionSelector:
364
+ // "which provider under <extensionName> is active is chosen by <qualifiedKey>".
365
+ // Readiness counts a provider-feature's required keys only when selected.
366
+ export type ExtensionSelectorDef = {
367
+ readonly extensionName: string;
368
+ readonly qualifiedKey: string;
353
369
  };
354
370
 
355
371
  // --- Reference Data ---
@@ -12,6 +12,7 @@ import type {
12
12
  ConfigKeyHandle,
13
13
  ConfigKeyType,
14
14
  ConfigSeedDef,
15
+ ExtensionSelectorDef,
15
16
  JobDefinition,
16
17
  JobHandlerFn,
17
18
  NotificationDataFn,
@@ -108,6 +109,10 @@ export type SecretKeyDefinition = {
108
109
  readonly hint?: { readonly [locale: string]: string };
109
110
  // Per-secret scope. v1 only "tenant" — user / system scopes ship in v2.
110
111
  readonly scope: "tenant";
112
+ // Tenant must set this secret before the owning feature works. Surfaced
113
+ // by readiness:query:status; keep in sync with the missing-secret throw
114
+ // in the feature's build-fn.
115
+ readonly required?: boolean;
111
116
  };
112
117
 
113
118
  export type SecretOptions = Omit<SecretKeyDefinition, "shortName" | "qualifiedName">;
@@ -210,6 +215,7 @@ export type FeatureDefinition = {
210
215
  readonly jobs: Readonly<Record<string, JobDefinition>>;
211
216
  readonly registrarExtensions: Readonly<Record<string, RegistrarExtensionDef>>;
212
217
  readonly extensionUsages: readonly RegistrarExtensionRegistration[];
218
+ readonly extensionSelectors: readonly ExtensionSelectorDef[];
213
219
  /**
214
220
  * Cross-feature API names this feature exposes via `r.exposesApi(name)`.
215
221
  * Pure Marker-Deklaration — die echte Implementation wird als
@@ -346,7 +352,7 @@ export type FeatureRegistrar<TFeature extends string = string> = {
346
352
  // Declare the feature as operator-togglable. `default` is the effective
347
353
  // state when no global-toggle row exists. Must be called at most once per
348
354
  // feature; calling on an always-on feature (e.g. auth/tenant/user) is a
349
- // bug the registry catches at boot.
355
+ // bug and one nothing catches at boot, so don't.
350
356
  toggleable(options: { default: boolean }): void;
351
357
 
352
358
  entity(name: string, definition: EntityDefinition): EntityRef;
@@ -480,6 +486,17 @@ export type FeatureRegistrar<TFeature extends string = string> = {
480
486
 
481
487
  useExtension(extensionName: string, entity: NameOrRef, options?: Record<string, unknown>): void;
482
488
 
489
+ /**
490
+ * Declares which config key selects the active provider under an
491
+ * extension point — called by the point-owning foundation (e.g.
492
+ * `r.extensionSelector("mailTransport", configKeys.provider)`).
493
+ * Readiness gating counts a provider-feature's `required` keys and
494
+ * secrets only while that provider is the selected one. Registry-build
495
+ * fails on duplicate declarations per extension and on selector keys
496
+ * that no mounted feature declares.
497
+ */
498
+ extensionSelector(extensionName: string, key: { readonly name: string } | string): void;
499
+
483
500
  /**
484
501
  * Marker-Deklaration: dieses Feature stellt eine Cross-Feature-API
485
502
  * unter dem genannten Namen bereit. Die eigentliche Implementation
@@ -555,8 +572,9 @@ export type FeatureRegistrar<TFeature extends string = string> = {
555
572
  // rules are for).
556
573
  authClaims(fn: AuthClaimsFn): void;
557
574
 
558
- // Declare a claim key. Qualified name follows "<feature>:<kebab-short>"
559
- // via the QN helper same convention as r.secret / r.config. Returns a
575
+ // Declare a claim key. Qualified name follows "<feature>:<shortName>"
576
+ // NO kebab conversion (it would break the claim round-trip, unlike
577
+ // r.secret / r.config). Returns a
560
578
  // typed handle so feature code can pass it to `readClaim(user, handle)`
561
579
  // without retyping the qualified string and with the right narrowed
562
580
  // return type.
@@ -593,7 +611,8 @@ export type FeatureRegistrar<TFeature extends string = string> = {
593
611
  // Register an HTTP-route owned by this feature. The route is mounted
594
612
  // outside the dispatcher pipeline (= außerhalb /api/write|query|batch),
595
613
  // direkt an die app — Use-Case: RSS/Atom-Feeds, OG-Images, OpenAPI-Specs.
596
- // Boot-validation rejects duplicate "method path"-Combinations.
614
+ // Duplicate "method path"-Combinations are rejected per feature at setup
615
+ // time; there is no cross-feature check.
597
616
  // Symmetric to queryHandler/writeHandler — Routes leben mit dem Feature,
598
617
  // nicht im Bootstrap. Escape-hatch für nicht-feature-bound Routes
599
618
  // bleibt runProdApp.extraRoutes.
@@ -788,6 +807,8 @@ export type Registry = {
788
807
  >;
789
808
  getExtension(name: string): RegistrarExtensionDef | undefined;
790
809
  getExtensionUsages(extensionName: string): readonly RegistrarExtensionRegistration[];
810
+ // Extension point → selector config key, from r.extensionSelector calls.
811
+ getAllExtensionSelectors(): ReadonlyMap<string, string>;
791
812
  getAllNotifications(): ReadonlyMap<string, NotificationDefinition>;
792
813
  getAllReferenceData(): readonly ReferenceDataDef[];
793
814
  // Look up projections by source-entity name. Empty list when no projection
@@ -32,6 +32,7 @@ export type {
32
32
  CreateSeedOptions,
33
33
  CreateTenantSeedOptions,
34
34
  CreateUserSeedOptions,
35
+ ExtensionSelectorDef,
35
36
  JobDefinition,
36
37
  JobHandlerFn,
37
38
  JobRunIn,
@@ -176,7 +176,9 @@ export type UnprocessableOpts = Pick<ErrorOpts, "i18nKey" | "i18nParams" | "caus
176
176
  };
177
177
 
178
178
  export class UnprocessableError extends KumikoError {
179
- readonly code = "unprocessable";
179
+ // Widened to `string` so subclasses (UnconfiguredError) can refine the
180
+ // value — same pattern as ConflictError above.
181
+ readonly code: string = "unprocessable";
180
182
  readonly httpStatus = 422;
181
183
 
182
184
  constructor(reason: string, opts?: UnprocessableOpts) {
@@ -190,6 +192,32 @@ export class UnprocessableError extends KumikoError {
190
192
  }
191
193
  }
192
194
 
195
+ // A required tenant-config key has no usable value yet. Same 422 as the
196
+ // parent, but a distinct code so clients can route the user to the settings
197
+ // screen instead of showing a generic business-rule error.
198
+ export type UnconfiguredDetails = {
199
+ readonly feature: string;
200
+ readonly key: string;
201
+ readonly hint?: string;
202
+ };
203
+
204
+ export class UnconfiguredError extends UnprocessableError {
205
+ override readonly code: string = "unconfigured";
206
+
207
+ constructor(details: UnconfiguredDetails, opts?: Pick<ErrorOpts, "i18nKey" | "cause">) {
208
+ super(
209
+ `${details.feature}: '${details.key}' is empty — tenant must configure it before use.${
210
+ details.hint ? ` ${details.hint}` : ""
211
+ }`,
212
+ {
213
+ i18nKey: opts?.i18nKey ?? "errors.unconfigured",
214
+ details,
215
+ ...(opts?.cause && { cause: opts.cause }),
216
+ },
217
+ );
218
+ }
219
+ }
220
+
193
221
  // Auto-wrap target for unexpected throws. Never exposes .details to the client
194
222
  // — the serializer drops it. Stack + cause live in the log only.
195
223
  export class InternalError extends KumikoError {