@cosmicdrift/kumiko-framework 0.401.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 (39) hide show
  1. package/AGENTS.md +4 -2
  2. package/agents/api/framework-engine.md +24 -0
  3. package/agents/api/types.md +1 -0
  4. package/agents/api.md +1 -1
  5. package/agents/boot-rules.md +98 -0
  6. package/dist/db/replay-migration-sql.js +1 -1
  7. package/dist/engine/boot-validator/api-ext-rules.d.ts +27 -0
  8. package/dist/engine/boot-validator/api-ext-rules.js +27 -0
  9. package/dist/engine/boot-validator/api-ext.js +25 -12
  10. package/dist/engine/boot-validator/boot-check-rules.d.ts +7 -0
  11. package/dist/engine/boot-validator/boot-check-rules.js +7 -0
  12. package/dist/engine/boot-validator/boot-check.js +13 -1
  13. package/dist/engine/boot-validator/boot-report.d.ts +1 -0
  14. package/dist/engine/boot-validator/boot-report.js +58 -7
  15. package/dist/engine/boot-validator/boot-rule.d.ts +20 -0
  16. package/dist/engine/boot-validator/boot-rule.js +33 -2
  17. package/dist/engine/boot-validator/boot-rules.d.ts +210 -0
  18. package/dist/engine/boot-validator/boot-rules.js +17 -0
  19. package/dist/engine/boot-validator/card-meta-overflow-rules.d.ts +7 -0
  20. package/dist/engine/boot-validator/card-meta-overflow-rules.js +7 -0
  21. package/dist/engine/boot-validator/card-meta-overflow.js +5 -1
  22. package/dist/engine/boot-validator/entity-list-screens-rules.d.ts +32 -0
  23. package/dist/engine/boot-validator/entity-list-screens-rules.js +32 -0
  24. package/dist/engine/boot-validator/entity-list-screens.js +74 -41
  25. package/dist/engine/boot-validator/i18n-keys-rules.d.ts +27 -0
  26. package/dist/engine/boot-validator/i18n-keys-rules.js +27 -0
  27. package/dist/engine/boot-validator/i18n-keys.js +27 -16
  28. package/dist/engine/boot-validator/pii-retention-rules.d.ts +107 -0
  29. package/dist/engine/boot-validator/pii-retention-rules.js +107 -0
  30. package/dist/engine/boot-validator/pii-retention.js +76 -33
  31. package/dist/engine/boot-validator/screens-actions-rules.d.ts +7 -0
  32. package/dist/engine/boot-validator/screens-actions-rules.js +7 -0
  33. package/dist/engine/boot-validator/screens-actions.js +7 -3
  34. package/dist/engine/index.d.ts +2 -0
  35. package/dist/engine/index.js +2 -0
  36. package/dist/i18n/required-surface-keys.js +3 -0
  37. package/dist/schema-cli/validate-command.js +4 -1
  38. package/package.json +3 -3
  39. package/src/changes.json +12 -0
@@ -0,0 +1,107 @@
1
+ export declare const PII_RULES: {
2
+ readonly multipleSubjectAnnotations: {
3
+ readonly id: "pii/multiple-subject-annotations";
4
+ readonly summary: "A field carries at most one subject-key annotation.";
5
+ readonly fix: "Keep a single personal annotation on the field.";
6
+ };
7
+ readonly textWithoutPersonalStance: {
8
+ readonly id: "pii/text-without-personal-stance";
9
+ readonly summary: "Every text and longText field declares whether it holds personal data.";
10
+ readonly fix: "Declare a personal stance, or personal: false with a reason stating why the value is not personal data.";
11
+ };
12
+ readonly plaintextUserReference: {
13
+ readonly id: "pii/plaintext-user-reference";
14
+ readonly summary: "A reference to the user entity is never plaintext.";
15
+ readonly fix: 'Use the default reference field or personal: "ref" instead of personal: false.';
16
+ };
17
+ readonly lookupableNonText: {
18
+ readonly id: "pii/lookupable-non-text";
19
+ readonly summary: "Blind-index lookups apply to text fields only.";
20
+ readonly fix: 'Use a text field or set find: "none".';
21
+ };
22
+ readonly lookupableWithoutSubject: {
23
+ readonly id: "pii/lookupable-without-subject";
24
+ readonly summary: "A blind-index lookup requires a subject annotation on the field.";
25
+ readonly fix: 'Add a personal annotation or set find: "none".';
26
+ };
27
+ readonly sensitiveWithoutCiphertext: {
28
+ readonly id: "pii/sensitive-without-ciphertext";
29
+ readonly summary: "A sensitive field is stored as ciphertext at rest.";
30
+ readonly fix: "Add a subject annotation or encrypted: true.";
31
+ };
32
+ readonly writeOnlyEmbeddedSubField: {
33
+ readonly id: "pii/write-only-embedded-sub-field";
34
+ readonly summary: "writeOnly is only supported on top-level text fields, not on embedded sub-fields.";
35
+ readonly fix: "Remove writeOnly from the sub-field or declare it on a top-level text field.";
36
+ };
37
+ readonly writeOnlyNonText: {
38
+ readonly id: "pii/write-only-non-text";
39
+ readonly summary: "writeOnly applies to text fields only.";
40
+ readonly fix: "Use a text field or remove writeOnly.";
41
+ };
42
+ readonly writeOnlyWithoutSensitive: {
43
+ readonly id: "pii/write-only-without-sensitive";
44
+ readonly summary: "A writeOnly field is also sensitive.";
45
+ readonly fix: 'Declare the field with find: "secret".';
46
+ };
47
+ readonly writeOnlyConflictingFlag: {
48
+ readonly id: "pii/write-only-conflicting-flag";
49
+ readonly summary: "A writeOnly field cannot be defaulted, searched, sorted, filtered or looked up.";
50
+ readonly fix: "Remove either writeOnly or the conflicting flag.";
51
+ };
52
+ readonly subjectKeySortable: {
53
+ readonly id: "pii/subject-key-sortable";
54
+ readonly summary: "A field with a subject-key annotation is not sortable, its column is ciphertext.";
55
+ readonly fix: 'Drop sortable, use find: "exact" for equality lookups, or keep the field plaintext.';
56
+ };
57
+ readonly sensitiveSearchable: {
58
+ readonly id: "pii/sensitive-searchable";
59
+ readonly summary: "A sensitive field is never searchable.";
60
+ readonly fix: "Drop searchable, or add writeOnly to never return the value.";
61
+ };
62
+ readonly personalJsonbFlag: {
63
+ readonly id: "pii/personal-jsonb-flag";
64
+ readonly summary: "A personal jsonb field cannot be sortable, filterable, searchable or lookupable, its value is a ciphertext string.";
65
+ readonly fix: "Remove the flag or the personal annotation.";
66
+ };
67
+ readonly personalJsonbIndex: {
68
+ readonly id: "pii/personal-jsonb-index";
69
+ readonly summary: "A personal jsonb field is not part of an entity index.";
70
+ readonly fix: "Drop the index or the personal annotation.";
71
+ };
72
+ readonly personalJsonbCustomFields: {
73
+ readonly id: "pii/personal-jsonb-custom-fields";
74
+ readonly summary: "The custom-fields jsonb column cannot carry a personal annotation.";
75
+ readonly fix: "Drop the personal annotation.";
76
+ };
77
+ readonly personalOfMissingOwnerName: {
78
+ readonly id: "pii/personal-of-missing-owner-name";
79
+ readonly summary: "personal: { of } names the owner field.";
80
+ readonly fix: 'Pass the name of the owner field: personal: { of: "<ownerField>" }.';
81
+ };
82
+ readonly personalOfUnknownOwner: {
83
+ readonly id: "pii/personal-of-unknown-owner";
84
+ readonly summary: "The owner field named by personal: { of } exists on the entity.";
85
+ readonly fix: "Point personal.of at an existing field of the entity.";
86
+ };
87
+ readonly personalOfOwnerType: {
88
+ readonly id: "pii/personal-of-owner-type";
89
+ readonly summary: "The owner field named by personal: { of } is a reference or text field.";
90
+ readonly fix: "Use a reference or text field as owner.";
91
+ };
92
+ readonly personalOfInvalidWhenAbsent: {
93
+ readonly id: "pii/personal-of-invalid-when-absent";
94
+ readonly summary: 'personal.whenAbsent is either "tenant" or "self".';
95
+ readonly fix: 'Remove whenAbsent or set it to "tenant" or "self".';
96
+ };
97
+ readonly personalOfOptionalOwner: {
98
+ readonly id: "pii/personal-of-optional-owner";
99
+ readonly summary: "An optional owner field of personal: { of } declares a whenAbsent fallback.";
100
+ readonly fix: 'Set whenAbsent to "tenant" or "self", or make the owner field required.';
101
+ };
102
+ readonly retentionUnknownReference: {
103
+ readonly id: "retention/unknown-reference";
104
+ readonly summary: "retention.reference names an existing field or a framework timestamp.";
105
+ readonly fix: "Point retention.reference at an existing field or a framework-managed timestamp.";
106
+ };
107
+ };
@@ -0,0 +1,107 @@
1
+ export const PII_RULES = {
2
+ multipleSubjectAnnotations: {
3
+ id: "pii/multiple-subject-annotations",
4
+ summary: "A field carries at most one subject-key annotation.",
5
+ fix: "Keep a single personal annotation on the field.",
6
+ },
7
+ textWithoutPersonalStance: {
8
+ id: "pii/text-without-personal-stance",
9
+ summary: "Every text and longText field declares whether it holds personal data.",
10
+ fix: "Declare a personal stance, or personal: false with a reason stating why the value is not personal data.",
11
+ },
12
+ plaintextUserReference: {
13
+ id: "pii/plaintext-user-reference",
14
+ summary: "A reference to the user entity is never plaintext.",
15
+ fix: 'Use the default reference field or personal: "ref" instead of personal: false.',
16
+ },
17
+ lookupableNonText: {
18
+ id: "pii/lookupable-non-text",
19
+ summary: "Blind-index lookups apply to text fields only.",
20
+ fix: 'Use a text field or set find: "none".',
21
+ },
22
+ lookupableWithoutSubject: {
23
+ id: "pii/lookupable-without-subject",
24
+ summary: "A blind-index lookup requires a subject annotation on the field.",
25
+ fix: 'Add a personal annotation or set find: "none".',
26
+ },
27
+ sensitiveWithoutCiphertext: {
28
+ id: "pii/sensitive-without-ciphertext",
29
+ summary: "A sensitive field is stored as ciphertext at rest.",
30
+ fix: "Add a subject annotation or encrypted: true.",
31
+ },
32
+ writeOnlyEmbeddedSubField: {
33
+ id: "pii/write-only-embedded-sub-field",
34
+ summary: "writeOnly is only supported on top-level text fields, not on embedded sub-fields.",
35
+ fix: "Remove writeOnly from the sub-field or declare it on a top-level text field.",
36
+ },
37
+ writeOnlyNonText: {
38
+ id: "pii/write-only-non-text",
39
+ summary: "writeOnly applies to text fields only.",
40
+ fix: "Use a text field or remove writeOnly.",
41
+ },
42
+ writeOnlyWithoutSensitive: {
43
+ id: "pii/write-only-without-sensitive",
44
+ summary: "A writeOnly field is also sensitive.",
45
+ fix: 'Declare the field with find: "secret".',
46
+ },
47
+ writeOnlyConflictingFlag: {
48
+ id: "pii/write-only-conflicting-flag",
49
+ summary: "A writeOnly field cannot be defaulted, searched, sorted, filtered or looked up.",
50
+ fix: "Remove either writeOnly or the conflicting flag.",
51
+ },
52
+ subjectKeySortable: {
53
+ id: "pii/subject-key-sortable",
54
+ summary: "A field with a subject-key annotation is not sortable, its column is ciphertext.",
55
+ fix: 'Drop sortable, use find: "exact" for equality lookups, or keep the field plaintext.',
56
+ },
57
+ sensitiveSearchable: {
58
+ id: "pii/sensitive-searchable",
59
+ summary: "A sensitive field is never searchable.",
60
+ fix: "Drop searchable, or add writeOnly to never return the value.",
61
+ },
62
+ personalJsonbFlag: {
63
+ id: "pii/personal-jsonb-flag",
64
+ summary: "A personal jsonb field cannot be sortable, filterable, searchable or lookupable, its value is a ciphertext string.",
65
+ fix: "Remove the flag or the personal annotation.",
66
+ },
67
+ personalJsonbIndex: {
68
+ id: "pii/personal-jsonb-index",
69
+ summary: "A personal jsonb field is not part of an entity index.",
70
+ fix: "Drop the index or the personal annotation.",
71
+ },
72
+ personalJsonbCustomFields: {
73
+ id: "pii/personal-jsonb-custom-fields",
74
+ summary: "The custom-fields jsonb column cannot carry a personal annotation.",
75
+ fix: "Drop the personal annotation.",
76
+ },
77
+ personalOfMissingOwnerName: {
78
+ id: "pii/personal-of-missing-owner-name",
79
+ summary: "personal: { of } names the owner field.",
80
+ fix: 'Pass the name of the owner field: personal: { of: "<ownerField>" }.',
81
+ },
82
+ personalOfUnknownOwner: {
83
+ id: "pii/personal-of-unknown-owner",
84
+ summary: "The owner field named by personal: { of } exists on the entity.",
85
+ fix: "Point personal.of at an existing field of the entity.",
86
+ },
87
+ personalOfOwnerType: {
88
+ id: "pii/personal-of-owner-type",
89
+ summary: "The owner field named by personal: { of } is a reference or text field.",
90
+ fix: "Use a reference or text field as owner.",
91
+ },
92
+ personalOfInvalidWhenAbsent: {
93
+ id: "pii/personal-of-invalid-when-absent",
94
+ summary: 'personal.whenAbsent is either "tenant" or "self".',
95
+ fix: 'Remove whenAbsent or set it to "tenant" or "self".',
96
+ },
97
+ personalOfOptionalOwner: {
98
+ id: "pii/personal-of-optional-owner",
99
+ summary: "An optional owner field of personal: { of } declares a whenAbsent fallback.",
100
+ fix: 'Set whenAbsent to "tenant" or "self", or make the owner field required.',
101
+ },
102
+ retentionUnknownReference: {
103
+ id: "retention/unknown-reference",
104
+ summary: "retention.reference names an existing field or a framework timestamp.",
105
+ fix: "Point retention.reference at an existing field or a framework-managed timestamp.",
106
+ },
107
+ };
@@ -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;
@@ -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
@@ -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";
@@ -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";
@@ -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.402.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.402.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.402.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,16 @@
1
1
  [
2
+ {
3
+ "version": "0.402.0",
4
+ "type": "improvement",
5
+ "title": "Boot violations carry rule ids with a fix and a doc link",
6
+ "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."
7
+ },
8
+ {
9
+ "version": "0.402.0",
10
+ "type": "fix",
11
+ "title": "Migration replay keeps tables whose name ends in \"as\"",
12
+ "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."
13
+ },
2
14
  {
3
15
  "version": "0.401.0",
4
16
  "type": "improvement",