@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
@@ -1,6 +1,10 @@
1
1
  import { holdsFileRefs } from "../../files/file-field-slots.js";
2
2
  import { createFallbackLogger } from "../../logging/utils.js";
3
+ import { isUserEntityReference } from "../user-entity-reference.js";
4
+ import { collectBootRuleViolations } from "./boot-report.js";
5
+ import { bootRuleError } from "./boot-rule.js";
3
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";
4
8
  const log = createFallbackLogger("kumiko:boot");
5
9
  // Framework-managed timestamp fields may be used as retention.reference
6
10
  // even when they are not declared in entity.fields.
@@ -45,7 +49,9 @@ function validateWriteOnlyField(input) {
45
49
  for (const [subName, subField] of Object.entries(field.schema)) {
46
50
  if ("writeOnly" in subField && subField.writeOnly === true) {
47
51
  // @cast-boundary schema-walk
48
- 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
+ });
49
55
  }
50
56
  }
51
57
  // skip: embedded fields carry no writeOnly of their own; sub-fields are checked above
@@ -56,15 +62,21 @@ function validateWriteOnlyField(input) {
56
62
  if (flags.writeOnly !== true)
57
63
  return;
58
64
  if (field.type !== "text") {
59
- 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
+ });
60
68
  }
61
69
  if (field.sensitive !== true) {
62
- 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
+ });
63
73
  }
64
74
  for (const flag of WRITE_ONLY_FORBIDDEN_FLAGS) {
65
75
  const value = flags[flag];
66
76
  if (value !== undefined && value !== false) {
67
- 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
+ });
68
80
  }
69
81
  }
70
82
  }
@@ -88,15 +100,21 @@ function validatePersonalJsonbField(input) {
88
100
  const flags = field; // @cast-boundary schema-walk
89
101
  for (const flag of JSONB_PERSONAL_FORBIDDEN_FLAGS) {
90
102
  if (flags[flag] !== undefined && flags[flag] !== false) {
91
- 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
+ });
92
106
  }
93
107
  }
94
108
  const index = entity.indexes?.find((def) => def.columns.includes(fieldName));
95
109
  if (index) {
96
- 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
+ });
97
113
  }
98
114
  if (fieldName === CUSTOM_FIELDS_COLUMN) {
99
- 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
+ });
100
118
  }
101
119
  }
102
120
  // --- PII / Subject-Key Annotations + Retention validation ---
@@ -124,16 +142,17 @@ function validatePersonalJsonbField(input) {
124
142
  // Encryption and decryption are handled elsewhere; this validation runs early
125
143
  // so schema drift is detected during boot.
126
144
  export function validatePiiAndRetention(feature) {
127
- for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
128
- validateFieldAnnotations(feature.name, entityName, entity);
129
- validateEntityRetention(feature.name, entityName, entity);
130
- }
145
+ collectBootRuleViolations(Object.entries(feature.entities ?? {}), ([entityName, entity]) => {
146
+ const entityChecks = [validateFieldAnnotations, validateEntityRetention];
147
+ collectBootRuleViolations(entityChecks, (check) => check(feature.name, entityName, entity));
148
+ });
131
149
  }
132
- // Order matters: the first failing rule decides the boot error, and warnings
133
- // are emitted in this order.
150
+ // Every rule runs on every field; order only decides the order of the reported
151
+ // violations and warnings.
134
152
  const FIELD_RULES = [
135
153
  rejectMultipleSubjectAnnotations,
136
154
  rejectTextWithoutPersonalStance,
155
+ rejectPlaintextUserReference,
137
156
  rejectInvalidLookupable,
138
157
  rejectSensitiveWithoutCiphertextAtRest,
139
158
  validateWriteOnlyField,
@@ -144,7 +163,7 @@ const FIELD_RULES = [
144
163
  ];
145
164
  function validateFieldAnnotations(featureName, entityName, entity) {
146
165
  const personalOfOwnerFieldNames = ownerFieldNamesReferencedByPersonalOf(entity.fields);
147
- for (const [fieldName, field] of Object.entries(entity.fields)) {
166
+ collectBootRuleViolations(Object.entries(entity.fields), ([fieldName, field]) => {
148
167
  const flags = field; // @cast-boundary schema-walk
149
168
  const input = {
150
169
  featureName,
@@ -156,9 +175,8 @@ function validateFieldAnnotations(featureName, entityName, entity) {
156
175
  subjectAnnotationCount: countSubjectAnnotations(flags),
157
176
  personalOfOwnerFieldNames,
158
177
  };
159
- for (const rule of FIELD_RULES)
160
- rule(input);
161
- }
178
+ collectBootRuleViolations(FIELD_RULES, (rule) => rule(input));
179
+ });
162
180
  }
163
181
  function countSubjectAnnotations(flags) {
164
182
  return [flags.pii, flags.userOwned, flags.tenantOwned, flags.recordOwned].filter(Boolean).length;
@@ -168,7 +186,9 @@ function fieldLocation(input) {
168
186
  }
169
187
  function rejectMultipleSubjectAnnotations(input) {
170
188
  if (input.subjectAnnotationCount > 1) {
171
- 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
+ });
172
192
  }
173
193
  }
174
194
  function rejectTextWithoutPersonalStance(input) {
@@ -177,9 +197,23 @@ function rejectTextWithoutPersonalStance(input) {
177
197
  input.subjectAnnotationCount === 0 &&
178
198
  !flags.allowPlaintext &&
179
199
  !flags.subjectRef) {
180
- 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
+ });
181
203
  }
182
204
  }
205
+ function rejectPlaintextUserReference(input) {
206
+ const { field, flags } = input;
207
+ // skip: only a plaintext reference to the user entity is a violation
208
+ if (field.type !== "reference" || !flags.allowPlaintext)
209
+ return;
210
+ // skip: references to other entities may stay plaintext
211
+ if (!isUserEntityReference(field.entity))
212
+ return;
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
+ });
216
+ }
183
217
  // lookupable: only text fields with a subject annotation are supported. Without
184
218
  // a subject, the value remains plaintext, so a blind index would be a
185
219
  // second deterministic copy without any benefit.
@@ -188,10 +222,14 @@ function rejectInvalidLookupable(input) {
188
222
  if (input.flags.lookupable !== true)
189
223
  return;
190
224
  if (input.field.type !== "text") {
191
- 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
+ });
192
228
  }
193
229
  if (input.subjectAnnotationCount === 0) {
194
- 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
+ });
195
233
  }
196
234
  }
197
235
  // Sensitive fields are stored as table ciphertext in the event log; without
@@ -200,7 +238,9 @@ function rejectInvalidLookupable(input) {
200
238
  function rejectSensitiveWithoutCiphertextAtRest(input) {
201
239
  const { flags } = input;
202
240
  if (flags.sensitive === true && input.subjectAnnotationCount === 0 && flags.encrypted !== true) {
203
- 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
+ });
204
244
  }
205
245
  }
206
246
  // Sorting reads the projection column — that stays ciphertext, so
@@ -211,10 +251,14 @@ function rejectSensitiveWithoutCiphertextAtRest(input) {
211
251
  function rejectCiphertextSortOrSensitiveSearch(input) {
212
252
  const { flags } = input;
213
253
  if (input.subjectAnnotationCount > 0 && flags.sortable === true) {
214
- 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
+ });
215
257
  }
216
258
  if (flags.sensitive === true && flags.searchable === true) {
217
- 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
+ });
218
262
  }
219
263
  }
220
264
  function validatePersonalOfOwner(input) {
@@ -224,7 +268,9 @@ function validatePersonalOfOwner(input) {
224
268
  return;
225
269
  const ownerName = personalOf.ownerField;
226
270
  if (!ownerName || typeof ownerName !== "string") {
227
- 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
+ });
228
274
  }
229
275
  const ownerField = requireOwnerField(input, ownerName);
230
276
  rejectOwnerWithoutRowFallback(input, ownerName, ownerField, personalOf);
@@ -235,7 +281,9 @@ function requireOwnerField(input, ownerName) {
235
281
  const ownerField = fieldsByName[ownerName];
236
282
  if (!ownerField) {
237
283
  const known = Object.keys(fieldsByName).sort().join(", ");
238
- 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
+ });
239
287
  }
240
288
  // Text is accepted alongside reference: the ES-framework carries
241
289
  // user ids as plain text columns throughout the bundled features
@@ -243,17 +291,23 @@ function requireOwnerField(input, ownerName) {
243
291
  // relational FK to point a reference at. Self-referencing
244
292
  // ownerField (the field's own value IS the owner id) rides on this.
245
293
  if (ownerField.type !== "reference" && ownerField.type !== "text") {
246
- 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
+ });
247
297
  }
248
298
  return ownerField;
249
299
  }
250
300
  function rejectOwnerWithoutRowFallback(input, ownerName, ownerField, personalOf) {
251
301
  const { whenAbsent } = personalOf;
252
302
  if (whenAbsent !== undefined && whenAbsent !== "tenant" && whenAbsent !== "self") {
253
- 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
+ });
254
306
  }
255
307
  if (ownerField.required !== true && whenAbsent === undefined) {
256
- 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
+ });
257
311
  }
258
312
  }
259
313
  // Custom subject entities (such as HR employees or patients) are allowed but
@@ -263,8 +317,7 @@ function warnOnNonUserOwnerReference(input, ownerName, ownerField) {
263
317
  if (ownerField.type !== "reference")
264
318
  return;
265
319
  const refTarget = ownerField.entity;
266
- const targetEntity = refTarget.includes(":") ? refTarget.split(":")[1] : refTarget;
267
- if (targetEntity !== "user") {
320
+ if (!isUserEntityReference(refTarget)) {
268
321
  log.warn(`[Feature ${input.featureName}] personal.of "${ownerName}" on entity "${input.entityName}" targets reference "${refTarget}" — typically should be a user reference. If intentional (custom subject-entity like employee/patient), ignore.`);
269
322
  }
270
323
  }
@@ -316,8 +369,7 @@ function validateEntityRetention(featureName, entityName, entity) {
316
369
  fieldsByName: entity.fields,
317
370
  retention,
318
371
  };
319
- for (const rule of RETENTION_RULES)
320
- rule(input);
372
+ collectBootRuleViolations(RETENTION_RULES, (rule) => rule(input));
321
373
  }
322
374
  function warnOnInvalidKeepFor({ featureName, entityName, retention }) {
323
375
  if (!KEEP_FOR_PATTERN.test(retention.keepFor)) {
@@ -333,7 +385,9 @@ function rejectUnknownRetentionReference(input) {
333
385
  if (!fieldsByName[refName] && !FRAMEWORK_TIMESTAMP_FIELDS.has(refName)) {
334
386
  const known = Object.keys(fieldsByName).sort().join(", ");
335
387
  const framework = [...FRAMEWORK_TIMESTAMP_FIELDS].sort().join(", ");
336
- 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
+ });
337
391
  }
338
392
  }
339
393
  // blockDelete with no field that can carry `anonymize` is fine;
@@ -4,16 +4,16 @@ import { findEntity } from "./parent-ref.js";
4
4
  function fail(check, message) {
5
5
  throw new Error(`[Kumiko ReadGate] entity "${check.entityName}" (feature: "${check.feature.name}"): ${message}`);
6
6
  }
7
- function validateAnyLink(check, anyLink) {
8
- const link = findEntity(check.featureMap, anyLink.entity);
7
+ function validateLink(check, key, gateLink) {
8
+ const link = findEntity(check.featureMap, gateLink.entity);
9
9
  if (link === undefined) {
10
- fail(check, `readGate.anyLink.entity "${anyLink.entity}" is not registered.`);
10
+ fail(check, `readGate.${key}.entity "${gateLink.entity}" is not registered.`);
11
11
  }
12
12
  if (link.parentRef === undefined) {
13
- fail(check, `readGate.anyLink.entity "${anyLink.entity}" must declare a parentRef; its host visibility is what the gate checks.`);
13
+ fail(check, `readGate.${key}.entity "${gateLink.entity}" must declare a parentRef; its host visibility is what the gate checks.`);
14
14
  }
15
- if (link.fields[anyLink.field] === undefined) {
16
- fail(check, `readGate.anyLink.field "${anyLink.field}" is not a field of "${anyLink.entity}".`);
15
+ if (link.fields[gateLink.field] === undefined) {
16
+ fail(check, `readGate.${key}.field "${gateLink.field}" is not a field of "${gateLink.entity}".`);
17
17
  }
18
18
  }
19
19
  function validateFileOwner(check, fileOwner) {
@@ -45,8 +45,13 @@ export function validateReadGates(feature, featureMap) {
45
45
  if (entity.parentRef !== undefined) {
46
46
  fail(check, "readGate and parentRef cannot be combined; a gated entity cannot be a join row.");
47
47
  }
48
+ if (gate.anyLink !== undefined && gate.allLinks !== undefined) {
49
+ fail(check, "readGate declares both anyLink and allLinks; pick one.");
50
+ }
48
51
  if (gate.anyLink !== undefined)
49
- validateAnyLink(check, gate.anyLink);
52
+ validateLink(check, "anyLink", gate.anyLink);
53
+ if (gate.allLinks !== undefined)
54
+ validateLink(check, "allLinks", gate.allLinks);
50
55
  if (gate.fileOwner !== undefined)
51
56
  validateFileOwner(check, gate.fileOwner);
52
57
  validateNoHandWrittenReaders(check);
@@ -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
@@ -59,12 +59,19 @@ export declare const entityListSchema: z.ZodObject<{
59
59
  }, z.core.$strip>>>;
60
60
  }, z.core.$strip>>;
61
61
  }, z.core.$strip>;
62
- export declare function defineEntityWriteHandler(name: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): WriteHandlerDef;
62
+ /** The phase slots are always unset on entity handlers, so a consumer can spread one into its own
63
+ * handler and add typed phases without inheriting the slot types. */
64
+ export type EntityWriteHandlerDef = WriteHandlerDef & {
65
+ readonly reserveBeforeTransaction?: undefined;
66
+ readonly beforeTransaction?: undefined;
67
+ readonly beforeTransactionWhenNested?: undefined;
68
+ };
69
+ export declare function defineEntityWriteHandler(name: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): EntityWriteHandlerDef;
63
70
  export declare function defineEntityQueryHandler(name: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
64
- export declare function defineEntityCreateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): WriteHandlerDef;
65
- export declare function defineEntityUpdateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): WriteHandlerDef;
66
- export declare function defineEntityDeleteHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): WriteHandlerDef;
67
- export declare function defineEntityRestoreHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): WriteHandlerDef;
71
+ export declare function defineEntityCreateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): EntityWriteHandlerDef;
72
+ export declare function defineEntityUpdateHandler(entityName: string, entity: EntityDefinition, options: EntityWriteHandlerOptions): EntityWriteHandlerDef;
73
+ export declare function defineEntityDeleteHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): EntityWriteHandlerDef;
74
+ export declare function defineEntityRestoreHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): EntityWriteHandlerDef;
68
75
  export declare function defineEntityListHandler(entityName: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
69
76
  export declare function defineEntityDetailHandler(entityName: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
70
77
  export declare function createEntityExecutor<E extends EntityDefinition>(entityName: string, entity: E): {
@@ -1,4 +1,4 @@
1
- import type { BigIntFieldDef, BooleanFieldDef, DateFieldDef, DecimalFieldDef, DerivedFieldDef, EmbeddedFieldDef, EntityDefinition, EntityIndexDef, EntityLongTextFieldDef, EntityTextFieldDef, FieldsMap, FileFieldDef, FilesFieldDef, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, ResolvedPiiFlags, RetentionDef, SelectFieldDef, TimestampFieldDef, TzFieldDef } from "@cosmicdrift/kumiko-types/fields";
1
+ import type { BigIntFieldDef, BooleanFieldDef, DateFieldDef, DecimalFieldDef, DerivedFieldDef, EmbeddedFieldDef, EntityDefinition, EntityIndexDef, EntityLongTextFieldDef, EntityTextFieldDef, FieldsMap, FileFieldDef, FilesFieldDef, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, TimestampFieldDef, TzFieldDef } from "@cosmicdrift/kumiko-types/fields";
2
2
  import type { EntityTenancy } from "@cosmicdrift/kumiko-types/tenancy-brand";
3
3
  import type { TextFieldDef } from "./types/index.ts";
4
4
  export declare function createTextField<R extends true | false = false>(overrides: Partial<Omit<TextFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotations & {
@@ -40,6 +40,18 @@ export declare function createSelectField<const TOptions extends readonly string
40
40
  }): SelectFieldDef<TOptions> & {
41
41
  required: R;
42
42
  };
43
+ /**
44
+ * Foreign key to another entity (`"customer"` or `"feature:entity"`), stored as a UUID.
45
+ * A reference to the user entity defaults to `personal: "ref"`; `personal: false` is
46
+ * rejected there because a user id is personal data.
47
+ */
48
+ export declare function createReferenceField<R extends true | false = false>(opts: {
49
+ entity: string;
50
+ } & Partial<Omit<ReferenceFieldDef, "type" | "entity" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind & {
51
+ required?: R;
52
+ }): ReferenceFieldDef & {
53
+ required: R;
54
+ };
43
55
  /**
44
56
  * Multi-select field — N values from a fixed option list.
45
57
  *
@@ -1,4 +1,5 @@
1
1
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
2
+ import { isUserEntityReference } from "./user-entity-reference.js";
2
3
  // `personal: { of: "id" }` means the row itself is the subject, not a
3
4
  // referenced user — resolves to `recordOwned` instead of `userOwned`.
4
5
  const RECORD_OWNER_FIELD = "id";
@@ -167,6 +168,23 @@ export function createSelectField(opts) {
167
168
  ...expandPersonalAnnotations(opts),
168
169
  }; // @cast-boundary engine-payload
169
170
  }
171
+ /**
172
+ * Foreign key to another entity (`"customer"` or `"feature:entity"`), stored as a UUID.
173
+ * A reference to the user entity defaults to `personal: "ref"`; `personal: false` is
174
+ * rejected there because a user id is personal data.
175
+ */
176
+ export function createReferenceField(opts) {
177
+ const targetsUser = isUserEntityReference(opts.entity);
178
+ if (targetsUser && opts.personal === false) {
179
+ throw new Error(`createReferenceField({ entity: "${opts.entity}" }): personal: false is not allowed for a reference to the user entity — a user id is personal data. Drop the annotation or use { personal: "ref" }.`);
180
+ }
181
+ const withUserDefault = targetsUser && opts.personal === undefined ? { ...opts, personal: "ref" } : opts;
182
+ return {
183
+ type: "reference",
184
+ required: false,
185
+ ...expandPersonalAnnotations(withUserDefault),
186
+ }; // @cast-boundary engine-payload
187
+ }
170
188
  /**
171
189
  * Multi-select field — N values from a fixed option list.
172
190
  *
@@ -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";
@@ -57,7 +59,7 @@ export type { StorageProviderDestroyTenantHook, StorageProviderHookCtx, } from "
57
59
  export { isTenantDataExtensionHooks, type TenantDataDestroyHook, type TenantDataHookCtx, } from "./extensions/tenant-data.ts";
58
60
  export { isTenantResourceExtensionHooks, type TenantDestroyHookResult, type TenantResourceDestroyHook, type TenantResourceHookCtx, } from "./extensions/tenant-resource.ts";
59
61
  export type { TenantUserModel, UserDataDeleteHook, UserDataDeleteStrategy, UserDataExportHook, UserDataExportSnippet, UserDataHookCtx, UserDataStorageProvider, } from "./extensions/user-data.ts";
60
- export { createBigIntField, createBooleanField, createDateField, createDecimalField, createDerivedField, createEmbeddedField, createEmbeddedListField, createEntity, createFileField, createFilesField, createImageField, createImagesField, createJsonbField, createLocatedTimestampField, createLongTextField, createMoneyField, createMultiSelectField, createNumberField, createSelectField, createTextField, createTimestampField, createTzField, } from "./factories.ts";
62
+ export { createBigIntField, createBooleanField, createDateField, createDecimalField, createDerivedField, createEmbeddedField, createEmbeddedListField, createEntity, createFileField, createFilesField, createImageField, createImagesField, createJsonbField, createLocatedTimestampField, createLongTextField, createMoneyField, createMultiSelectField, createNumberField, createReferenceField, createSelectField, createTextField, createTimestampField, createTzField, } from "./factories.ts";
61
63
  export { type ChangelogEntry, compareVersions, filterEntriesAfter, parseFeatureChangelog, } from "./feature-changelog.ts";
62
64
  export { buildManifestFromRegistry, type FeatureManifest, type ManifestConfigKey, type ManifestExtension, type ManifestFeature, type ManifestSecret, type ManifestSystemEvent, serializeManifest, } from "./feature-manifest.ts";
63
65
  export { filterReadFields, maskWriteOnlyFields, } from "./field-access.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";
@@ -36,7 +38,7 @@ export { extensionSelectorTargets, SELECTED_EXTENSIONS_QUERY, selectablePluginId
36
38
  export { extensionUsageEscapeHatchReason } from "./extensions/escape-hatch-usage.js";
37
39
  export { isTenantDataExtensionHooks, } from "./extensions/tenant-data.js";
38
40
  export { isTenantResourceExtensionHooks, } from "./extensions/tenant-resource.js";
39
- export { createBigIntField, createBooleanField, createDateField, createDecimalField, createDerivedField, createEmbeddedField, createEmbeddedListField, createEntity, createFileField, createFilesField, createImageField, createImagesField, createJsonbField, createLocatedTimestampField, createLongTextField, createMoneyField, createMultiSelectField, createNumberField, createSelectField, createTextField, createTimestampField, createTzField, } from "./factories.js";
41
+ export { createBigIntField, createBooleanField, createDateField, createDecimalField, createDerivedField, createEmbeddedField, createEmbeddedListField, createEntity, createFileField, createFilesField, createImageField, createImagesField, createJsonbField, createLocatedTimestampField, createLongTextField, createMoneyField, createMultiSelectField, createNumberField, createReferenceField, createSelectField, createTextField, createTimestampField, createTzField, } from "./factories.js";
40
42
  export { compareVersions, filterEntriesAfter, parseFeatureChangelog, } from "./feature-changelog.js";
41
43
  export { buildManifestFromRegistry, serializeManifest, } from "./feature-manifest.js";
42
44
  export { filterReadFields, maskWriteOnlyFields, } from "./field-access.js";
@@ -0,0 +1,2 @@
1
+ /** True when a reference field's `entity` ("user" or "<feature>:user") targets the user entity. */
2
+ export declare function isUserEntityReference(referenceTarget: string): boolean;
@@ -0,0 +1,7 @@
1
+ /** True when a reference field's `entity` ("user" or "<feature>:user") targets the user entity. */
2
+ export function isUserEntityReference(referenceTarget) {
3
+ const entityName = referenceTarget.includes(":")
4
+ ? referenceTarget.split(":")[1]
5
+ : referenceTarget;
6
+ return entityName === "user";
7
+ }
@@ -20,6 +20,8 @@ export { createInMemoryFileProvider } from "./in-memory-provider.ts";
20
20
  export { createLocalProvider } from "./local-provider.ts";
21
21
  export type { FileProviderContext, FileProviderPlugin, FileProviderResolver, } from "./provider-resolver.ts";
22
22
  export { createFileProviderForTenant, isFileProviderPlugin, } from "./provider-resolver.ts";
23
+ export type { FileRefOwner, RebindFileRefContext, RebindFileRefOutcome, } from "./rebind-file-ref.ts";
24
+ export { rebindFileRef } from "./rebind-file-ref.ts";
23
25
  export { filesStorageTrackingFeature, tenantStorageUsageTable, transferTenantStorageUsage, } from "./storage-tracking.ts";
24
26
  export type { FileStorageProvider, SignedUrlOptions, } from "./types.ts";
25
27
  export { assertSafeStorageKey, buildStorageKey, normalizeMimeType, sniffMimeType, tenantExportPrefix, tenantStoragePrefixes, validateFile, } from "./types.ts";
@@ -15,6 +15,7 @@ export { configuredMaxUploadBytes, readFilesRouteOptions, resolveMaxUploadBodyBy
15
15
  export { createInMemoryFileProvider } from "./in-memory-provider.js";
16
16
  export { createLocalProvider } from "./local-provider.js";
17
17
  export { createFileProviderForTenant, isFileProviderPlugin, } from "./provider-resolver.js";
18
+ export { rebindFileRef } from "./rebind-file-ref.js";
18
19
  export { filesStorageTrackingFeature, tenantStorageUsageTable, transferTenantStorageUsage, } from "./storage-tracking.js";
19
20
  export { assertSafeStorageKey, buildStorageKey, normalizeMimeType, sniffMimeType, tenantExportPrefix, tenantStoragePrefixes, validateFile, } from "./types.js";
20
21
  export { fileUploadLedgerTable, withUploadLedger, } from "./upload-ledger.js";
@@ -0,0 +1,41 @@
1
+ import type { TenantDb } from "../db/tenant-db.ts";
2
+ import type { Registry, SessionUser } from "../engine/types/index.ts";
3
+ import { type CanReadFileRefContext } from "./can-caller-read-file-ref.ts";
4
+ export type RebindFileRefContext = {
5
+ readonly db: TenantDb;
6
+ readonly registry: Registry;
7
+ readonly user: SessionUser;
8
+ readonly dispatch?: CanReadFileRefContext["dispatch"];
9
+ };
10
+ export type FileRefOwner = {
11
+ readonly entityType: string;
12
+ readonly entityId: string;
13
+ readonly fieldName: string;
14
+ };
15
+ export type RebindFileRefOutcome = "rebound" | "not-found" | "moved" | "version-conflict";
16
+ /**
17
+ * Moves a bound file from one record's file field to another's, for a record that takes over a
18
+ * file uploaded elsewhere (an upload record, a mail attachment). Server code only; pending uploads
19
+ * go through bindPendingFileRef.
20
+ *
21
+ * Only files of the caller's tenant are found ("not-found" otherwise, as for unknown ids). The
22
+ * file must still sit at `from`; "moved" means it does not (any more), for example because a
23
+ * parallel rebind took it first. A file already at `to` answers "rebound" without a write. The
24
+ * target must be a registered file field whose accept/maxSize the file fits (InternalError /
25
+ * UnprocessableError). Unless the caller is the framework system user, the caller must be able
26
+ * to read the file and to write both the source record and field (it must not be locked, and an
27
+ * entity without update handler leaves the move to the system user) and the target record and
28
+ * field (AccessDeniedError, RecordLockedError); the system user skips these checks, and only the
29
+ * target record has to exist (NotFoundError). The update is checked against the file's stream
30
+ * version read here, so a bind committing in between returns "version-conflict"; the source row
31
+ * stays locked until the surrounding transaction commits, so a lock set on `from` in between is
32
+ * either seen or waits. Rebind first, then save the id into
33
+ * the target field: the save accepts a file already bound to that record, and the source record
34
+ * no longer disposes of it when its field is cleared or the record is deleted.
35
+ * A handler passes its ctx; a job passes `{ db, registry, user: ctx.systemUser }`.
36
+ */
37
+ export declare function rebindFileRef(ctx: RebindFileRefContext, args: {
38
+ readonly fileRefId: string;
39
+ readonly from: FileRefOwner;
40
+ readonly to: FileRefOwner;
41
+ }): Promise<RebindFileRefOutcome>;