@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,9 @@
1
1
  import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta.js";
2
2
  import { normalizeListColumn } from "../screen-helpers.js";
3
+ import { collectBootRuleViolations } from "./boot-report.js";
4
+ import { bootRuleError } from "./boot-rule.js";
3
5
  import { isSortableSqlDerivedField } from "./derived-sql-fields.js";
6
+ import { ENTITY_LIST_RULES } from "./entity-list-screens-rules.js";
4
7
  /** Operator lists default searchable; low-cardinality audit trails stay opt-out. */
5
8
  export const SEARCHABLE_FALSE_WHITELIST = new Set(["download-attempt-list", "cap-list"]);
6
9
  // Same set computeListViewModel (headless) can actually render — NOT
@@ -21,57 +24,87 @@ function hasFilterableFields(feature, entityName) {
21
24
  function hasEntityEditDetail(feature, entityName) {
22
25
  return Object.values(feature.screens).some((s) => s.type === "entityEdit" && s.entity === entityName);
23
26
  }
24
- function validateOneEntityListScreen(feature, screen) {
25
- const prefix = `[entityList] Feature "${feature.name}" screen "${screen.id}"`;
27
+ function screenPrefix(feature, screen) {
28
+ return `[entityList] Feature "${feature.name}" screen "${screen.id}"`;
29
+ }
30
+ function checkSearchable(feature, screen) {
26
31
  if (screen.searchable === false && !SEARCHABLE_FALSE_WHITELIST.has(screen.id)) {
27
- throw new Error(`${prefix}: searchable defaults to true for operator lists — set searchable: true or add "${screen.id}" to the whitelist`);
32
+ throw bootRuleError(ENTITY_LIST_RULES.searchableFalse, {
33
+ message: `${screenPrefix(feature, screen)}: searchable defaults to true for operator lists — set searchable: true or add "${screen.id}" to the whitelist`,
34
+ });
28
35
  }
36
+ }
37
+ function checkDefaultSortRequired(feature, screen) {
29
38
  const filtersActive = hasFilterableFields(feature, screen.entity);
30
39
  const searchable = screen.searchable !== false;
31
40
  if ((searchable || filtersActive) && screen.defaultSort === undefined) {
32
- throw new Error(`${prefix}: defaultSort required when searchable or filterable fields are active. Set defaultSort: { field, direction } on the screen.`);
41
+ throw bootRuleError(ENTITY_LIST_RULES.defaultSortRequired, {
42
+ message: `${screenPrefix(feature, screen)}: defaultSort required when searchable or filterable fields are active. Set defaultSort: { field, direction } on the screen.`,
43
+ });
33
44
  }
34
- if (screen.defaultSort !== undefined) {
35
- const sortField = screen.defaultSort.field;
36
- const col = screen.columns.find((c) => normalizeListColumn(c).field === sortField);
37
- if (col === undefined) {
38
- throw new Error(`${prefix}: defaultSort.field "${sortField}" is not a listed column. Pick a field from columns or add it there.`);
39
- }
40
- const entityDef = feature.entities?.[screen.entity];
41
- const fieldDef = entityDef?.fields[sortField];
42
- const isSortable = (entityDef !== undefined && isSortableSqlDerivedField(entityDef, sortField)) ||
43
- (fieldDef !== undefined && "sortable" in fieldDef && fieldDef.sortable === true);
44
- if (!isSortable) {
45
- throw new Error(`${prefix}: defaultSort column "${sortField}" must be sortable`);
46
- }
45
+ }
46
+ function checkDefaultSortColumn(feature, screen) {
47
+ // skip: a missing defaultSort is reported by checkDefaultSortRequired
48
+ if (screen.defaultSort === undefined)
49
+ return;
50
+ const prefix = screenPrefix(feature, screen);
51
+ const sortField = screen.defaultSort.field;
52
+ const col = screen.columns.find((c) => normalizeListColumn(c).field === sortField);
53
+ if (col === undefined) {
54
+ throw bootRuleError(ENTITY_LIST_RULES.defaultSortNotListed, {
55
+ message: `${prefix}: defaultSort.field "${sortField}" is not a listed column. Pick a field from columns or add it there.`,
56
+ });
47
57
  }
48
- if (hasEntityEditDetail(feature, screen.entity)) {
49
- const hasNavigate = (screen.rowActions ?? []).some((a) => a.kind === "navigate" && (a.id === "view" || a.id === "edit"));
50
- if (!hasNavigate) {
51
- throw new Error(`${prefix}: detail screen exists — add a navigate rowAction with id "view" or "edit"`);
52
- }
58
+ const entityDef = feature.entities?.[screen.entity];
59
+ const fieldDef = entityDef?.fields[sortField];
60
+ const isSortable = (entityDef !== undefined && isSortableSqlDerivedField(entityDef, sortField)) ||
61
+ (fieldDef !== undefined && "sortable" in fieldDef && fieldDef.sortable === true);
62
+ if (!isSortable) {
63
+ throw bootRuleError(ENTITY_LIST_RULES.defaultSortNotSortable, {
64
+ message: `${prefix}: defaultSort column "${sortField}" must be sortable`,
65
+ });
53
66
  }
54
- for (const col of screen.columns) {
67
+ }
68
+ function checkDetailNavigate(feature, screen) {
69
+ // skip: only lists with a detail screen need a navigate row action
70
+ if (!hasEntityEditDetail(feature, screen.entity))
71
+ return;
72
+ const hasNavigate = (screen.rowActions ?? []).some((a) => a.kind === "navigate" && (a.id === "view" || a.id === "edit"));
73
+ if (!hasNavigate) {
74
+ throw bootRuleError(ENTITY_LIST_RULES.detailWithoutNavigate, {
75
+ message: `${screenPrefix(feature, screen)}: detail screen exists — add a navigate rowAction with id "view" or "edit"`,
76
+ });
77
+ }
78
+ }
79
+ function checkColumns(feature, screen) {
80
+ collectBootRuleViolations(screen.columns, (col) => {
55
81
  const normalized = normalizeListColumn(col);
56
- if (normalized.label === undefined) {
57
- const entity = feature.entities?.[screen.entity];
58
- const isDerived = entity?.derivedFields?.[normalized.field] !== undefined;
59
- // Row-meta column (id/tenantId/version/insertedAt/modifiedAt/...) is a
60
- // real base-table column without being a declared entity field — e.g.
61
- // a SystemAdmin cross-tenant list exposing tenantId.
62
- const isRowMeta = LIST_ROW_META_COLUMN_NAMES.has(normalized.field);
63
- if (!entity?.fields[normalized.field] && !isDerived && !isRowMeta) {
64
- throw new Error(`${prefix}: unknown column field "${normalized.field}"`);
65
- }
82
+ // skip: a labelled column is free text, not a field reference
83
+ if (normalized.label !== undefined)
84
+ return;
85
+ const entity = feature.entities?.[screen.entity];
86
+ const isDerived = entity?.derivedFields?.[normalized.field] !== undefined;
87
+ // Row-meta column (id/tenantId/version/insertedAt/modifiedAt/...) is a
88
+ // real base-table column without being a declared entity field — e.g.
89
+ // a SystemAdmin cross-tenant list exposing tenantId.
90
+ const isRowMeta = LIST_ROW_META_COLUMN_NAMES.has(normalized.field);
91
+ if (!entity?.fields[normalized.field] && !isDerived && !isRowMeta) {
92
+ throw bootRuleError(ENTITY_LIST_RULES.unknownColumn, {
93
+ message: `${screenPrefix(feature, screen)}: unknown column field "${normalized.field}"`,
94
+ });
66
95
  }
67
- }
96
+ });
68
97
  }
98
+ const ENTITY_LIST_CHECKS = [
99
+ checkSearchable,
100
+ checkDefaultSortRequired,
101
+ checkDefaultSortColumn,
102
+ checkDetailNavigate,
103
+ checkColumns,
104
+ ];
69
105
  export function validateEntityListScreens(features) {
70
- for (const feature of features) {
71
- for (const screen of Object.values(feature.screens)) {
72
- if (screen.type !== "entityList")
73
- continue;
74
- validateOneEntityListScreen(feature, screen);
75
- }
76
- }
106
+ const screens = features.flatMap((feature) => Object.values(feature.screens).flatMap((screen) => screen.type === "entityList" ? [{ feature, screen }] : []));
107
+ collectBootRuleViolations(screens, ({ feature, screen }) => {
108
+ collectBootRuleViolations(ENTITY_LIST_CHECKS, (check) => check(feature, screen));
109
+ });
77
110
  }
@@ -77,9 +77,19 @@ function assertFileSubField(where, sub) {
77
77
  throw new Error(`${where} has invalid maxSize "${sub.maxSize}". Use e.g. "10mb", "500kb".`);
78
78
  }
79
79
  }
80
- if (sub.accept?.some((ext) => ext.length === 0)) {
80
+ assertAcceptEntries(where, sub.accept);
81
+ }
82
+ // validateFile compares accept entries with the lower-cased last file-name segment, so only
83
+ // bare lower-case extensions can ever match.
84
+ const BARE_EXTENSION = /^[a-z0-9]+$/;
85
+ function assertAcceptEntries(where, accept) {
86
+ if (accept?.some((ext) => ext.length === 0)) {
81
87
  throw new Error(`${where} has an empty entry in accept. List file extensions like "pdf".`);
82
88
  }
89
+ const offender = accept?.find((entry) => !BARE_EXTENSION.test(entry));
90
+ if (offender !== undefined) {
91
+ throw new Error(`${where} has accept entry "${offender}", which can never match an uploaded file. accept takes bare lower-case file extensions without dot, MIME type or wildcard: use "pdf" instead of ".pdf" or "application/pdf".`);
92
+ }
83
93
  }
84
94
  function assertSelectSubFieldTones(where, sub) {
85
95
  // skip: only select sub-fields with declared tones have tones to check
@@ -168,6 +178,14 @@ function assertField(feature, entityName, entity, fieldName, field) {
168
178
  }
169
179
  if (field.type === "embedded")
170
180
  assertEmbeddedField(where, field);
181
+ if (isFileAcceptField(field))
182
+ assertAcceptEntries(where, field.accept);
183
+ }
184
+ function isFileAcceptField(field) {
185
+ return (field.type === "file" ||
186
+ field.type === "files" ||
187
+ field.type === "image" ||
188
+ field.type === "images");
171
189
  }
172
190
  export function validateFieldConditions(feature) {
173
191
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
@@ -0,0 +1,27 @@
1
+ export declare const I18N_RULES: {
2
+ readonly missingFeatureKey: {
3
+ readonly id: "i18n/missing-feature-key";
4
+ readonly summary: "Every translation key a feature surface requires is defined.";
5
+ readonly fix: "Add the key via r.translations(...) in the feature.";
6
+ };
7
+ readonly missingSettingsHubKey: {
8
+ readonly id: "i18n/missing-settings-hub-key";
9
+ readonly summary: "Every translation key the generated settings hub requires is defined.";
10
+ readonly fix: "Add the key via r.translations(...) in the owning feature.";
11
+ };
12
+ readonly localeInconsistent: {
13
+ readonly id: "i18n/locale-inconsistent";
14
+ readonly summary: "Every locale a feature uses is maintained for every one of its translation keys.";
15
+ readonly fix: "Add the missing locale to the key, or move that locale into a locale package.";
16
+ };
17
+ readonly missingRequiredLocale: {
18
+ readonly id: "i18n/missing-required-locale";
19
+ readonly summary: "Every translation key exists in every locale the app requires.";
20
+ readonly fix: "Load the matching locale package or add the missing translations.";
21
+ };
22
+ readonly missingFieldValueTranslation: {
23
+ readonly id: "i18n/missing-field-value-translation";
24
+ readonly summary: "With requireFieldValueTranslations, select options and embedded cells are translated.";
25
+ readonly fix: "Add the keys via r.translations(...) in the owning feature.";
26
+ };
27
+ };
@@ -0,0 +1,27 @@
1
+ export const I18N_RULES = {
2
+ missingFeatureKey: {
3
+ id: "i18n/missing-feature-key",
4
+ summary: "Every translation key a feature surface requires is defined.",
5
+ fix: "Add the key via r.translations(...) in the feature.",
6
+ },
7
+ missingSettingsHubKey: {
8
+ id: "i18n/missing-settings-hub-key",
9
+ summary: "Every translation key the generated settings hub requires is defined.",
10
+ fix: "Add the key via r.translations(...) in the owning feature.",
11
+ },
12
+ localeInconsistent: {
13
+ id: "i18n/locale-inconsistent",
14
+ summary: "Every locale a feature uses is maintained for every one of its translation keys.",
15
+ fix: "Add the missing locale to the key, or move that locale into a locale package.",
16
+ },
17
+ missingRequiredLocale: {
18
+ id: "i18n/missing-required-locale",
19
+ summary: "Every translation key exists in every locale the app requires.",
20
+ fix: "Load the matching locale package or add the missing translations.",
21
+ },
22
+ missingFieldValueTranslation: {
23
+ id: "i18n/missing-field-value-translation",
24
+ summary: "With requireFieldValueTranslations, select options and embedded cells are translated.",
25
+ fix: "Add the keys via r.translations(...) in the owning feature.",
26
+ },
27
+ };
@@ -2,3 +2,4 @@ import type { FeatureDefinition, Registry } from "../types/index.ts";
2
2
  export declare function validateI18nSurfaceKeys(features: readonly FeatureDefinition[], registry: Registry): void;
3
3
  export declare function validateTranslationLocaleConsistency(features: readonly FeatureDefinition[]): void;
4
4
  export declare function validateRequiredLocales(features: readonly FeatureDefinition[], requiredLocales: readonly string[] | undefined): void;
5
+ export declare function validateFieldValueTranslations(features: readonly FeatureDefinition[], enabled: boolean | undefined): void;
@@ -1,6 +1,9 @@
1
- import { buildEffectiveTranslationKeys, featureHasI18nSurface, findRequiredLocaleGaps, findTranslationLocaleGaps, requiredKeysFromFeature, requiredKeysFromNav, requiredKeysFromScreen, requiredKeysFromWorkspace, screenTitleKey, } from "../../i18n/required-surface-keys.js";
1
+ import { buildEffectiveTranslationKeys, featureHasI18nSurface, fieldValueTranslationKeysFromFeature, findRequiredLocaleGaps, findTranslationLocaleGaps, requiredKeysFromFeature, requiredKeysFromNav, requiredKeysFromScreen, requiredKeysFromWorkspace, screenTitleKey, } from "../../i18n/required-surface-keys.js";
2
2
  import { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE, } from "../build-config-feature-schema.js";
3
3
  import { EXTENSION_SELECTOR_HINT_KEY } from "../extension-selector-plugins.js";
4
+ import { collectBootRuleViolations } from "./boot-report.js";
5
+ import { bootRuleError } from "./boot-rule.js";
6
+ import { I18N_RULES } from "./i18n-keys-rules.js";
4
7
  function isDormantSecretsScreen(screen) {
5
8
  return screen.type === "secretsEdit" && screen.dormant === true;
6
9
  }
@@ -67,28 +70,32 @@ export function validateI18nSurfaceKeys(features, registry) {
67
70
  // declared secret would fail boot on its own generated keys.
68
71
  for (const key of Object.keys(generatedConfigHub.translations ?? {}))
69
72
  defined.add(key);
70
- for (const feature of features) {
71
- if (!featureHasI18nSurface(feature))
72
- continue;
73
- for (const key of requiredKeysFromFeature(feature)) {
74
- if (isFrameworkOwnedI18nKey(key))
75
- continue;
76
- if (!hasDefinedTranslation(defined, key)) {
77
- throw new Error(`[i18n] Feature "${feature.name}": required translation key missing: "${key}". Add it via r.translations(...) in this feature.`);
78
- }
73
+ const featureKeys = features.filter(featureHasI18nSurface).flatMap((feature) => requiredKeysFromFeature(feature)
74
+ .filter((key) => !isFrameworkOwnedI18nKey(key))
75
+ .map((key) => ({ featureName: feature.name, key })));
76
+ const checkFeatureKeys = () => collectBootRuleViolations(featureKeys, ({ featureName, key }) => {
77
+ if (!hasDefinedTranslation(defined, key)) {
78
+ throw bootRuleError(I18N_RULES.missingFeatureKey, {
79
+ message: `[i18n] Feature "${featureName}": required translation key missing: "${key}". Add it via r.translations(...) in this feature.`,
80
+ });
79
81
  }
80
- }
81
- for (const key of requiredKeysFromGeneratedConfigHub(generatedConfigHub)) {
82
+ });
83
+ const checkSettingsHubKeys = () => collectBootRuleViolations(requiredKeysFromGeneratedConfigHub(generatedConfigHub), (key) => {
82
84
  if (!hasDefinedTranslation(defined, key)) {
83
- throw new Error(`[i18n] Settings-Hub: required translation key missing: "${key}". Add it via r.translations(...) in the owning feature.`);
85
+ throw bootRuleError(I18N_RULES.missingSettingsHubKey, {
86
+ message: `[i18n] Settings-Hub: required translation key missing: "${key}". Add it via r.translations(...) in the owning feature.`,
87
+ });
84
88
  }
85
- }
89
+ });
90
+ collectBootRuleViolations([checkFeatureKeys, checkSettingsHubKeys], (run) => run());
86
91
  }
87
92
  export function validateTranslationLocaleConsistency(features) {
88
93
  const gaps = findTranslationLocaleGaps(features);
89
94
  if (gaps.length > 0) {
90
95
  const lines = gaps.map((gap) => `[i18n] Feature "${gap.featureName}": translation key "${gap.key}" is missing locale(s) ${gap.missingLocales.join(", ")}.`);
91
- throw new Error(`${lines.join("\n")}\nEvery locale a feature uses must be maintained for every one of its translation keys; otherwise move that locale into a locale package.`);
96
+ throw bootRuleError(I18N_RULES.localeInconsistent, {
97
+ message: `${lines.join("\n")}\nEvery locale a feature uses must be maintained for every one of its translation keys; otherwise move that locale into a locale package.`,
98
+ });
92
99
  }
93
100
  }
94
101
  export function validateRequiredLocales(features, requiredLocales) {
@@ -97,6 +104,26 @@ export function validateRequiredLocales(features, requiredLocales) {
97
104
  : findRequiredLocaleGaps(features, requiredLocales);
98
105
  if (requiredLocales !== undefined && gaps.length > 0) {
99
106
  const lines = gaps.map((gap) => `[i18n] Feature "${gap.featureName}": translation key "${gap.key}" is missing required locale(s) ${gap.missingLocales.join(", ")}.`);
100
- throw new Error(`${lines.join("\n")}\nThe app requires locales [${requiredLocales.join(", ")}] for every translation key; load the matching locale package or add the missing translations.`);
107
+ throw bootRuleError(I18N_RULES.missingRequiredLocale, {
108
+ message: `${lines.join("\n")}\nThe app requires locales [${requiredLocales.join(", ")}] for every translation key; load the matching locale package or add the missing translations.`,
109
+ });
110
+ }
111
+ }
112
+ export function validateFieldValueTranslations(features, enabled) {
113
+ // skip: opt-in rule, off unless the app enables requireFieldValueTranslations
114
+ if (enabled !== true)
115
+ return;
116
+ const defined = buildEffectiveTranslationKeys(features);
117
+ const missing = [];
118
+ for (const feature of features) {
119
+ for (const key of fieldValueTranslationKeysFromFeature(feature)) {
120
+ if (!hasDefinedTranslation(defined, key))
121
+ missing.push(` - ${feature.name}: ${key}`);
122
+ }
123
+ }
124
+ if (missing.length > 0) {
125
+ throw bootRuleError(I18N_RULES.missingFieldValueTranslation, {
126
+ message: `[i18n] ${missing.length} select option / embedded-cell translation key(s) missing (requireFieldValueTranslations):\n${missing.join("\n")}\nAdd them via r.translations(...) in the owning feature.`,
127
+ });
101
128
  }
102
129
  }
@@ -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
+ };