@cosmicdrift/kumiko-framework 0.401.0 → 0.403.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 (53) hide show
  1. package/AGENTS.md +4 -2
  2. package/agents/api/framework-engine.md +29 -1
  3. package/agents/api/framework-ui-types.md +4 -0
  4. package/agents/api/renderer-web.md +1 -1
  5. package/agents/api/types.md +34 -0
  6. package/agents/api.md +1 -1
  7. package/agents/boot-rules.md +110 -0
  8. package/agents/examples/dashboard-stats.md +58 -3
  9. package/dist/db/replay-migration-sql.js +1 -1
  10. package/dist/engine/boot-validator/api-ext-rules.d.ts +27 -0
  11. package/dist/engine/boot-validator/api-ext-rules.js +27 -0
  12. package/dist/engine/boot-validator/api-ext.js +25 -12
  13. package/dist/engine/boot-validator/boot-check-rules.d.ts +7 -0
  14. package/dist/engine/boot-validator/boot-check-rules.js +7 -0
  15. package/dist/engine/boot-validator/boot-check.js +13 -1
  16. package/dist/engine/boot-validator/boot-phases-app.js +5 -0
  17. package/dist/engine/boot-validator/boot-report.d.ts +1 -0
  18. package/dist/engine/boot-validator/boot-report.js +58 -7
  19. package/dist/engine/boot-validator/boot-rule.d.ts +20 -0
  20. package/dist/engine/boot-validator/boot-rule.js +33 -2
  21. package/dist/engine/boot-validator/boot-rules.d.ts +246 -0
  22. package/dist/engine/boot-validator/boot-rules.js +19 -0
  23. package/dist/engine/boot-validator/card-meta-overflow-rules.d.ts +7 -0
  24. package/dist/engine/boot-validator/card-meta-overflow-rules.js +7 -0
  25. package/dist/engine/boot-validator/card-meta-overflow.js +5 -1
  26. package/dist/engine/boot-validator/dashboard-stat-navigate.d.ts +2 -0
  27. package/dist/engine/boot-validator/dashboard-stat-navigate.js +77 -0
  28. package/dist/engine/boot-validator/entity-list-screens-rules.d.ts +37 -0
  29. package/dist/engine/boot-validator/entity-list-screens-rules.js +37 -0
  30. package/dist/engine/boot-validator/entity-list-screens.js +86 -41
  31. package/dist/engine/boot-validator/i18n-keys-rules.d.ts +27 -0
  32. package/dist/engine/boot-validator/i18n-keys-rules.js +27 -0
  33. package/dist/engine/boot-validator/i18n-keys.js +27 -16
  34. package/dist/engine/boot-validator/named-filter-rules.d.ts +32 -0
  35. package/dist/engine/boot-validator/named-filter-rules.js +32 -0
  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 +76 -33
  39. package/dist/engine/boot-validator/projection-list-screens.js +13 -0
  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 +1 -0
  44. package/dist/engine/entity-handlers.js +13 -1
  45. package/dist/engine/factories.d.ts +2 -0
  46. package/dist/engine/index.d.ts +3 -0
  47. package/dist/engine/index.js +3 -0
  48. package/dist/engine/resolve-named-list-filters.d.ts +3 -0
  49. package/dist/engine/resolve-named-list-filters.js +30 -0
  50. package/dist/i18n/required-surface-keys.js +3 -0
  51. package/dist/schema-cli/validate-command.js +4 -1
  52. package/package.json +3 -3
  53. package/src/changes.json +18 -0
@@ -1,7 +1,10 @@
1
1
  import { holdsFileRefs } from "../../files/file-field-slots.js";
2
2
  import { createFallbackLogger } from "../../logging/utils.js";
3
3
  import { isUserEntityReference } from "../user-entity-reference.js";
4
+ import { collectBootRuleViolations } from "./boot-report.js";
5
+ import { bootRuleError } from "./boot-rule.js";
4
6
  import { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./entity-handler.js";
7
+ import { PII_RULES } from "./pii-retention-rules.js";
5
8
  const log = createFallbackLogger("kumiko:boot");
6
9
  // Framework-managed timestamp fields may be used as retention.reference
7
10
  // even when they are not declared in entity.fields.
@@ -46,7 +49,9 @@ function validateWriteOnlyField(input) {
46
49
  for (const [subName, subField] of Object.entries(field.schema)) {
47
50
  if ("writeOnly" in subField && subField.writeOnly === true) {
48
51
  // @cast-boundary schema-walk
49
- throw new Error(`${where} declares writeOnly on embedded sub-field "${subName}" — writeOnly is only supported on top-level text fields. Remove writeOnly from the sub-field or declare it on a top-level text field.`);
52
+ throw bootRuleError(PII_RULES.writeOnlyEmbeddedSubField, {
53
+ message: `${where} declares writeOnly on embedded sub-field "${subName}" — writeOnly is only supported on top-level text fields. Remove writeOnly from the sub-field or declare it on a top-level text field.`,
54
+ });
50
55
  }
51
56
  }
52
57
  // skip: embedded fields carry no writeOnly of their own; sub-fields are checked above
@@ -57,15 +62,21 @@ function validateWriteOnlyField(input) {
57
62
  if (flags.writeOnly !== true)
58
63
  return;
59
64
  if (field.type !== "text") {
60
- throw new Error(`${where} declares { writeOnly: true } but has type "${field.type}" — writeOnly only applies to text fields. Use a text field or remove writeOnly.`);
65
+ throw bootRuleError(PII_RULES.writeOnlyNonText, {
66
+ message: `${where} declares { writeOnly: true } but has type "${field.type}" — writeOnly only applies to text fields. Use a text field or remove writeOnly.`,
67
+ });
61
68
  }
62
69
  if (field.sensitive !== true) {
63
- throw new Error(`${where} declares { writeOnly: true } without { sensitive: true } — add find: "secret" (ciphertext at rest, stripped from event echoes).`);
70
+ throw bootRuleError(PII_RULES.writeOnlyWithoutSensitive, {
71
+ message: `${where} declares { writeOnly: true } without { sensitive: true } — add find: "secret" (ciphertext at rest, stripped from event echoes).`,
72
+ });
64
73
  }
65
74
  for (const flag of WRITE_ONLY_FORBIDDEN_FLAGS) {
66
75
  const value = flags[flag];
67
76
  if (value !== undefined && value !== false) {
68
- throw new Error(`${where} combines { writeOnly: true } with { ${flag} } — a value that is never returned cannot be defaulted, searched, sorted, filtered or looked up. Remove either writeOnly or the conflicting flag.`);
77
+ throw bootRuleError(PII_RULES.writeOnlyConflictingFlag, {
78
+ message: `${where} combines { writeOnly: true } with { ${flag} } — a value that is never returned cannot be defaulted, searched, sorted, filtered or looked up. Remove either writeOnly or the conflicting flag.`,
79
+ });
69
80
  }
70
81
  }
71
82
  }
@@ -89,15 +100,21 @@ function validatePersonalJsonbField(input) {
89
100
  const flags = field; // @cast-boundary schema-walk
90
101
  for (const flag of JSONB_PERSONAL_FORBIDDEN_FLAGS) {
91
102
  if (flags[flag] !== undefined && flags[flag] !== false) {
92
- throw new Error(`${where} combines a personal annotation on a jsonb field with { ${flag} } — ${reason}. Remove the ${flag} flag or the personal annotation.`);
103
+ throw bootRuleError(PII_RULES.personalJsonbFlag, {
104
+ message: `${where} combines a personal annotation on a jsonb field with { ${flag} } — ${reason}. Remove the ${flag} flag or the personal annotation.`,
105
+ });
93
106
  }
94
107
  }
95
108
  const index = entity.indexes?.find((def) => def.columns.includes(fieldName));
96
109
  if (index) {
97
- throw new Error(`${where} is a personal jsonb field used in an entity index — ${reason}. Drop the index or the personal annotation.`);
110
+ throw bootRuleError(PII_RULES.personalJsonbIndex, {
111
+ message: `${where} is a personal jsonb field used in an entity index — ${reason}. Drop the index or the personal annotation.`,
112
+ });
98
113
  }
99
114
  if (fieldName === CUSTOM_FIELDS_COLUMN) {
100
- throw new Error(`${where} is the custom-fields jsonb column — its values are merged and searched per key, which ciphertext cannot support. Drop the personal annotation.`);
115
+ throw bootRuleError(PII_RULES.personalJsonbCustomFields, {
116
+ message: `${where} is the custom-fields jsonb column — its values are merged and searched per key, which ciphertext cannot support. Drop the personal annotation.`,
117
+ });
101
118
  }
102
119
  }
103
120
  // --- PII / Subject-Key Annotations + Retention validation ---
@@ -125,13 +142,13 @@ function validatePersonalJsonbField(input) {
125
142
  // Encryption and decryption are handled elsewhere; this validation runs early
126
143
  // so schema drift is detected during boot.
127
144
  export function validatePiiAndRetention(feature) {
128
- for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
129
- validateFieldAnnotations(feature.name, entityName, entity);
130
- validateEntityRetention(feature.name, entityName, entity);
131
- }
145
+ collectBootRuleViolations(Object.entries(feature.entities ?? {}), ([entityName, entity]) => {
146
+ const entityChecks = [validateFieldAnnotations, validateEntityRetention];
147
+ collectBootRuleViolations(entityChecks, (check) => check(feature.name, entityName, entity));
148
+ });
132
149
  }
133
- // Order matters: the first failing rule decides the boot error, and warnings
134
- // are emitted in this order.
150
+ // Every rule runs on every field; order only decides the order of the reported
151
+ // violations and warnings.
135
152
  const FIELD_RULES = [
136
153
  rejectMultipleSubjectAnnotations,
137
154
  rejectTextWithoutPersonalStance,
@@ -146,7 +163,7 @@ const FIELD_RULES = [
146
163
  ];
147
164
  function validateFieldAnnotations(featureName, entityName, entity) {
148
165
  const personalOfOwnerFieldNames = ownerFieldNamesReferencedByPersonalOf(entity.fields);
149
- for (const [fieldName, field] of Object.entries(entity.fields)) {
166
+ collectBootRuleViolations(Object.entries(entity.fields), ([fieldName, field]) => {
150
167
  const flags = field; // @cast-boundary schema-walk
151
168
  const input = {
152
169
  featureName,
@@ -158,9 +175,8 @@ function validateFieldAnnotations(featureName, entityName, entity) {
158
175
  subjectAnnotationCount: countSubjectAnnotations(flags),
159
176
  personalOfOwnerFieldNames,
160
177
  };
161
- for (const rule of FIELD_RULES)
162
- rule(input);
163
- }
178
+ collectBootRuleViolations(FIELD_RULES, (rule) => rule(input));
179
+ });
164
180
  }
165
181
  function countSubjectAnnotations(flags) {
166
182
  return [flags.pii, flags.userOwned, flags.tenantOwned, flags.recordOwned].filter(Boolean).length;
@@ -170,7 +186,9 @@ function fieldLocation(input) {
170
186
  }
171
187
  function rejectMultipleSubjectAnnotations(input) {
172
188
  if (input.subjectAnnotationCount > 1) {
173
- throw new Error(`${fieldLocation(input)} has multiple subject-key annotations (personal: "self" / "tenant" / { of: "<field>" }). Pick one — each field belongs to exactly one subject.`);
189
+ throw bootRuleError(PII_RULES.multipleSubjectAnnotations, {
190
+ message: `${fieldLocation(input)} has multiple subject-key annotations (personal: "self" / "tenant" / { of: "<field>" }). Pick one — each field belongs to exactly one subject.`,
191
+ });
174
192
  }
175
193
  }
176
194
  function rejectTextWithoutPersonalStance(input) {
@@ -179,7 +197,9 @@ function rejectTextWithoutPersonalStance(input) {
179
197
  input.subjectAnnotationCount === 0 &&
180
198
  !flags.allowPlaintext &&
181
199
  !flags.subjectRef) {
182
- throw new Error(`${fieldLocation(input)} (type "${field.type}") declares no personal stance. Build it with createTextField / createLongTextField and { personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false }; false additionally needs { reason: "..." } stating why the value is not personal data.`);
200
+ throw bootRuleError(PII_RULES.textWithoutPersonalStance, {
201
+ message: `${fieldLocation(input)} (type "${field.type}") declares no personal stance. Build it with createTextField / createLongTextField and { personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false }; false additionally needs { reason: "..." } stating why the value is not personal data.`,
202
+ });
183
203
  }
184
204
  }
185
205
  function rejectPlaintextUserReference(input) {
@@ -190,7 +210,9 @@ function rejectPlaintextUserReference(input) {
190
210
  // skip: references to other entities may stay plaintext
191
211
  if (!isUserEntityReference(field.entity))
192
212
  return;
193
- throw new Error(`${fieldLocation(input)} references the user entity "${field.entity}" with { personal: false } — a user id is a pseudonym that falls under Art.17 forget. Use createReferenceField({ entity: "${field.entity}" }) or { personal: "ref" }.`);
213
+ throw bootRuleError(PII_RULES.plaintextUserReference, {
214
+ message: `${fieldLocation(input)} references the user entity "${field.entity}" with { personal: false } — a user id is a pseudonym that falls under Art.17 forget. Use createReferenceField({ entity: "${field.entity}" }) or { personal: "ref" }.`,
215
+ });
194
216
  }
195
217
  // lookupable: only text fields with a subject annotation are supported. Without
196
218
  // a subject, the value remains plaintext, so a blind index would be a
@@ -200,10 +222,14 @@ function rejectInvalidLookupable(input) {
200
222
  if (input.flags.lookupable !== true)
201
223
  return;
202
224
  if (input.field.type !== "text") {
203
- throw new Error(`${fieldLocation(input)} declares find: "exact" (or "fuzzy") but has type "${input.field.type}" — blind-index equality lookups only apply to text fields. Use a text field or set find: "none".`);
225
+ throw bootRuleError(PII_RULES.lookupableNonText, {
226
+ message: `${fieldLocation(input)} declares find: "exact" (or "fuzzy") but has type "${input.field.type}" — blind-index equality lookups only apply to text fields. Use a text field or set find: "none".`,
227
+ });
204
228
  }
205
229
  if (input.subjectAnnotationCount === 0) {
206
- throw new Error(`${fieldLocation(input)} declares find: "exact" (or "fuzzy") without a subject annotation (personal: "self" / "tenant" / { of: "<field>" }). Plaintext fields don't need a blind index — add personal or set find: "none".`);
230
+ throw bootRuleError(PII_RULES.lookupableWithoutSubject, {
231
+ message: `${fieldLocation(input)} declares find: "exact" (or "fuzzy") without a subject annotation (personal: "self" / "tenant" / { of: "<field>" }). Plaintext fields don't need a blind index — add personal or set find: "none".`,
232
+ });
207
233
  }
208
234
  }
209
235
  // Sensitive fields are stored as table ciphertext in the event log; without
@@ -212,7 +238,9 @@ function rejectInvalidLookupable(input) {
212
238
  function rejectSensitiveWithoutCiphertextAtRest(input) {
213
239
  const { flags } = input;
214
240
  if (flags.sensitive === true && input.subjectAnnotationCount === 0 && flags.encrypted !== true) {
215
- throw new Error(`${fieldLocation(input)} declares { sensitive: true } without ciphertext-at-rest. Since #967 the event log stores sensitive fields as table ciphertext — add a subject annotation (personal: "self" / "tenant" / { of: "<field>" }) or { encrypted: true }.`);
241
+ throw bootRuleError(PII_RULES.sensitiveWithoutCiphertext, {
242
+ message: `${fieldLocation(input)} declares { sensitive: true } without ciphertext-at-rest. Since #967 the event log stores sensitive fields as table ciphertext — add a subject annotation (personal: "self" / "tenant" / { of: "<field>" }) or { encrypted: true }.`,
243
+ });
216
244
  }
217
245
  }
218
246
  // Sorting reads the projection column — that stays ciphertext, so
@@ -223,10 +251,14 @@ function rejectSensitiveWithoutCiphertextAtRest(input) {
223
251
  function rejectCiphertextSortOrSensitiveSearch(input) {
224
252
  const { flags } = input;
225
253
  if (input.subjectAnnotationCount > 0 && flags.sortable === true) {
226
- throw new Error(`${fieldLocation(input)} combines a subject-key annotation with { sortable: true } — sorting reads the projection column, which is ciphertext at rest. For equality lookups use find: "exact"; drop sortable or keep the field plaintext (personal: false).`);
254
+ throw bootRuleError(PII_RULES.subjectKeySortable, {
255
+ message: `${fieldLocation(input)} combines a subject-key annotation with { sortable: true } — sorting reads the projection column, which is ciphertext at rest. For equality lookups use find: "exact"; drop sortable or keep the field plaintext (personal: false).`,
256
+ });
227
257
  }
228
258
  if (flags.sensitive === true && flags.searchable === true) {
229
- throw new Error(`${fieldLocation(input)} combines { sensitive: true } with { searchable: true } — sensitive means ciphertext at rest, never searchable, stripped from event echoes — authorized readers can still read it back (passwords, tokens, tax IDs). Subject-annotated identity fields may be searchable (#1610); sensitive fields may not. To never return the value at all, add { writeOnly: true }.`);
259
+ throw bootRuleError(PII_RULES.sensitiveSearchable, {
260
+ message: `${fieldLocation(input)} combines { sensitive: true } with { searchable: true } — sensitive means ciphertext at rest, never searchable, stripped from event echoes — authorized readers can still read it back (passwords, tokens, tax IDs). Subject-annotated identity fields may be searchable (#1610); sensitive fields may not. To never return the value at all, add { writeOnly: true }.`,
261
+ });
230
262
  }
231
263
  }
232
264
  function validatePersonalOfOwner(input) {
@@ -236,7 +268,9 @@ function validatePersonalOfOwner(input) {
236
268
  return;
237
269
  const ownerName = personalOf.ownerField;
238
270
  if (!ownerName || typeof ownerName !== "string") {
239
- throw new Error(`${fieldLocation(input)} has personal: { of: ... } without an owner field name. Pass the name of the owner field: personal: { of: "<ownerField>" }.`);
271
+ throw bootRuleError(PII_RULES.personalOfMissingOwnerName, {
272
+ message: `${fieldLocation(input)} has personal: { of: ... } without an owner field name. Pass the name of the owner field: personal: { of: "<ownerField>" }.`,
273
+ });
240
274
  }
241
275
  const ownerField = requireOwnerField(input, ownerName);
242
276
  rejectOwnerWithoutRowFallback(input, ownerName, ownerField, personalOf);
@@ -247,7 +281,9 @@ function requireOwnerField(input, ownerName) {
247
281
  const ownerField = fieldsByName[ownerName];
248
282
  if (!ownerField) {
249
283
  const known = Object.keys(fieldsByName).sort().join(", ");
250
- throw new Error(`${fieldLocation(input)} references personal.of "${ownerName}" but no such field exists. Known fields: ${known}`);
284
+ throw bootRuleError(PII_RULES.personalOfUnknownOwner, {
285
+ message: `${fieldLocation(input)} references personal.of "${ownerName}" but no such field exists. Known fields: ${known}`,
286
+ });
251
287
  }
252
288
  // Text is accepted alongside reference: the ES-framework carries
253
289
  // user ids as plain text columns throughout the bundled features
@@ -255,17 +291,23 @@ function requireOwnerField(input, ownerName) {
255
291
  // relational FK to point a reference at. Self-referencing
256
292
  // ownerField (the field's own value IS the owner id) rides on this.
257
293
  if (ownerField.type !== "reference" && ownerField.type !== "text") {
258
- throw new Error(`[Feature ${input.featureName}] personal.of "${ownerName}" on entity "${input.entityName}" must be a reference or text (userId) field, got type "${ownerField.type}"`);
294
+ throw bootRuleError(PII_RULES.personalOfOwnerType, {
295
+ message: `[Feature ${input.featureName}] personal.of "${ownerName}" on entity "${input.entityName}" must be a reference or text (userId) field, got type "${ownerField.type}"`,
296
+ });
259
297
  }
260
298
  return ownerField;
261
299
  }
262
300
  function rejectOwnerWithoutRowFallback(input, ownerName, ownerField, personalOf) {
263
301
  const { whenAbsent } = personalOf;
264
302
  if (whenAbsent !== undefined && whenAbsent !== "tenant" && whenAbsent !== "self") {
265
- throw new Error(`${fieldLocation(input)} declares personal.whenAbsent "${String(whenAbsent)}" — entity fields only support whenAbsent: "tenant" or "self" (no plaintext fallback for stored PII). Remove whenAbsent or set it to "tenant" (tenant key) or "self" (the row's own record key).`);
303
+ throw bootRuleError(PII_RULES.personalOfInvalidWhenAbsent, {
304
+ message: `${fieldLocation(input)} declares personal.whenAbsent "${String(whenAbsent)}" — entity fields only support whenAbsent: "tenant" or "self" (no plaintext fallback for stored PII). Remove whenAbsent or set it to "tenant" (tenant key) or "self" (the row's own record key).`,
305
+ });
266
306
  }
267
307
  if (ownerField.required !== true && whenAbsent === undefined) {
268
- throw new Error(`${fieldLocation(input)} is personal: { of: "${ownerName}" }, but "${ownerName}" is not required — rows without an owner would fail to encrypt (SubjectResolutionError, HTTP 500) on update, delete and forget. Declare { personal: { of: "${ownerName}", whenAbsent: "tenant" } } to encrypt those rows under the tenant key, whenAbsent: "self" to encrypt them under the row's own record key (shreddable per record), or make "${ownerName}" required: true. Entity fields have no plaintext fallback (fw#3654).`);
308
+ throw bootRuleError(PII_RULES.personalOfOptionalOwner, {
309
+ message: `${fieldLocation(input)} is personal: { of: "${ownerName}" }, but "${ownerName}" is not required — rows without an owner would fail to encrypt (SubjectResolutionError, HTTP 500) on update, delete and forget. Declare { personal: { of: "${ownerName}", whenAbsent: "tenant" } } to encrypt those rows under the tenant key, whenAbsent: "self" to encrypt them under the row's own record key (shreddable per record), or make "${ownerName}" required: true. Entity fields have no plaintext fallback (fw#3654).`,
310
+ });
269
311
  }
270
312
  }
271
313
  // Custom subject entities (such as HR employees or patients) are allowed but
@@ -327,8 +369,7 @@ function validateEntityRetention(featureName, entityName, entity) {
327
369
  fieldsByName: entity.fields,
328
370
  retention,
329
371
  };
330
- for (const rule of RETENTION_RULES)
331
- rule(input);
372
+ collectBootRuleViolations(RETENTION_RULES, (rule) => rule(input));
332
373
  }
333
374
  function warnOnInvalidKeepFor({ featureName, entityName, retention }) {
334
375
  if (!KEEP_FOR_PATTERN.test(retention.keepFor)) {
@@ -344,7 +385,9 @@ function rejectUnknownRetentionReference(input) {
344
385
  if (!fieldsByName[refName] && !FRAMEWORK_TIMESTAMP_FIELDS.has(refName)) {
345
386
  const known = Object.keys(fieldsByName).sort().join(", ");
346
387
  const framework = [...FRAMEWORK_TIMESTAMP_FIELDS].sort().join(", ");
347
- throw new Error(`[Feature ${featureName}] Entity "${entityName}" retention.reference "${refName}" does not exist. Known fields: ${known} — framework-managed timestamps also accepted: ${framework}`);
388
+ throw bootRuleError(PII_RULES.retentionUnknownReference, {
389
+ message: `[Feature ${featureName}] Entity "${entityName}" retention.reference "${refName}" does not exist. Known fields: ${known} — framework-managed timestamps also accepted: ${framework}`,
390
+ });
348
391
  }
349
392
  }
350
393
  // blockDelete with no field that can carry `anonymize` is fine;
@@ -1,8 +1,10 @@
1
1
  import { isListQueryWithoutSearchableFields, resolveEntityBehindListQuery, } from "../entity-list-searchability.js";
2
2
  import { QnTypes, qualifyEntityName } from "../qualified-name.js";
3
3
  import { isEditLayoutScreen } from "../screen-helpers.js";
4
+ import { bootRuleError } from "./boot-rule.js";
4
5
  import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens.js";
5
6
  import { validateListTableAggregateQuery } from "./list-table-aggregate-query.js";
7
+ import { NAMED_FILTER_RULES } from "./named-filter-rules.js";
6
8
  import { findEntity } from "./parent-ref.js";
7
9
  import { getZodObjectShape } from "./zod-shape.js";
8
10
  // Sibling to entity-list-screens.ts rather than an extension of it:
@@ -66,6 +68,7 @@ function validateOneProjectionListScreen(feature, screen, queryHandlers, feature
66
68
  }
67
69
  validateProjectionListFilterSchemaAcceptance(prefix, screen, schema);
68
70
  validateProjectionListFacetsSchemaAcceptance(prefix, screen, schema);
71
+ validateProjectionListNamedFilterSchemaAcceptance(prefix, screen, schema);
69
72
  validateListTableAggregateQuery({ prefix, ...screen, handler, featureMap });
70
73
  }
71
74
  // filter sends `payload.filter` — same 422 footgun as facets below
@@ -78,6 +81,16 @@ function validateProjectionListFilterSchemaAcceptance(prefix, screen, schema) {
78
81
  `schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","lte","gte","in"]), ` +
79
82
  `value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`);
80
83
  }
84
+ // namedFilters send `payload.namedFilter` — same 422 footgun as filter/facets.
85
+ function validateProjectionListNamedFilterSchemaAcceptance(prefix, screen, schema) {
86
+ // skip: no named filters declared, or the schema already accepts the key — nothing to reject.
87
+ if (screen.namedFilters === undefined || schemaAccepts(schema, "namedFilter"))
88
+ return;
89
+ throw bootRuleError(NAMED_FILTER_RULES.projectionQueryAcceptsNamedFilter, {
90
+ message: `${prefix}: declares namedFilters but query "${screen.query}" has no "namedFilter" parameter in its ` +
91
+ `Zod schema — add namedFilter: z.string().optional() to the handler's schema and resolve it with resolveNamedListFilters.`,
92
+ });
93
+ }
81
94
  // facets send `payload.filters` — same failure mode as search/sort:
82
95
  // definePagedQueryHandler doesn't auto-merge params into the
83
96
  // handler's own Zod schema, so a declared facet would 422 on every query
@@ -0,0 +1,7 @@
1
+ export declare const SCREEN_ACTION_RULES: {
2
+ readonly actionIcon: {
3
+ readonly id: "screen/action-icon";
4
+ readonly summary: "Every screen action resolves to a registered icon.";
5
+ readonly fix: "Set icon to a registered NAV_ICONS key or use an action id that resolves one via the default icon map.";
6
+ };
7
+ };
@@ -0,0 +1,7 @@
1
+ export const SCREEN_ACTION_RULES = {
2
+ actionIcon: {
3
+ id: "screen/action-icon",
4
+ summary: "Every screen action resolves to a registered icon.",
5
+ fix: "Set icon to a registered NAV_ICONS key or use an action id that resolves one via the default icon map.",
6
+ },
7
+ };
@@ -4,6 +4,8 @@ import { fallbackTargets, SCREEN_FALLBACK_REASONS, } from "@cosmicdrift/kumiko-t
4
4
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
5
5
  import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name.js";
6
6
  import { isFieldsEditSection, normalizeEditField, sectionFieldSpecs } from "../screen-helpers.js";
7
+ import { bootRuleError } from "./boot-rule.js";
8
+ import { SCREEN_ACTION_RULES } from "./screens-actions-rules.js";
7
9
  export function isRowClickAction(action) {
8
10
  return (action.kind === "navigate" || action.kind === "drawer") && action.rowClick === true;
9
11
  }
@@ -117,9 +119,11 @@ function isRegisteredIcon(icon) {
117
119
  export function validateActionHasIcon(featureName, screenId, screenType, context, action) {
118
120
  const resolved = resolveActionIcon(action.id, action.icon);
119
121
  if (resolved === undefined || !isRegisteredIcon(resolved)) {
120
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${context} "${action.id}" has no ` +
121
- `resolvable icon — set icon to a key registered in NAV_ICONS, or use an id that resolves one of ` +
122
- `its kebab segments via the default icon map (resolveActionIcon).`);
122
+ throw bootRuleError(SCREEN_ACTION_RULES.actionIcon, {
123
+ message: `[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${context} "${action.id}" has no ` +
124
+ `resolvable icon — set icon to a key registered in NAV_ICONS, or use an id that resolves one of ` +
125
+ `its kebab segments via the default icon map (resolveActionIcon).`,
126
+ });
123
127
  }
124
128
  }
125
129
  // Pin an action's declarative field references against the entity
@@ -45,6 +45,7 @@ export declare const entityListSchema: z.ZodObject<{
45
45
  }>;
46
46
  value: z.ZodUnknown;
47
47
  }, z.core.$strip>>>;
48
+ namedFilter: z.ZodOptional<z.ZodString>;
48
49
  aggregate: z.ZodOptional<z.ZodObject<{
49
50
  groupBy: z.ZodOptional<z.ZodString>;
50
51
  measures: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -15,6 +15,7 @@ import { entityListRuntime, parentVisibilityFor } from "./entity-list-runtime-op
15
15
  import { assertReason } from "./escape-hatch-reason.js";
16
16
  import { dropEmptyWriteOnlyValues } from "./field-access.js";
17
17
  import { instructionFieldNames } from "./instruction-fields.js";
18
+ import { resolveNamedListFilters } from "./resolve-named-list-filters.js";
18
19
  import { buildInsertSchema, buildUpdateSchema } from "./schema-builder.js";
19
20
  import { parseTenantId } from "./types/identifiers.js";
20
21
  // Convention-based handler factories for event-sourced aggregates.
@@ -109,6 +110,8 @@ export const entityListSchema = z.object({
109
110
  value: z.unknown(),
110
111
  }))
111
112
  .optional(),
113
+ // Entity-defined work-queue rule (EntityDefinition.namedFilters); its conditions are appended to `filters`.
114
+ namedFilter: z.string().min(1).max(100).optional(),
112
115
  aggregate: listAggregatePayloadSchema.optional(),
113
116
  });
114
117
  function parseHandlerName(name, validVerbs) {
@@ -398,7 +401,16 @@ export function defineEntityQueryHandler(name, entity, options) {
398
401
  // against Meilisearch/InMem at runtime (remote combobox search). The
399
402
  // executor is built at definition time and cannot know the adapter, so the
400
403
  // runtime override supplies it.
401
- const listPayload = query.payload; // @cast-boundary engine-payload
404
+ const requestPayload = query.payload; // @cast-boundary engine-payload
405
+ const listPayload = requestPayload.namedFilter === undefined
406
+ ? requestPayload
407
+ : {
408
+ ...requestPayload,
409
+ filters: [
410
+ ...(requestPayload.filters ?? []),
411
+ ...(await resolveNamedListFilters(ctx, query.user, entityName, requestPayload.namedFilter)),
412
+ ],
413
+ };
402
414
  const db = dbFor(ctx);
403
415
  const { runtimeOptions, eagerLoadViewer, resolveEntity } = entityListRuntime(ctx, entityName, query.user);
404
416
  if (listPayload.aggregate !== undefined) {
@@ -208,6 +208,8 @@ export declare function createEntity<const F, const T extends EntityTenancy = "t
208
208
  readonly readGate?: EntityDefinition["readGate"];
209
209
  readonly retention?: RetentionDef;
210
210
  readonly derivedFields?: EntityDefinition["derivedFields"];
211
+ /** See EntityDefinition.namedFilters. */
212
+ readonly namedFilters?: EntityDefinition["namedFilters"];
211
213
  readonly tenancy?: T;
212
214
  /** See EntityDefinition.transferable. */
213
215
  readonly transferable?: boolean;
@@ -22,6 +22,8 @@ export { handlerFieldLabelKey, handlerTitleKey } from "../i18n/required-surface-
22
22
  export { attachOutsideTransaction } from "../pipeline/attach-outside-transaction.ts";
23
23
  export { hasAccess } from "./access.ts";
24
24
  export type { PrincipalProfile, PrincipalStatus, PrincipalStatusPlugin, TenantLifecycleStatusPlugin, } from "./active-membership.ts";
25
+ export { collectBootRuleViolations } from "./boot-validator/boot-report.ts";
26
+ export { type BootRule, type BootRuleTable, BootRuleViolation, BootValidationError, type BootViolation, bootRuleError, } from "./boot-validator/boot-rule.ts";
25
27
  export { validateBundledListsOptions } from "./boot-validator/bundled-lists-table.ts";
26
28
  export { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./boot-validator/entity-handler.ts";
27
29
  export { entityHasAnonymizableSubjectField } from "./boot-validator/pii-retention.ts";
@@ -78,6 +80,7 @@ export { defineApply, defineMspApply, setFields } from "./projection-helpers.ts"
78
80
  export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name.ts";
79
81
  export { readClaim } from "./read-claim.ts";
80
82
  export { createRegistry } from "./registry.ts";
83
+ export { resolveNamedListFilters } from "./resolve-named-list-filters.ts";
81
84
  export type { AssignableAppRoles } from "./role-assignment.ts";
82
85
  export { assignableAppRolesFromUsages, assignableAppRolesOf, BUILT_IN_MEMBERSHIP_ROLES, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.ts";
83
86
  export { runPipeline, runStepList } from "./run-pipeline.ts";
@@ -4,6 +4,8 @@ export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HTTP_ERROR_CODE_PATTERN,
4
4
  export { handlerFieldLabelKey, handlerTitleKey } from "../i18n/required-surface-keys.js";
5
5
  export { attachOutsideTransaction } from "../pipeline/attach-outside-transaction.js";
6
6
  export { hasAccess } from "./access.js";
7
+ export { collectBootRuleViolations } from "./boot-validator/boot-report.js";
8
+ export { BootRuleViolation, BootValidationError, bootRuleError, } from "./boot-validator/boot-rule.js";
7
9
  export { validateBundledListsOptions } from "./boot-validator/bundled-lists-table.js";
8
10
  export { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./boot-validator/entity-handler.js";
9
11
  export { entityHasAnonymizableSubjectField } from "./boot-validator/pii-retention.js";
@@ -62,6 +64,7 @@ export { defineApply, defineMspApply, setFields } from "./projection-helpers.js"
62
64
  export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name.js";
63
65
  export { readClaim } from "./read-claim.js";
64
66
  export { createRegistry } from "./registry.js";
67
+ export { resolveNamedListFilters } from "./resolve-named-list-filters.js";
65
68
  export { assignableAppRolesFromUsages, assignableAppRolesOf, BUILT_IN_MEMBERSHIP_ROLES, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
66
69
  export { runPipeline, runStepList } from "./run-pipeline.js";
67
70
  export { buildInsertSchema, buildUpdateSchema, fieldToZod, SYSTEM_ONLY_JSON_SCHEMA_KEY, } from "./schema-builder.js";
@@ -0,0 +1,3 @@
1
+ import type { NamedListFilterCondition } from "@cosmicdrift/kumiko-types/named-list-filters";
2
+ import type { HandlerContext, SessionUser } from "./types/index.ts";
3
+ export declare function resolveNamedListFilters(ctx: HandlerContext, user: SessionUser, entityName: string, name: string): Promise<readonly NamedListFilterCondition[]>;
@@ -0,0 +1,30 @@
1
+ import { AccessDeniedError, ValidationError } from "../errors/index.js";
2
+ import { hasAccess } from "./access.js";
3
+ export async function resolveNamedListFilters(ctx, user, entityName, name) {
4
+ const registered = ctx.registry.getEntity(entityName)?.namedFilters ?? {};
5
+ const def = Object.hasOwn(registered, name) ? registered[name] : undefined;
6
+ if (def === undefined) {
7
+ throw new ValidationError({
8
+ fields: [
9
+ {
10
+ path: "namedFilter",
11
+ code: "unknown_named_filter",
12
+ i18nKey: "errors.validation.invalid_value",
13
+ params: { entity: entityName, namedFilter: name },
14
+ },
15
+ ],
16
+ });
17
+ }
18
+ if (def.access !== undefined && !hasAccess(user, def.access)) {
19
+ throw new AccessDeniedError();
20
+ }
21
+ const conditions = await def.filters({ ctx, user, now: ctx.tz.now() });
22
+ const entityFields = ctx.registry.getEntity(entityName)?.fields ?? {};
23
+ for (const condition of conditions) {
24
+ // Fail closed: a condition on a non-column is dropped by the list filter and would widen the queue.
25
+ if (condition.field !== "id" && !Object.hasOwn(entityFields, condition.field)) {
26
+ throw new Error(`Named filter "${name}" of entity "${entityName}" filters on unknown field "${condition.field}".`);
27
+ }
28
+ }
29
+ return conditions;
30
+ }
@@ -480,6 +480,9 @@ export function requiredKeysFromFeature(feature) {
480
480
  for (const key of requiredKeysFromNav(nav))
481
481
  out.add(key);
482
482
  }
483
+ for (const collection of Object.values(feature.contentCollections ?? {})) {
484
+ pushKey(out, collection.nav.entryLabel);
485
+ }
483
486
  for (const ws of Object.values(feature.workspaces)) {
484
487
  for (const key of requiredKeysFromWorkspace(ws))
485
488
  out.add(key);
@@ -4,7 +4,7 @@ import { assertRowLevelSecurityTableState, createDbConnection, generateMigration
4
4
  import { isLiveFrameworkTable } from "../db/known-table-names.js";
5
5
  import { readMigrationSqlTexts } from "../db/migrate-runner.js";
6
6
  import { diffReplayAgainstSnapshot, replayMigrationsDir } from "../db/replay-migration-sql.js";
7
- import { BootValidationError } from "../engine/boot-validator/boot-rule.js";
7
+ import { BootValidationError, bootRuleDocLink, } from "../engine/boot-validator/boot-rule.js";
8
8
  import { validateBoot } from "../engine/boot-validator.js";
9
9
  import { describeThrown, loadPreviousSnapshot, loadSchemaModOrReport, nextSequenceNumber, SNAPSHOT_FILENAME, } from "./shared.js";
10
10
  import { featureDefinitionsOf, parseValidateBootOptions, withFileStoragePresence, } from "./validate-boot-input.js";
@@ -44,6 +44,9 @@ function reportBootViolations(out, violations) {
44
44
  out.err(` ✗ boot [${v.ruleId}] ${v.message}`);
45
45
  if (v.fix !== undefined)
46
46
  out.err(` Fix: ${v.fix}`);
47
+ const docLink = bootRuleDocLink(v.ruleId);
48
+ if (docLink !== undefined)
49
+ out.err(` Rule: ${docLink}`);
47
50
  }
48
51
  }
49
52
  // 2. Boot validity — needs the composed feature set.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.401.0",
3
+ "version": "0.403.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -203,7 +203,7 @@
203
203
  "./package.json": "./package.json"
204
204
  },
205
205
  "dependencies": {
206
- "@cosmicdrift/kumiko-types": "0.401.0",
206
+ "@cosmicdrift/kumiko-types": "0.403.0",
207
207
  "bullmq": "^5.76.7",
208
208
  "hono": "^4.13.14",
209
209
  "i18next": "^26.1.0",
@@ -225,7 +225,7 @@
225
225
  }
226
226
  },
227
227
  "devDependencies": {
228
- "@cosmicdrift/kumiko-testing": "0.401.0",
228
+ "@cosmicdrift/kumiko-testing": "0.403.0",
229
229
  "bun-types": "^1.4.4",
230
230
  "fflate": "^0.8.3",
231
231
  "meilisearch": "^0.58.0",
package/src/changes.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.403.0",
4
+ "type": "improvement",
5
+ "title": "Entities define named list filters",
6
+ "detail": "createEntity takes namedFilters, a record of { access?, filters(scope) } where filters returns ListFilter conditions (sync or async). The default list query accepts namedFilter and appends the resolved conditions to filters, on the row path and the aggregate path. An unknown name is a validation error on namedFilter, a failed access rule is access_denied. resolveNamedListFilters is exported from the engine entry. The definition is function-valued and never reaches the client schema."
7
+ },
8
+ {
9
+ "version": "0.402.0",
10
+ "type": "improvement",
11
+ "title": "Boot violations carry rule ids with a fix and a doc link",
12
+ "detail": "43 boot rules have an id such as `pii/subject-key-sortable` or `entity-list/default-sort-required`. A violation in `BootValidationError.violations` and in `kumiko-schema validate` (text and `--json`) carries that id instead of `unclassified/<check>`, plus a `Fix:` line and `Rule: agents/boot-rules.md#<id>`. PII field rules, entityList screen rules, missing i18n keys, API and extension usages and user-data-rights hooks now report all violations in one run. Message texts are unchanged. `@cosmicdrift/kumiko-framework/engine` exports `bootRuleError`, `BootRuleViolation`, `BootValidationError`, `collectBootRuleViolations` and the types `BootRule`, `BootRuleTable`, `BootViolation`, so a feature's `r.bootCheck` can throw a classified rule."
13
+ },
14
+ {
15
+ "version": "0.402.0",
16
+ "type": "fix",
17
+ "title": "Migration replay keeps tables whose name ends in \"as\"",
18
+ "detail": "`CREATE TABLE IF NOT EXISTS \"read_themas\" (...)` was parsed as `CREATE TABLE read_them AS ...`, so `kumiko-schema validate` reported content drift for `read_them` and `read_themas`. `AS` now needs a column list or whitespace before it."
19
+ },
2
20
  {
3
21
  "version": "0.401.0",
4
22
  "type": "improvement",