@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
@@ -0,0 +1,170 @@
1
+ import { isUuid } from "@cosmicdrift/kumiko-types/identifiers";
2
+ import { isFrameworkSystemUser } from "../db/lock-guard.js";
3
+ import { selectRowForUpdateById } from "../db/queries/entity-read.js";
4
+ import { asEntityTableMeta } from "../db/table-introspection.js";
5
+ import { tenantDbRunner } from "../db/tenant-db-runner.js";
6
+ import { RecordLockedError } from "../errors/classes.js";
7
+ import { AccessDeniedError, InternalError, NotFoundError, reraiseAsKumikoError, } from "../errors/index.js";
8
+ import { assertRowFitsBindTarget } from "./bind-pending-file-refs.js";
9
+ import { canCallerReadFileRef } from "./can-caller-read-file-ref.js";
10
+ import { getFileRefExecutor } from "./create-file-ref.js";
11
+ import { loadFileRefRows } from "./load-save-file-refs.js";
12
+ import { createRecordReadCheck } from "./record-read-check.js";
13
+ import { createRecordWriteCheck, findImplicitEntityTable, RecordWriteDecisions, } from "./record-write-check.js";
14
+ const CONCURRENT_WRITE_ERROR_CODES = ["version_conflict", "unique_violation"];
15
+ function nullableString(value) {
16
+ return value === null || typeof value === "string";
17
+ }
18
+ // The file guard (and an app's accessGuard) reads these columns; a row missing one is corrupt.
19
+ async function loadRow(ctx, fileRefId) {
20
+ if (!isUuid(fileRefId))
21
+ return undefined;
22
+ const row = (await loadFileRefRows(ctx.db, [fileRefId], ctx.user.tenantId)).get(fileRefId);
23
+ if (row === undefined)
24
+ return undefined;
25
+ const { tenantId, storageKey, fileName, mimeType, size, entityType, entityId, fieldName } = row;
26
+ const { insertedById, version } = row;
27
+ if (typeof tenantId !== "string" ||
28
+ typeof storageKey !== "string" ||
29
+ typeof fileName !== "string" ||
30
+ typeof mimeType !== "string" ||
31
+ typeof size !== "number" ||
32
+ typeof version !== "number" ||
33
+ !nullableString(entityType) ||
34
+ !nullableString(entityId) ||
35
+ !nullableString(fieldName) ||
36
+ !nullableString(insertedById)) {
37
+ return undefined;
38
+ }
39
+ return {
40
+ fileRef: {
41
+ id: fileRefId,
42
+ tenantId,
43
+ storageKey,
44
+ fileName,
45
+ mimeType,
46
+ size,
47
+ entityType,
48
+ entityId,
49
+ fieldName,
50
+ insertedById,
51
+ },
52
+ version,
53
+ };
54
+ }
55
+ function isOwnedBy(fileRef, owner) {
56
+ return (fileRef.entityType === owner.entityType &&
57
+ fileRef.entityId === owner.entityId &&
58
+ fileRef.fieldName === owner.fieldName);
59
+ }
60
+ // Holds the source row until commit, so a lock or ownership change committed by another
61
+ // transaction is either seen by the write check below or waits for this rebind to finish.
62
+ async function lockSourceRow(ctx, from) {
63
+ const table = findImplicitEntityTable(ctx.registry, from.entityType);
64
+ const tableName = asEntityTableMeta(table)?.tableName;
65
+ // skip: no stored row to lock; the write check below answers not_found for it
66
+ if (tableName === undefined)
67
+ return;
68
+ await selectRowForUpdateById(tenantDbRunner(ctx.db), tableName, from.entityId);
69
+ }
70
+ // A caller other than the system job must be able to read the file today and must be able to
71
+ // write both records as a save of them would: the source it takes the file from (unlocked, and
72
+ // with an update handler the caller passes; without one only the system job may move the file),
73
+ // and the target field it puts the file into.
74
+ async function assertCallerMayMove(ctx, fileRef, from, to) {
75
+ if (!(await canCallerReadFileRef(ctx, fileRef))) {
76
+ throw new AccessDeniedError({ message: `rebindFileRef: file ${fileRef.id} is not readable` });
77
+ }
78
+ const canWriteRecord = createRecordWriteCheck({
79
+ registry: ctx.registry,
80
+ canReadRecord: createRecordReadCheck(ctx.registry, (qn, payload) => ctx.dispatch.query(qn, payload)),
81
+ tenantDbFor: () => ctx.db,
82
+ });
83
+ await lockSourceRow(ctx, from);
84
+ const sourceDecision = await canWriteRecord({ ...from, user: ctx.user });
85
+ if (sourceDecision === RecordWriteDecisions.locked) {
86
+ throw new RecordLockedError({ entityName: from.entityType, entityId: from.entityId });
87
+ }
88
+ if (sourceDecision !== RecordWriteDecisions.writable) {
89
+ throw new AccessDeniedError({
90
+ message: `rebindFileRef: source ${from.entityType}.${from.fieldName} of ${from.entityId} is not writable`,
91
+ details: { decision: sourceDecision },
92
+ });
93
+ }
94
+ const decision = await canWriteRecord({ ...to, user: ctx.user });
95
+ if (decision !== RecordWriteDecisions.writable) {
96
+ throw new AccessDeniedError({
97
+ message: `rebindFileRef: ${to.entityType}.${to.fieldName} of ${to.entityId} is not writable`,
98
+ details: { decision },
99
+ });
100
+ }
101
+ }
102
+ // The caller checks find the target as a side effect; the system path needs its own lookup so a
103
+ // job cannot point a file at a record that does not exist in its tenant.
104
+ async function assertTargetRecordExists(ctx, to) {
105
+ const entity = ctx.registry.getEntity(to.entityType);
106
+ const table = findImplicitEntityTable(ctx.registry, to.entityType);
107
+ const row = entity === undefined || table === undefined
108
+ ? undefined
109
+ : await ctx.db.fetchOne(table, {
110
+ id: to.entityId,
111
+ ...(entity.softDelete === true && { isDeleted: false }),
112
+ });
113
+ if (row === undefined)
114
+ throw new NotFoundError(to.entityType, to.entityId);
115
+ }
116
+ /**
117
+ * Moves a bound file from one record's file field to another's, for a record that takes over a
118
+ * file uploaded elsewhere (an upload record, a mail attachment). Server code only; pending uploads
119
+ * go through bindPendingFileRef.
120
+ *
121
+ * Only files of the caller's tenant are found ("not-found" otherwise, as for unknown ids). The
122
+ * file must still sit at `from`; "moved" means it does not (any more), for example because a
123
+ * parallel rebind took it first. A file already at `to` answers "rebound" without a write. The
124
+ * target must be a registered file field whose accept/maxSize the file fits (InternalError /
125
+ * UnprocessableError). Unless the caller is the framework system user, the caller must be able
126
+ * to read the file and to write both the source record and field (it must not be locked, and an
127
+ * entity without update handler leaves the move to the system user) and the target record and
128
+ * field (AccessDeniedError, RecordLockedError); the system user skips these checks, and only the
129
+ * target record has to exist (NotFoundError). The update is checked against the file's stream
130
+ * version read here, so a bind committing in between returns "version-conflict"; the source row
131
+ * stays locked until the surrounding transaction commits, so a lock set on `from` in between is
132
+ * either seen or waits. Rebind first, then save the id into
133
+ * the target field: the save accepts a file already bound to that record, and the source record
134
+ * no longer disposes of it when its field is cleared or the record is deleted.
135
+ * A handler passes its ctx; a job passes `{ db, registry, user: ctx.systemUser }`.
136
+ */
137
+ export async function rebindFileRef(ctx, args) {
138
+ const { fileRefId, from, to } = args;
139
+ const loaded = await loadRow(ctx, fileRefId);
140
+ if (loaded === undefined)
141
+ return "not-found";
142
+ const { fileRef, version } = loaded;
143
+ if (isOwnedBy(fileRef, to))
144
+ return "rebound";
145
+ if (!isOwnedBy(fileRef, from))
146
+ return "moved";
147
+ assertRowFitsBindTarget(ctx.registry, { entityName: to.entityType, fieldName: to.fieldName }, fileRef);
148
+ if (isFrameworkSystemUser(ctx.user)) {
149
+ await assertTargetRecordExists(ctx, to);
150
+ }
151
+ else {
152
+ const { dispatch } = ctx;
153
+ if (dispatch === undefined) {
154
+ throw new InternalError({
155
+ message: "rebindFileRef: a caller other than the system user needs ctx.dispatch.query",
156
+ });
157
+ }
158
+ await assertCallerMayMove({ ...ctx, dispatch }, fileRef, from, to);
159
+ }
160
+ const moved = await getFileRefExecutor().update({
161
+ id: fileRefId,
162
+ version,
163
+ changes: { entityType: to.entityType, entityId: to.entityId, fieldName: to.fieldName },
164
+ }, ctx.user, ctx.db);
165
+ if (moved.isSuccess)
166
+ return "rebound";
167
+ if (CONCURRENT_WRITE_ERROR_CODES.includes(moved.error.code))
168
+ return "version-conflict";
169
+ throw reraiseAsKumikoError(moved.error);
170
+ }
@@ -56,3 +56,9 @@ export type RequiredLocaleGap = {
56
56
  * reported as gap owners.
57
57
  */
58
58
  export declare function findRequiredLocaleGaps(features: readonly FeatureDefinition[], requiredLocales: readonly string[]): readonly RequiredLocaleGap[];
59
+ export declare function embeddedCellLabelKey(featureName: string, entityName: string, fieldName: string, subFieldName: string): string;
60
+ export declare function embeddedCellOptionLabelKey(featureName: string, entityName: string, fieldName: string, subFieldName: string, value: string): string;
61
+ /** Keys for select option labels and embedded-list cells that the screens of one feature
62
+ * display. Opt-in via `requireFieldValueTranslations`: without them the renderer falls back
63
+ * to the raw option value or sub-field name. */
64
+ export declare function fieldValueTranslationKeysFromFeature(feature: FeatureDefinition): readonly string[];
@@ -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);
@@ -596,3 +599,64 @@ export function findRequiredLocaleGaps(features, requiredLocales) {
596
599
  }
597
600
  return gaps;
598
601
  }
602
+ export function embeddedCellLabelKey(featureName, entityName, fieldName, subFieldName) {
603
+ return `${fieldLabelKey(featureName, entityName, fieldName)}:cell:${subFieldName}`;
604
+ }
605
+ export function embeddedCellOptionLabelKey(featureName, entityName, fieldName, subFieldName, value) {
606
+ return `${embeddedCellLabelKey(featureName, entityName, fieldName, subFieldName)}:option:${value}`;
607
+ }
608
+ function pushFieldValueTranslationKeys(out, featureName, entityName, fieldName, def, includeEmbeddedCells) {
609
+ if (def.type === "select" || def.type === "multiSelect") {
610
+ for (const value of def.options) {
611
+ out.add(selectFacetOptionKey(featureName, entityName, fieldName, value));
612
+ }
613
+ // skip: option keys are complete, nothing else to collect for select fields
614
+ return;
615
+ }
616
+ // skip: only multi-row embedded fields render cell labels
617
+ if (!includeEmbeddedCells || def.type !== "embedded" || def.multiple !== true)
618
+ return;
619
+ for (const [subName, sub] of Object.entries(def.schema)) {
620
+ out.add(embeddedCellLabelKey(featureName, entityName, fieldName, subName));
621
+ if (sub.type !== "select")
622
+ continue;
623
+ for (const value of sub.options) {
624
+ out.add(embeddedCellOptionLabelKey(featureName, entityName, fieldName, subName, value));
625
+ }
626
+ }
627
+ }
628
+ /** Keys for select option labels and embedded-list cells that the screens of one feature
629
+ * display. Opt-in via `requireFieldValueTranslations`: without them the renderer falls back
630
+ * to the raw option value or sub-field name. */
631
+ export function fieldValueTranslationKeysFromFeature(feature) {
632
+ const out = new Set();
633
+ for (const screen of Object.values(feature.screens)) {
634
+ if (screen.type === "entityList") {
635
+ const entity = feature.entities?.[screen.entity];
636
+ if (!entity)
637
+ continue;
638
+ for (const col of screen.columns) {
639
+ const { field } = normalizeListColumn(col);
640
+ const def = entity.fields[field];
641
+ if (def)
642
+ pushFieldValueTranslationKeys(out, feature.name, screen.entity, field, def, false);
643
+ }
644
+ }
645
+ else if (screen.type === "entityEdit") {
646
+ const entity = feature.entities?.[screen.entity];
647
+ if (!entity)
648
+ continue;
649
+ for (const section of screen.layout.sections) {
650
+ if (isExtensionEditSection(section) || section.kind === "relatedList")
651
+ continue;
652
+ for (const spec of sectionFieldSpecs(section)) {
653
+ const field = editFieldName(spec);
654
+ const def = entity.fields[field];
655
+ if (def)
656
+ pushFieldValueTranslationKeys(out, feature.name, screen.entity, field, def, true);
657
+ }
658
+ }
659
+ }
660
+ }
661
+ return [...out];
662
+ }
@@ -41,8 +41,10 @@ export function parseValidateBootOptions(raw) {
41
41
  const navAllowlistExempt = readOptionalOption(raw, "navAllowlistExempt", isStringSet, "a Set<string>");
42
42
  const warnOnUniqueAccessRoles = readOptionalOption(raw, "warnOnUniqueAccessRoles", isBoolean, "a boolean");
43
43
  const requiredLocales = readOptionalOption(raw, "requiredLocales", isNonEmptyStringArray, "an array of non-empty strings");
44
+ const requireFieldValueTranslations = readOptionalOption(raw, "requireFieldValueTranslations", isBoolean, "a boolean");
44
45
  return {
45
46
  ...(requiredLocales !== undefined && { requiredLocales }),
47
+ ...(requireFieldValueTranslations !== undefined && { requireFieldValueTranslations }),
46
48
  ...(navAllowlist !== undefined && { navAllowlist }),
47
49
  ...(navAllowlistExempt !== undefined && { navAllowlistExempt }),
48
50
  ...(warnOnUniqueAccessRoles !== undefined && { warnOnUniqueAccessRoles }),
@@ -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.400.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.400.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.400.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,29 @@
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
+ },
14
+ {
15
+ "version": "0.401.0",
16
+ "type": "improvement",
17
+ "title": "rebindFileRef, readGate allLinks with documents linkReadMode, documents tab entity fix",
18
+ "detail": "`rebindFileRef(ctx, { fileRefId, from, to })` from `@cosmicdrift/kumiko-framework/files` moves a file that is already bound (for example to an upload record or an inbound mail) onto another record's file field, for server code where a record takes over that file; pending uploads keep using `bindPendingFileRef`. It finds only files of the caller's tenant (\"not-found\" otherwise), answers \"moved\" when the file no longer sits at `from`, \"rebound\" when it was moved or already sits at `to`, and \"version-conflict\" when a parallel bind committed first, because the update is checked against the stream version. The target must be a registered file field whose accept and maxSize the file fits. A caller other than the framework system user must be able to read the file and must be able to write both the source record and field (not locked; an entity without update handler leaves the move to the system user) and the target record and field; the source row stays locked until the transaction commits, so a lock set in between is either seen or waits; a system job (`{ db, registry, user: ctx.systemUser }`) skips these checks, but the target record must exist. Rebind first, then save the id into the target field; clearing or deleting the source record afterwards leaves the file alone. An entity `readGate` takes `allLinks` as an alternative to `anyLink`: a row with links is visible only when the viewer can read every live link, and the boot validator rejects a gate that declares both. `createDocumentsFeature({ linkReadMode: \"allLinks\" })` switches the documents gate to it; the default stays `anyLink`, and the file-owner part is unchanged. `documentsTab()` now references `documents:filed-document`; before, a projection screen using it failed boot after the entity rename. Linking a document to a target entity without an update handler is now allowed for the roles in that entity's `access.write` (plus its row-level write rule); a target with neither stays unlinkable. `documents:write:link` and `documents:write:unlink` have title and field-label translations (de, en), and the documents write handlers carry an explicit `agent.risk` (link, unlink, create, update \"mid\"; import \"high\"). `createDocumentsFeature({ categoryLabels })` takes a de/en label per category (an unlabelled one shows its raw value); a key that is not in `categories` throws."
19
+ },
20
+ {
21
+ "version": "0.401.0",
22
+ "type": "breaking",
23
+ "title": "Boot rejects a user reference with personal: false and file accept entries that never match",
24
+ "detail": "`createReferenceField({ entity, personal?, reason?, required? })` builds a foreign key; a target ending in `:user` defaults to `personal: \"ref\"` and throws on `personal: false`. The boot validator now rejects a reference field to the user entity that carries `personal: false`, and a file, files, image or images field (also inside embedded schemas) whose `accept` has an entry that is not a bare lower-case extension, because the upload check compares the file's extension only. The new boot option `requireFieldValueTranslations: true` (also readable from the schema CLI input) lists every missing translation of select/multiSelect options and embedded cell labels shown by entityList/entityEdit screens in one error. `defineEntityWriteHandler` and the create/update/delete/restore wrappers return a type whose `reserveBeforeTransaction`, `beforeTransaction` and `beforeTransactionWhenNested` are `undefined`, so a handler spread into `defineWriteHandler` can add its own. user-data-rights has the export-job status option labels (de, en, es, fr, it); managed-pages branding accepts `png, jpg, jpeg, webp` instead of MIME types.",
25
+ "migration": "Replace `personal: false` on a reference to the user entity with `createReferenceField({ entity: \"user:user\" })` or `personal: \"ref\"`. Write `accept` entries as bare extensions (`\"pdf\"`, not `\".pdf\"` or `\"application/pdf\"`)."
26
+ },
2
27
  {
3
28
  "version": "0.400.0",
4
29
  "type": "improvement",