@cosmicdrift/kumiko-framework 0.400.0 → 0.402.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 (59) hide show
  1. package/AGENTS.md +8 -5
  2. package/agents/api/framework-engine.md +31 -5
  3. package/agents/api/testing-stack.md +1 -0
  4. package/agents/api/types.md +16 -1
  5. package/agents/api.md +2 -2
  6. package/agents/boot-rules.md +98 -0
  7. package/agents/examples/relations.md +13 -3
  8. package/dist/config/index.d.ts +1 -1
  9. package/dist/db/read-gate-clause.js +23 -10
  10. package/dist/db/replay-migration-sql.js +1 -1
  11. package/dist/engine/boot-validator/api-ext-rules.d.ts +27 -0
  12. package/dist/engine/boot-validator/api-ext-rules.js +27 -0
  13. package/dist/engine/boot-validator/api-ext.js +25 -12
  14. package/dist/engine/boot-validator/boot-check-rules.d.ts +7 -0
  15. package/dist/engine/boot-validator/boot-check-rules.js +7 -0
  16. package/dist/engine/boot-validator/boot-check.js +13 -1
  17. package/dist/engine/boot-validator/boot-options.d.ts +5 -0
  18. package/dist/engine/boot-validator/boot-phases-app.js +5 -1
  19. package/dist/engine/boot-validator/boot-report.d.ts +1 -0
  20. package/dist/engine/boot-validator/boot-report.js +58 -7
  21. package/dist/engine/boot-validator/boot-rule.d.ts +20 -0
  22. package/dist/engine/boot-validator/boot-rule.js +33 -2
  23. package/dist/engine/boot-validator/boot-rules.d.ts +210 -0
  24. package/dist/engine/boot-validator/boot-rules.js +17 -0
  25. package/dist/engine/boot-validator/card-meta-overflow-rules.d.ts +7 -0
  26. package/dist/engine/boot-validator/card-meta-overflow-rules.js +7 -0
  27. package/dist/engine/boot-validator/card-meta-overflow.js +5 -1
  28. package/dist/engine/boot-validator/entity-list-screens-rules.d.ts +32 -0
  29. package/dist/engine/boot-validator/entity-list-screens-rules.js +32 -0
  30. package/dist/engine/boot-validator/entity-list-screens.js +74 -41
  31. package/dist/engine/boot-validator/field-conditions.js +19 -1
  32. package/dist/engine/boot-validator/i18n-keys-rules.d.ts +27 -0
  33. package/dist/engine/boot-validator/i18n-keys-rules.js +27 -0
  34. package/dist/engine/boot-validator/i18n-keys.d.ts +1 -0
  35. package/dist/engine/boot-validator/i18n-keys.js +43 -16
  36. package/dist/engine/boot-validator/pii-retention-rules.d.ts +107 -0
  37. package/dist/engine/boot-validator/pii-retention-rules.js +107 -0
  38. package/dist/engine/boot-validator/pii-retention.js +88 -34
  39. package/dist/engine/boot-validator/read-gate.js +12 -7
  40. package/dist/engine/boot-validator/screens-actions-rules.d.ts +7 -0
  41. package/dist/engine/boot-validator/screens-actions-rules.js +7 -0
  42. package/dist/engine/boot-validator/screens-actions.js +7 -3
  43. package/dist/engine/entity-handlers.d.ts +12 -5
  44. package/dist/engine/factories.d.ts +13 -1
  45. package/dist/engine/factories.js +18 -0
  46. package/dist/engine/index.d.ts +3 -1
  47. package/dist/engine/index.js +3 -1
  48. package/dist/engine/user-entity-reference.d.ts +2 -0
  49. package/dist/engine/user-entity-reference.js +7 -0
  50. package/dist/files/index.d.ts +2 -0
  51. package/dist/files/index.js +1 -0
  52. package/dist/files/rebind-file-ref.d.ts +41 -0
  53. package/dist/files/rebind-file-ref.js +170 -0
  54. package/dist/i18n/required-surface-keys.d.ts +6 -0
  55. package/dist/i18n/required-surface-keys.js +64 -0
  56. package/dist/schema-cli/validate-boot-input.js +2 -0
  57. package/dist/schema-cli/validate-command.js +4 -1
  58. package/package.json +3 -3
  59. package/src/changes.json +25 -0
package/AGENTS.md CHANGED
@@ -15,6 +15,7 @@ not exist in your version:
15
15
  | `agents/api/<entry>.md` | Signatures of one entry point; options and screen types as property lists |
16
16
  | `agents/api/types.md` | Types the entry points use but do not export, such as nested option shapes |
17
17
  | `agents/widgets.md` | Every value export of `@cosmicdrift/kumiko-renderer/web` with its short description |
18
+ | `agents/boot-rules.md` | Every boot rule by id: summary, fix and source file; a boot violation links its entry |
18
19
  | `agents/examples.md` | Index of the worked examples, one per concept |
19
20
  | `agents/examples/<name>.md` | One tested sample app: README plus every source and test file |
20
21
 
@@ -33,7 +34,7 @@ Order of work for a new feature:
33
34
  `agents/api.md` instead of `dist/*.d.ts`.
34
35
  4. Build the feature with the declarative building blocks below.
35
36
  5. Write an integration test against the real stack.
36
- 6. Run boot validation and the tests. Boot errors name the fix; apply it.
37
+ 6. Run boot validation and the tests. Boot errors name the fix; apply it. Each violation carries a rule id whose entry is in `agents/boot-rules.md`.
37
38
 
38
39
  ## Mental model
39
40
 
@@ -113,8 +114,9 @@ the field types reject a text field without that stance.
113
114
  `personal: false, reason: "is_business_data"` (a snake_case reason) for
114
115
  business data. For personal data name the subject, `personal: "self"`,
115
116
  `personal: { of: "<ownerField>" }` or `personal: "tenant"`, plus
116
- `find: "exact" | "fuzzy" | "none" | "secret"`. A plain foreign key to a user
117
- is `personal: "ref"`. `personal: "self"` is for the row that is the user.
117
+ `find: "exact" | "fuzzy" | "none" | "secret"`. A foreign key to a user
118
+ is `createReferenceField({ entity: "user:user" })`, which defaults to
119
+ `personal: "ref"`; `personal: false` on it fails at boot. `personal: "self"` is for the row that is the user.
118
120
  When the record itself is the person, such as a contact or a customer that
119
121
  is not a user, use `personal: { of: "id" }`; the entity then needs a uuid
120
122
  `id`. The framework encrypts personal fields per subject and
@@ -292,7 +294,8 @@ produces code that compiles and then fails in production.
292
294
  unknown field in a column, a nav pointing at a missing screen, an
293
295
  encrypted field marked searchable. The message names the
294
296
  feature, the place and the fix. Apply the fix the message names instead
295
- of working around the check.
297
+ of working around the check. Each violation shows a rule id and, for
298
+ classified rules, a `Rule:` link to its entry in `agents/boot-rules.md`.
296
299
 
297
300
  ## I need X, use Y
298
301
 
@@ -306,7 +309,7 @@ as bundled features.
306
309
  | Business logic beyond CRUD | `r.writeHandler` / `r.queryHandler` with `createEntityExecutor` | `agents/examples/custom-handlers.md` |
307
310
  | Filtered lists and counts | Executor `list` with `filter` and `totalCount` | `agents/examples/custom-handlers.md` |
308
311
  | Key figures on a dashboard | `dashboard` screen with `stat` / `stat-group` panels and a query handler | `agents/examples/dashboard-stats.md` |
309
- | Relations between entities | `r.relation(entity, name, { type, target, foreignKey, onDelete })` | `agents/examples/relations.md` |
312
+ | Relations between entities | `createReferenceField({ entity })` for the foreign key (user ids: `entity: "user:user"`), `r.relation(entity, name, { type, target, foreignKey, onDelete })` | `agents/examples/relations.md` |
310
313
  | Structured sub-objects in a field | Embedded fields | `agents/examples/embedded.md` |
311
314
  | List, edit, custom screens and navs | `r.screen`, `r.nav` | `agents/examples/screens-nav.md` |
312
315
  | A record page with header, metrics, tabs | `projectionDetail` with `header`, `metrics`, `layout.mode: "tabs"` | `agents/examples/record-detail-layout.md` |
@@ -11,6 +11,7 @@ function assertNotificationVariables(registry: Pick<Registry, "getAllNotificatio
11
11
  function assignableAppRolesFromUsages(usages: { entityName: string; options?: Record<string, unknown> | undefined; }[]): AssignableAppRoles;
12
12
  function assignableAppRolesOf(registry: Pick<Registry, "getExtensionUsages">): AssignableAppRoles;
13
13
  function attachOutsideTransaction<T extends object, O extends object | undefined>(db: T, outsideTransaction: O): T & { outsideTransaction: O; };
14
+ function bootRuleError(rule: BootRule, details: { message: string; }): BootRuleViolation;
14
15
  function bridgeStub(opts?: { user?: SessionUser; log?: HandlerObservability["log"]; dispatch?: Partial<HandlerDispatch>; services?: Partial<ContextServices>; }): Pick<HandlerContext, "dispatch" | "events" | "auth" | "obs" | "hasFeature" | "tz" | "locale" | "user" | "services" | "lifecycle" | "tenants">;
15
16
  function buildAppSchema(registry: Registry, options?: BuildAppSchemaOptions): AppSchema;
16
17
  function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchema;
@@ -20,6 +21,7 @@ function buildPipelineSteps<TPayload, TAwaits extends Record<string, AwaitedEven
20
21
  function buildSessionRoles(globalRoles: string[], membershipRoles: string[]): string[];
21
22
  function buildUpdateSchema(entity: EntityDefinition, currencies?: string[], excludedFields?: string[]): z.ZodObject<Record<string, z.ZodTypeAny>>;
22
23
  function canActorAssignRole(actorRoles: string[], role: string, assignableAppRoles?: AssignableAppRoles): boolean;
24
+ function collectBootRuleViolations<T>(items: Iterable<T>, run: (item: T) => void): void;
23
25
  function collectCardMetaOverflow(features: FeatureDefinition[]): CardMetaOverflow[];
24
26
  function collectWriteHandlerQns(features: FeatureDefinition[]): Set<string>;
25
27
  function compareVersions(a: string, b: string): number;
@@ -45,6 +47,7 @@ function createLongTextField<R extends true | false = false>(overrides: Partial<
45
47
  function createMoneyField<R extends true | false = false>(overrides?: Partial<Omit<MoneyFieldDef, "type" | "required">> & { required?: R; }): MoneyFieldDef & { required: R; };
46
48
  function createMultiSelectField<const TOptions extends string[]>(opts: { options: TOptions; } & Partial<Omit<MultiSelectFieldDef<TOptions>, "type" | "options" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): MultiSelectFieldDef<TOptions>; // Multi-select field — N values from a fixed option list.
47
49
  function createNumberField<R extends true | false = false>(overrides?: Partial<Omit<NumberFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind & { required?: R; }): NumberFieldDef & { required: R; }; // Numeric field — `double precision` column by default (fractional values allowed end to end).
50
+ function createReferenceField<R extends true | false = false>(opts: { entity: string; } & Partial<Omit<ReferenceFieldDef, "type" | "entity" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind & { required?: R; }): ReferenceFieldDef & { required: R; }; // Foreign key to another entity (`"customer"` or `"feature:entity"`), stored as a UUID.
48
51
  function createRegistry(rawFeatures: FeatureDefinition[]): Registry;
49
52
  function createSeed(opts: CreateSeedOptions): ConfigSeedDef;
50
53
  function createSelectField<const TOptions extends string[], R extends true | false = false>(opts: { options: TOptions; } & Partial<Omit<SelectFieldDef<NoInfer<TOptions>>, "type" | "options" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind & { required?: R; }): SelectFieldDef<TOptions> & { required: R; };
@@ -60,14 +63,14 @@ function createUserConfig<T extends ConfigKeyType>(type: T, opts?: ConfigKeyOpti
60
63
  function declareEscapeHatch(declaration: EscapeHatchDeclaration): void;
61
64
  function dedupeFeatures(features: FeatureDefinition[]): FeatureDefinition[];
62
65
  function defineApply<TPayload = Record<string, unknown>>(fn: (event: StoredEvent<TPayload>, tx: ExecutorManagedRunner, table: ProjectionTable) => Promise<void>): SingleStreamApplyFn;
63
- function defineEntityCreateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): WriteHandlerDef;
64
- function defineEntityDeleteHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): WriteHandlerDef;
66
+ function defineEntityCreateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): EntityWriteHandlerDef;
67
+ function defineEntityDeleteHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): EntityWriteHandlerDef;
65
68
  function defineEntityDetailHandler(entityName: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
66
69
  function defineEntityListHandler(entityName: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
67
70
  function defineEntityQueryHandler(name: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
68
- function defineEntityRestoreHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): WriteHandlerDef;
69
- function defineEntityUpdateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): WriteHandlerDef;
70
- function defineEntityWriteHandler(name: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): WriteHandlerDef;
71
+ function defineEntityRestoreHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): EntityWriteHandlerDef;
72
+ function defineEntityUpdateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): EntityWriteHandlerDef;
73
+ function defineEntityWriteHandler(name: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): EntityWriteHandlerDef;
71
74
  function defineFeature<const TName extends string, TExports = undefined>(name: TName, setup: (r: FeatureRegistrar<TName>) => TExports, settings?: DefineFeatureSettings): FeatureDefinition & { exports: TExports; };
72
75
  function defineMspApply<TPayload = Record<string, unknown>>(fn: (event: StoredEvent<TPayload>, tx: ExecutorManagedRunner, ctx: MultiStreamApplyContext) => Promise<void>): MultiStreamApplyFn;
73
76
  function definePagedQueryHandler<const TName extends string, TSchema extends ZodType, TRow = unknown, TMap extends object = KumikoEventTypeMap>(def: QueryHandlerDefinition<TName, TSchema, CursorResult<TRow>, TMap>, ..._noSecretInResponse: true extends ContainsSecret<CursorResult<TRow>> ? [ secretLeak: "A handler response must not contain a Secret<> — call .reveal() and return the plaintext, or drop the field." ] : [ ]): PagedQueryHandlerDefinition<TName, TSchema, TRow, TMap>;
@@ -224,6 +227,14 @@ const templateVariable: { text; link; richText; money; date; percent; images; li
224
227
  ## Classes
225
228
 
226
229
  ```ts
230
+ class BootRuleViolation extends Error {
231
+ constructor(rule: BootRule, message: string);
232
+ name: unknown;
233
+ }
234
+ class BootValidationError extends Error {
235
+ constructor(violations: BootViolation[]);
236
+ name: unknown;
237
+ }
227
238
  class ProbeFailure extends Error {
228
239
  constructor(text: ProbeText);
229
240
  }
@@ -314,6 +325,19 @@ type BeforeTransactionResult<TPrepared = unknown> = // Outcome of a write handle
314
325
  | WriteFailure
315
326
  ;
316
327
  type BootOptionName = "mail" | "kmsMode" | "fileStorage" | "billing" | "featureToggles" | "rateLimit"; // runProdApp options a stack can depend on; runProdApp checks the set at boot.
328
+ type BootRule = {
329
+ id: string;
330
+ summary: string;
331
+ fix: string;
332
+ };
333
+ type BootRuleTable = Readonly<Record<string, BootRule>>;
334
+ type BootViolation = {
335
+ ruleId: string;
336
+ check: string;
337
+ feature?: string;
338
+ message: string;
339
+ fix?: string;
340
+ };
317
341
  type BundledListsOptions = { // One `table` block for every entityList, projectionList and relatedList of the bundled features.
318
342
  table: ListTableOptions;
319
343
  pinFirstColumn?: boolean; // Pins the first column left on every bundled list where no column is pinned.
@@ -426,6 +450,7 @@ type ContentCollectionDefinition = {
426
450
  variableSchema?: Readonly<Record<string, string>>;
427
451
  nav: {
428
452
  label: string;
453
+ entryLabel?: string;
429
454
  icon?: NavIconKey;
430
455
  parent?: string;
431
456
  order?: number;
@@ -1713,6 +1738,7 @@ type ValidateBootOptions = {
1713
1738
  env?: Readonly<Record<string, string | undefined>>; // Env the encrypted-field master-key probe reads.
1714
1739
  entityFieldCipher?: EnvelopeCipher; // The cipher the runner injects after validation (built from the app's `masterKey` or the env keyring).
1715
1740
  requiredLocales?: string[]; // Locales every translation key must carry across all mounted features (locale packages count).
1741
+ requireFieldValueTranslations?: boolean; // Require translations for the values that entityList columns and entityEdit fields display: select/multiSelect option labels and the column headers and select options of embedded lists.
1716
1742
  };
1717
1743
  type ValidationError = {
1718
1744
  field: string;
@@ -13,6 +13,7 @@ function createRequestHelper(app: Hono, jwt: JwtHelper, options?: RequestHelperO
13
13
  function createTestDb(arg?: string | CreateTestDbOptions): Promise<TestDb>;
14
14
  function createTestRedis(opts?: CreateTestRedisOptions): Promise<TestRedis>;
15
15
  function drainEventConsumers(stack: Pick<TestStack, "db" | "eventDispatcher">, consumerNames: [ string, ...string[] ], options?: { maxPasses?: number; }): Promise<void>;
16
+ function dropTestDb(baseUrl: string, dbName: string): Promise<void>;
16
17
  function pushEntityProjectionTables(stack: DbHolder, registry: Registry): Promise<void>; // Push all implicit-projection tables — one per `r.entity()` — that the registry knows about.
17
18
  function resetEventStore(stack: { db: unknown; eventDispatcher?: EventDispatcher; }, extraTables?: (unknown | string)[]): Promise<void>; // Wipes event store + framework-state + the given feature read-models in one TRUNCATE, then re-registers the event-consumer state rows.
18
19
  function setupTestStack(options: TestStackOptions): Promise<TestStack>;
@@ -903,6 +903,16 @@ type WorkflowTrigger = // Trigger configuration for a workflow.
903
903
  type NumericDerivedOp = Exclude<EmbeddedDerivedCellDef["op"], "inRange">;
904
904
  ```
905
905
 
906
+ ## packages/framework/src/engine/entity-handlers.ts
907
+
908
+ ```ts
909
+ type EntityWriteHandlerDef = WriteHandlerDef & { // The phase slots are always unset on entity handlers, so a consumer can spread one into its own handler and add typed phases without inheriting the slot types.
910
+ reserveBeforeTransaction?: undefined;
911
+ beforeTransaction?: undefined;
912
+ beforeTransactionWhenNested?: undefined;
913
+ };
914
+ ```
915
+
906
916
  ## packages/framework/src/engine/event-helpers.ts
907
917
 
908
918
  ```ts
@@ -3162,10 +3172,14 @@ type ReadGateDef = {
3162
3172
  entity: string;
3163
3173
  field: string;
3164
3174
  }; // Row readable only if at least one live row of `entity` with `field` = row.id is readable through that entity's own parentRef gate (host visibility).
3175
+ allLinks?: {
3176
+ entity: string;
3177
+ field: string;
3178
+ }; // Like `anyLink`, but every live link row must be readable, and there must be at least one.
3165
3179
  fileOwner?: {
3166
3180
  field: string;
3167
3181
  }; // Row readable only if the file_ref in `field` is live and readable through its owner: owner row readable (ownership, parentRef, handler gate) and the field read access on the file_ref's fieldName passes.
3168
- linkExemptRoles?: string[]; // Roles that skip `anyLink` (they also see unlinked rows).
3182
+ linkExemptRoles?: string[]; // Roles that skip `anyLink` / `allLinks` (they also see unlinked rows).
3169
3183
  };
3170
3184
  ```
3171
3185
 
@@ -4769,6 +4783,7 @@ type MetricSpec = {
4769
4783
  navigate?: MetricNavigate;
4770
4784
  tone?: DashboardStatTone; // Tile color as on dashboard stat panels; not applied while the value is 0.
4771
4785
  icon?: NavIconKey; // Icon chip of the tile; only drawn with `metricsStyle: "tiles"`.
4786
+ format?: FormatSpec; // Formats the displayed value (e.g. `date`, `enumOption`); the tone still resolves on the raw value.
4772
4787
  };
4773
4788
  type RecordHeaderSpec = {
4774
4789
  title: string;
package/agents/api.md CHANGED
@@ -8,13 +8,13 @@ Format: one line per function or constant, object and options types as property
8
8
 
9
9
  | Import path | File | Exports |
10
10
  | --- | --- | --- |
11
- | `@cosmicdrift/kumiko-framework/engine` | `api/framework-engine.md` | 411 |
11
+ | `@cosmicdrift/kumiko-framework/engine` | `api/framework-engine.md` | 419 |
12
12
  | `@cosmicdrift/kumiko-framework/errors` | `api/framework-errors.md` | 25 |
13
13
  | `@cosmicdrift/kumiko-framework/ui-types` | `api/framework-ui-types.md` | 154 |
14
14
  | `@cosmicdrift/kumiko-framework/db` | `api/framework-db.md` | 150 |
15
15
  | `@cosmicdrift/kumiko-framework/event-store` | `api/framework-event-store.md` | 38 |
16
16
  | `@cosmicdrift/kumiko-framework/seeding` | `api/framework-seeding.md` | 6 |
17
17
  | `@cosmicdrift/kumiko-renderer/web` | `api/renderer-web.md` | 298 |
18
- | `@cosmicdrift/kumiko-testing/stack` | `api/testing-stack.md` | 21 |
18
+ | `@cosmicdrift/kumiko-testing/stack` | `api/testing-stack.md` | 22 |
19
19
  | `@cosmicdrift/kumiko-testing/helpers` | `api/testing-helpers.md` | 71 |
20
20
  | `@cosmicdrift/kumiko-dev-server/testing` | `api/dev-server-testing.md` | 5 |
@@ -0,0 +1,98 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from boot rule tables (`*-rules.ts`, `boot-rules.ts`). Do not edit. -->
2
+
3
+ # Boot rules
4
+
5
+ `validateBoot` reports every violation as `- [<id>] <message>`, followed by a `Fix:` and a `Rule:` line that links the entry below. Violations with an `unclassified/<check>` id have no entry yet. `kumiko-schema validate --json` returns the same ids.
6
+
7
+ ## api
8
+
9
+ | Rule | Summary | Fix | Source |
10
+ | --- | --- | --- | --- |
11
+ | <a id="api-provider-not-mounted"></a>`api/provider-not-mounted` | The feature exposing a used API is part of the app's feature list. | Register the provider feature in the app's feature list. | `packages/framework/src/engine/boot-validator/api-ext-rules.ts` |
12
+ | <a id="api-provider-not-required"></a>`api/provider-not-required` | A feature using an API lists the exposing feature in requires or optionalRequires. | Add the exposing feature via r.requires({ features: [...] }). | `packages/framework/src/engine/boot-validator/api-ext-rules.ts` |
13
+ | <a id="api-unknown-api"></a>`api/unknown-api` | Every r.usesApi name is exposed by some mounted feature. | Mount the feature that exposes the API or fix the API name. | `packages/framework/src/engine/boot-validator/api-ext-rules.ts` |
14
+
15
+ ## boot-check
16
+
17
+ | Rule | Summary | Fix | Source |
18
+ | --- | --- | --- | --- |
19
+ | <a id="boot-check-failed"></a>`boot-check/failed` | A feature's r.bootCheck invariant holds for the mounted feature set. | Satisfy the invariant named in the message or unmount the feature that declares it. | `packages/framework/src/engine/boot-validator/boot-check-rules.ts` |
20
+
21
+ ## entity-list
22
+
23
+ | Rule | Summary | Fix | Source |
24
+ | --- | --- | --- | --- |
25
+ | <a id="entity-list-default-sort-not-listed"></a>`entity-list/default-sort-not-listed` | defaultSort.field is one of the listed columns. | Pick a field from columns or add it there. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
26
+ | <a id="entity-list-default-sort-not-sortable"></a>`entity-list/default-sort-not-sortable` | The defaultSort column is sortable. | Mark the field sortable or pick another defaultSort field. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
27
+ | <a id="entity-list-default-sort-required"></a>`entity-list/default-sort-required` | A list with active search or filterable fields declares a defaultSort. | Set defaultSort: { field, direction } on the screen. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
28
+ | <a id="entity-list-detail-without-navigate"></a>`entity-list/detail-without-navigate` | A list whose entity has a detail screen offers a navigate row action. | Add a navigate rowAction with id "view" or "edit". | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
29
+ | <a id="entity-list-searchable-false"></a>`entity-list/searchable-false` | Operator lists are searchable unless they are on the searchable-false whitelist. | Set searchable: true or add the screen id to the whitelist. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
30
+ | <a id="entity-list-unknown-column"></a>`entity-list/unknown-column` | Every column without a label names an entity field, derived field or row-meta column. | Fix the column field name or give the column a label. | `packages/framework/src/engine/boot-validator/entity-list-screens-rules.ts` |
31
+
32
+ ## extension
33
+
34
+ | Rule | Summary | Fix | Source |
35
+ | --- | --- | --- | --- |
36
+ | <a id="extension-provider-not-required"></a>`extension/provider-not-required` | A feature using an extension lists the defining feature in requires or optionalRequires. | Add the defining feature via r.requires({ features: [...] }). | `packages/framework/src/engine/boot-validator/api-ext-rules.ts` |
37
+ | <a id="extension-unknown-extension"></a>`extension/unknown-extension` | Every extension a feature uses is defined by some mounted feature. | Register the feature that defines the extension or fix the extension name. | `packages/framework/src/engine/boot-validator/api-ext-rules.ts` |
38
+
39
+ ## i18n
40
+
41
+ | Rule | Summary | Fix | Source |
42
+ | --- | --- | --- | --- |
43
+ | <a id="i18n-locale-inconsistent"></a>`i18n/locale-inconsistent` | Every locale a feature uses is maintained for every one of its translation keys. | Add the missing locale to the key, or move that locale into a locale package. | `packages/framework/src/engine/boot-validator/i18n-keys-rules.ts` |
44
+ | <a id="i18n-missing-feature-key"></a>`i18n/missing-feature-key` | Every translation key a feature surface requires is defined. | Add the key via r.translations(...) in the feature. | `packages/framework/src/engine/boot-validator/i18n-keys-rules.ts` |
45
+ | <a id="i18n-missing-field-value-translation"></a>`i18n/missing-field-value-translation` | With requireFieldValueTranslations, select options and embedded cells are translated. | Add the keys via r.translations(...) in the owning feature. | `packages/framework/src/engine/boot-validator/i18n-keys-rules.ts` |
46
+ | <a id="i18n-missing-required-locale"></a>`i18n/missing-required-locale` | Every translation key exists in every locale the app requires. | Load the matching locale package or add the missing translations. | `packages/framework/src/engine/boot-validator/i18n-keys-rules.ts` |
47
+ | <a id="i18n-missing-settings-hub-key"></a>`i18n/missing-settings-hub-key` | Every translation key the generated settings hub requires is defined. | Add the key via r.translations(...) in the owning feature. | `packages/framework/src/engine/boot-validator/i18n-keys-rules.ts` |
48
+
49
+ ## list
50
+
51
+ | Rule | Summary | Fix | Source |
52
+ | --- | --- | --- | --- |
53
+ | <a id="list-card-meta-overflow"></a>`list/card-meta-overflow` | A card list shows at most the maximum number of meta columns. | Reduce each list to the maximum number of meta columns or mark the others hideOnNarrow: true. | `packages/framework/src/engine/boot-validator/card-meta-overflow-rules.ts` |
54
+
55
+ ## pii
56
+
57
+ | Rule | Summary | Fix | Source |
58
+ | --- | --- | --- | --- |
59
+ | <a id="pii-lookupable-non-text"></a>`pii/lookupable-non-text` | Blind-index lookups apply to text fields only. | Use a text field or set find: "none". | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
60
+ | <a id="pii-lookupable-without-subject"></a>`pii/lookupable-without-subject` | A blind-index lookup requires a subject annotation on the field. | Add a personal annotation or set find: "none". | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
61
+ | <a id="pii-multiple-subject-annotations"></a>`pii/multiple-subject-annotations` | A field carries at most one subject-key annotation. | Keep a single personal annotation on the field. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
62
+ | <a id="pii-personal-jsonb-custom-fields"></a>`pii/personal-jsonb-custom-fields` | The custom-fields jsonb column cannot carry a personal annotation. | Drop the personal annotation. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
63
+ | <a id="pii-personal-jsonb-flag"></a>`pii/personal-jsonb-flag` | A personal jsonb field cannot be sortable, filterable, searchable or lookupable, its value is a ciphertext string. | Remove the flag or the personal annotation. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
64
+ | <a id="pii-personal-jsonb-index"></a>`pii/personal-jsonb-index` | A personal jsonb field is not part of an entity index. | Drop the index or the personal annotation. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
65
+ | <a id="pii-personal-of-invalid-when-absent"></a>`pii/personal-of-invalid-when-absent` | personal.whenAbsent is either "tenant" or "self". | Remove whenAbsent or set it to "tenant" or "self". | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
66
+ | <a id="pii-personal-of-missing-owner-name"></a>`pii/personal-of-missing-owner-name` | personal: { of } names the owner field. | Pass the name of the owner field: personal: { of: "<ownerField>" }. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
67
+ | <a id="pii-personal-of-optional-owner"></a>`pii/personal-of-optional-owner` | An optional owner field of personal: { of } declares a whenAbsent fallback. | Set whenAbsent to "tenant" or "self", or make the owner field required. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
68
+ | <a id="pii-personal-of-owner-type"></a>`pii/personal-of-owner-type` | The owner field named by personal: { of } is a reference or text field. | Use a reference or text field as owner. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
69
+ | <a id="pii-personal-of-unknown-owner"></a>`pii/personal-of-unknown-owner` | The owner field named by personal: { of } exists on the entity. | Point personal.of at an existing field of the entity. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
70
+ | <a id="pii-plaintext-user-reference"></a>`pii/plaintext-user-reference` | A reference to the user entity is never plaintext. | Use the default reference field or personal: "ref" instead of personal: false. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
71
+ | <a id="pii-sensitive-searchable"></a>`pii/sensitive-searchable` | A sensitive field is never searchable. | Drop searchable, or add writeOnly to never return the value. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
72
+ | <a id="pii-sensitive-without-ciphertext"></a>`pii/sensitive-without-ciphertext` | A sensitive field is stored as ciphertext at rest. | Add a subject annotation or encrypted: true. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
73
+ | <a id="pii-subject-key-sortable"></a>`pii/subject-key-sortable` | A field with a subject-key annotation is not sortable, its column is ciphertext. | Drop sortable, use find: "exact" for equality lookups, or keep the field plaintext. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
74
+ | <a id="pii-text-without-personal-stance"></a>`pii/text-without-personal-stance` | Every text and longText field declares whether it holds personal data. | Declare a personal stance, or personal: false with a reason stating why the value is not personal data. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
75
+ | <a id="pii-write-only-conflicting-flag"></a>`pii/write-only-conflicting-flag` | A writeOnly field cannot be defaulted, searched, sorted, filtered or looked up. | Remove either writeOnly or the conflicting flag. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
76
+ | <a id="pii-write-only-embedded-sub-field"></a>`pii/write-only-embedded-sub-field` | writeOnly is only supported on top-level text fields, not on embedded sub-fields. | Remove writeOnly from the sub-field or declare it on a top-level text field. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
77
+ | <a id="pii-write-only-non-text"></a>`pii/write-only-non-text` | writeOnly applies to text fields only. | Use a text field or remove writeOnly. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
78
+ | <a id="pii-write-only-without-sensitive"></a>`pii/write-only-without-sensitive` | A writeOnly field is also sensitive. | Declare the field with find: "secret". | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
79
+
80
+ ## retention
81
+
82
+ | Rule | Summary | Fix | Source |
83
+ | --- | --- | --- | --- |
84
+ | <a id="retention-unknown-reference"></a>`retention/unknown-reference` | retention.reference names an existing field or a framework timestamp. | Point retention.reference at an existing field or a framework-managed timestamp. | `packages/framework/src/engine/boot-validator/pii-retention-rules.ts` |
85
+
86
+ ## screen
87
+
88
+ | Rule | Summary | Fix | Source |
89
+ | --- | --- | --- | --- |
90
+ | <a id="screen-action-icon"></a>`screen/action-icon` | Every screen action resolves to a registered icon. | Set icon to a registered NAV_ICONS key or use an action id that resolves one via the default icon map. | `packages/framework/src/engine/boot-validator/screens-actions-rules.ts` |
91
+
92
+ ## user-data-rights
93
+
94
+ | Rule | Summary | Fix | Source |
95
+ | --- | --- | --- | --- |
96
+ | <a id="user-data-rights-empty-hook"></a>`user-data-rights/empty-hook` | An EXT_USER_DATA registration declares at least one of an export or a delete hook. | Register at least one hook. | `packages/bundled-features/src/user-data-rights/boot-rules.ts` |
97
+ | <a id="user-data-rights-export-without-delete"></a>`user-data-rights/export-without-delete` | An entity exported via EXT_USER_DATA also registers a delete hook. | Add a delete hook, or a documented no-op delete if erasure is handled elsewhere. | `packages/bundled-features/src/user-data-rights/boot-rules.ts` |
98
+ | <a id="user-data-rights-missing-hook"></a>`user-data-rights/missing-hook` | An entity with user-subject fields registers an EXT_USER_DATA hook. | Register r.useExtension(EXT_USER_DATA, ...) for the entity, or a documented no-op hook if it is covered elsewhere. | `packages/bundled-features/src/user-data-rights/boot-rules.ts` |
@@ -217,7 +217,12 @@ describe("cascade: deleting member removes tasks", () => {
217
217
 
218
218
  ```ts
219
219
  import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
220
- import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
220
+ import {
221
+ createEntity,
222
+ createReferenceField,
223
+ createTextField,
224
+ defineFeature,
225
+ } from "@cosmicdrift/kumiko-framework/engine";
221
226
 
222
227
  export const teamEntity = createEntity({
223
228
  table: "read_sample_teams",
@@ -230,7 +235,12 @@ export const memberEntity = createEntity({
230
235
  table: "read_sample_members",
231
236
  fields: {
232
237
  name: createTextField({ personal: { of: "id" }, find: "none", required: true }),
233
- teamId: createTextField({ personal: false, reason: "is_system_identifier", required: true }),
238
+ teamId: createReferenceField({
239
+ entity: "team",
240
+ personal: false,
241
+ reason: "is_system_identifier",
242
+ required: true,
243
+ }),
234
244
  role: createTextField({ personal: false, reason: "is_business_data" }),
235
245
  },
236
246
  });
@@ -239,7 +249,7 @@ export const taskEntity = createEntity({
239
249
  table: "read_sample_member_tasks",
240
250
  fields: {
241
251
  title: createTextField({ personal: false, reason: "is_business_data", required: true }),
242
- memberId: createTextField({ personal: "ref", required: true }),
252
+ memberId: createReferenceField({ entity: "member", personal: "ref", required: true }),
243
253
  },
244
254
  });
245
255
 
@@ -20,7 +20,7 @@ export type AppPorts = {
20
20
  readonly infra?: AppInfraPorts;
21
21
  };
22
22
  /** The boot-validator options an app owns; `env` and the cipher are injected by the runners. */
23
- export type AppValidateBootOptions = Pick<ValidateBootOptions, "navAllowlist" | "navAllowlistExempt" | "requiredLocales" | "warnOnUniqueAccessRoles">;
23
+ export type AppValidateBootOptions = Pick<ValidateBootOptions, "navAllowlist" | "navAllowlistExempt" | "requiredLocales" | "requireFieldValueTranslations" | "warnOnUniqueAccessRoles">;
24
24
  export type AppConfig = {
25
25
  /** Features mounted in every environment: dev, tests, prod. */
26
26
  readonly features: readonly FeatureDefinition[];
@@ -42,7 +42,9 @@ function liveRowSql(entity, table, alias) {
42
42
  function isRoleExempt(gate, user) {
43
43
  return user.roles.some((role) => gate.linkExemptRoles?.includes(role) === true);
44
44
  }
45
- function anyLinkClause(input, link) {
45
+ // "any": one readable live link suffices. "all": additionally no live link may be unreadable;
46
+ // COALESCE keeps a NULL from the ownership SQL from counting as readable.
47
+ function linkClause(input, link, mode) {
46
48
  const { option, user, db, tableName } = input;
47
49
  const linkEntity = option.entities.get(link.entity);
48
50
  if (linkEntity?.parentRef === undefined)
@@ -56,18 +58,24 @@ function anyLinkClause(input, link) {
56
58
  if (linkOwnership.kind === "empty")
57
59
  return EMPTY;
58
60
  const sql = new GateSql();
59
- const conditions = [
61
+ const linkRowsOfThisRow = () => [
60
62
  `"${LINK_ALIAS}"."${physicalColumnName(linkTable, link.field)}"::text = "${tableName}"."${physicalColumnName(input.table, "id")}"::text`,
61
63
  ...tenantScopeSql(sql, db, linkTable, LINK_ALIAS),
62
64
  ...liveRowSql(linkEntity, linkTable, LINK_ALIAS),
63
65
  ];
66
+ const existsLink = (conditions) => `EXISTS (SELECT 1 FROM "${linkTableName}" AS "${LINK_ALIAS}" WHERE ${conditions.join(" AND ")})`;
67
+ const readable = linkRowsOfThisRow();
64
68
  if (linkOwnership.kind === "sql")
65
- conditions.push(sql.embed(linkOwnership));
66
- return {
67
- kind: "sql",
68
- sqlText: `EXISTS (SELECT 1 FROM "${linkTableName}" AS "${LINK_ALIAS}" WHERE ${conditions.join(" AND ")})`,
69
- params: sql.params,
70
- };
69
+ readable.push(sql.embed(linkOwnership));
70
+ let sqlText = existsLink(readable);
71
+ if (mode === "all" && linkOwnership.kind === "sql") {
72
+ const unreadable = [
73
+ ...linkRowsOfThisRow(),
74
+ `NOT COALESCE((${sql.embed(linkOwnership)}), FALSE)`,
75
+ ];
76
+ sqlText = `(${sqlText} AND NOT ${existsLink(unreadable)})`;
77
+ }
78
+ return { kind: "sql", sqlText, params: sql.params };
71
79
  }
72
80
  // Every registered entity with a file field can own a file. Join rows and gated entities
73
81
  // are left out: their readability is not resolvable without recursion.
@@ -178,8 +186,13 @@ export function buildReadGateClause(entity, table, tableName, user, db, option)
178
186
  return EMPTY;
179
187
  const input = { entity, table, tableName, user, db, option };
180
188
  let clause = PASS;
181
- if (gate.anyLink !== undefined && !isRoleExempt(gate, user)) {
182
- clause = combineClauses(clause, anyLinkClause(input, gate.anyLink));
189
+ if (!isRoleExempt(gate, user)) {
190
+ if (gate.anyLink !== undefined) {
191
+ clause = combineClauses(clause, linkClause(input, gate.anyLink, "any"));
192
+ }
193
+ if (gate.allLinks !== undefined) {
194
+ clause = combineClauses(clause, linkClause(input, gate.allLinks, "all"));
195
+ }
183
196
  }
184
197
  if (gate.fileOwner !== undefined) {
185
198
  clause = combineClauses(clause, fileOwnerClause(input, gate.fileOwner.field));
@@ -151,7 +151,7 @@ function applyStatement(schema, statement, context) {
151
151
  // skip: DO block fully handled above
152
152
  return;
153
153
  }
154
- const createAs = statement.match(new RegExp(`^CREATE\\s+(?:UNLOGGED\\s+)?TABLE\\s+(IF NOT EXISTS\\s+)?${IDENT}\\s*(?:\\(([^)]*)\\)\\s*)?AS\\b([\\s\\S]*)$`, "i"));
154
+ const createAs = statement.match(new RegExp(`^CREATE\\s+(?:UNLOGGED\\s+)?TABLE\\s+(IF NOT EXISTS\\s+)?${IDENT}(?:\\s*\\(([^)]*)\\)\\s*|\\s+)AS\\b([\\s\\S]*)$`, "i"));
155
155
  if (createAs?.[2] !== undefined && createAs[4] !== undefined) {
156
156
  if (!(createAs[1] !== undefined && schema.has(createAs[2]))) {
157
157
  const explicitColumns = createAs[3] === undefined ? undefined : parseColumnNames(createAs[3]);
@@ -0,0 +1,27 @@
1
+ export declare const API_EXT_RULES: {
2
+ readonly unknownApi: {
3
+ readonly id: "api/unknown-api";
4
+ readonly summary: "Every r.usesApi name is exposed by some mounted feature.";
5
+ readonly fix: "Mount the feature that exposes the API or fix the API name.";
6
+ };
7
+ readonly providerNotRequired: {
8
+ readonly id: "api/provider-not-required";
9
+ readonly summary: "A feature using an API lists the exposing feature in requires or optionalRequires.";
10
+ readonly fix: "Add the exposing feature via r.requires({ features: [...] }).";
11
+ };
12
+ readonly providerNotMounted: {
13
+ readonly id: "api/provider-not-mounted";
14
+ readonly summary: "The feature exposing a used API is part of the app's feature list.";
15
+ readonly fix: "Register the provider feature in the app's feature list.";
16
+ };
17
+ readonly unknownExtension: {
18
+ readonly id: "extension/unknown-extension";
19
+ readonly summary: "Every extension a feature uses is defined by some mounted feature.";
20
+ readonly fix: "Register the feature that defines the extension or fix the extension name.";
21
+ };
22
+ readonly extensionProviderNotRequired: {
23
+ readonly id: "extension/provider-not-required";
24
+ readonly summary: "A feature using an extension lists the defining feature in requires or optionalRequires.";
25
+ readonly fix: "Add the defining feature via r.requires({ features: [...] }).";
26
+ };
27
+ };
@@ -0,0 +1,27 @@
1
+ export const API_EXT_RULES = {
2
+ unknownApi: {
3
+ id: "api/unknown-api",
4
+ summary: "Every r.usesApi name is exposed by some mounted feature.",
5
+ fix: "Mount the feature that exposes the API or fix the API name.",
6
+ },
7
+ providerNotRequired: {
8
+ id: "api/provider-not-required",
9
+ summary: "A feature using an API lists the exposing feature in requires or optionalRequires.",
10
+ fix: "Add the exposing feature via r.requires({ features: [...] }).",
11
+ },
12
+ providerNotMounted: {
13
+ id: "api/provider-not-mounted",
14
+ summary: "The feature exposing a used API is part of the app's feature list.",
15
+ fix: "Register the provider feature in the app's feature list.",
16
+ },
17
+ unknownExtension: {
18
+ id: "extension/unknown-extension",
19
+ summary: "Every extension a feature uses is defined by some mounted feature.",
20
+ fix: "Register the feature that defines the extension or fix the extension name.",
21
+ },
22
+ extensionProviderNotRequired: {
23
+ id: "extension/provider-not-required",
24
+ summary: "A feature using an extension lists the defining feature in requires or optionalRequires.",
25
+ fix: "Add the defining feature via r.requires({ features: [...] }).",
26
+ },
27
+ };
@@ -1,4 +1,7 @@
1
1
  import { createFallbackLogger } from "../../logging/utils.js";
2
+ import { API_EXT_RULES } from "./api-ext-rules.js";
3
+ import { collectBootRuleViolations } from "./boot-report.js";
4
+ import { bootRuleError } from "./boot-rule.js";
2
5
  const log = createFallbackLogger("kumiko:boot");
3
6
  // --- Cross-feature API exposure / usage matching ---
4
7
  //
@@ -13,42 +16,52 @@ const log = createFallbackLogger("kumiko:boot");
13
16
  // apiNames must be globally unique across features; validateBoot() checks
14
17
  // this before the per-feature walk.
15
18
  export function validateApiExposureMatching(feature, allExposedApis, featureMap) {
16
- for (const apiName of feature.usedApis) {
19
+ collectBootRuleViolations(feature.usedApis, (apiName) => {
17
20
  const providerFeature = allExposedApis.get(apiName);
18
21
  if (!providerFeature) {
19
22
  const known = [...allExposedApis.keys()].sort().join(", ") || "(none)";
20
- throw new Error(`[Feature ${feature.name}] r.usesApi("${apiName}") but no feature exposes that API. Known exposed APIs: ${known}`);
23
+ throw bootRuleError(API_EXT_RULES.unknownApi, {
24
+ message: `[Feature ${feature.name}] r.usesApi("${apiName}") but no feature exposes that API. Known exposed APIs: ${known}`,
25
+ });
21
26
  }
22
27
  if (providerFeature === feature.name) {
23
28
  log.warn(`[Feature ${feature.name}] r.usesApi("${apiName}") on its own r.exposesApi — typically a refactor leftover. Call the impl directly instead.`);
24
- continue;
29
+ return;
25
30
  }
26
31
  const allDeps = [...feature.requires, ...feature.optionalRequires];
27
32
  if (!allDeps.includes(providerFeature)) {
28
- throw new Error(`[Feature ${feature.name}] r.usesApi("${apiName}") is exposed by "${providerFeature}" but feature is not in requires/optionalRequires. Add r.requires({ features: ["${providerFeature}"] }).`);
33
+ throw bootRuleError(API_EXT_RULES.providerNotRequired, {
34
+ message: `[Feature ${feature.name}] r.usesApi("${apiName}") is exposed by "${providerFeature}" but feature is not in requires/optionalRequires. Add r.requires({ features: ["${providerFeature}"] }).`,
35
+ });
29
36
  }
30
37
  // Sanity: the provider feature must exist in this app's feature set.
31
38
  // This should always hold when allExposedApis was built from `features`,
32
39
  // but remains defensive for unclear constructor paths.
33
40
  if (!featureMap.has(providerFeature)) {
34
- throw new Error(`[Feature ${feature.name}] internal: r.usesApi("${apiName}") points to provider "${providerFeature}" which is not in feature map. Register the provider feature in the app's feature list.`);
41
+ throw bootRuleError(API_EXT_RULES.providerNotMounted, {
42
+ message: `[Feature ${feature.name}] internal: r.usesApi("${apiName}") points to provider "${providerFeature}" which is not in feature map. Register the provider feature in the app's feature list.`,
43
+ });
35
44
  }
36
- }
45
+ });
37
46
  }
38
47
  // --- Extension usage validation ---
39
48
  export function validateExtensionUsages(feature, extensionProviders) {
40
- for (const usage of feature.extensionUsages) {
49
+ collectBootRuleViolations(feature.extensionUsages, (usage) => {
41
50
  const providerFeature = extensionProviders.get(usage.extensionName);
42
51
  if (!providerFeature) {
43
- throw new Error(`Feature "${feature.name}" uses extension "${usage.extensionName}" on entity "${usage.entityName}" but no feature defines that extension. Register the feature that defines it or fix the extension name.`);
52
+ throw bootRuleError(API_EXT_RULES.unknownExtension, {
53
+ message: `Feature "${feature.name}" uses extension "${usage.extensionName}" on entity "${usage.entityName}" but no feature defines that extension. Register the feature that defines it or fix the extension name.`,
54
+ });
44
55
  }
45
- // self-extension is legitimate: requires(self) would be circular.
46
56
  if (providerFeature === feature.name) {
47
- continue;
57
+ // skip: self-extension is legitimate, requires(self) would be circular
58
+ return;
48
59
  }
49
60
  const allDeps = [...feature.requires, ...feature.optionalRequires];
50
61
  if (!allDeps.includes(providerFeature)) {
51
- throw new Error(`Feature "${feature.name}" uses extension "${usage.extensionName}" but missing requires("${providerFeature}"). Add r.requires({ features: ["${providerFeature}"] }).`);
62
+ throw bootRuleError(API_EXT_RULES.extensionProviderNotRequired, {
63
+ message: `Feature "${feature.name}" uses extension "${usage.extensionName}" but missing requires("${providerFeature}"). Add r.requires({ features: ["${providerFeature}"] }).`,
64
+ });
52
65
  }
53
- }
66
+ });
54
67
  }
@@ -0,0 +1,7 @@
1
+ export declare const BOOT_CHECK_RULES: {
2
+ readonly failed: {
3
+ readonly id: "boot-check/failed";
4
+ readonly summary: "A feature's r.bootCheck invariant holds for the mounted feature set.";
5
+ readonly fix: "Satisfy the invariant named in the message or unmount the feature that declares it.";
6
+ };
7
+ };
@@ -0,0 +1,7 @@
1
+ export const BOOT_CHECK_RULES = {
2
+ failed: {
3
+ id: "boot-check/failed",
4
+ summary: "A feature's r.bootCheck invariant holds for the mounted feature set.",
5
+ fix: "Satisfy the invariant named in the message or unmount the feature that declares it.",
6
+ },
7
+ };
@@ -1,16 +1,28 @@
1
+ import { BOOT_CHECK_RULES } from "./boot-check-rules.js";
2
+ import { BootRuleFailures, BootRuleViolation, bootRuleError } from "./boot-rule.js";
1
3
  // r.bootCheck(fn) lets a feature declare its own mount-invariant instead of
2
4
  // relying on framework-internal knowledge (the gdpr-storage.ts guards are
3
5
  // the framework-owned version of this same idea). Each registered fn gets
4
6
  // the full mounted-feature set and throws to fail the boot; we wrap the
5
7
  // message with the owning feature's name so the error text points back at
6
8
  // the feature that declared the check.
9
+ function prefixFailure(failure, prefix) {
10
+ const rule = failure instanceof BootRuleViolation ? failure.rule : BOOT_CHECK_RULES.failed;
11
+ return new BootRuleViolation(rule, prefix + failure.message);
12
+ }
7
13
  export function runFeatureBootCheck(feature, check, features) {
8
14
  const ctx = { features };
15
+ const prefix = `[Feature ${feature.name}] r.bootCheck failed: `;
9
16
  try {
10
17
  check(ctx);
11
18
  }
12
19
  catch (err) {
20
+ if (err instanceof BootRuleViolation)
21
+ throw prefixFailure(err, prefix);
22
+ if (err instanceof BootRuleFailures) {
23
+ throw new BootRuleFailures(err.failures.map((failure) => prefixFailure(failure, prefix)));
24
+ }
13
25
  const message = err instanceof Error ? err.message : String(err);
14
- throw new Error(`[Feature ${feature.name}] r.bootCheck failed: ${message}`);
26
+ throw bootRuleError(BOOT_CHECK_RULES.failed, { message: prefix + message });
15
27
  }
16
28
  }