@cosmicdrift/kumiko-framework 0.399.0 → 0.401.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 (144) hide show
  1. package/AGENTS.md +4 -3
  2. package/agents/api/framework-engine.md +16 -8
  3. package/agents/api/framework-ui-types.md +10 -5
  4. package/agents/api/renderer-web.md +2 -1
  5. package/agents/api/testing-stack.md +1 -0
  6. package/agents/api/types.md +256 -80
  7. package/agents/api.md +3 -3
  8. package/agents/examples/derived-fields-sql.md +251 -0
  9. package/agents/examples/documents.md +161 -0
  10. package/agents/examples/inspection-checklist.md +807 -0
  11. package/agents/examples/relations.md +13 -3
  12. package/agents/examples.md +3 -0
  13. package/agents/features-index.md +2 -0
  14. package/agents/features.md +8 -0
  15. package/dist/api/entity-read-access.d.ts +2 -0
  16. package/dist/api/entity-read-access.js +46 -2
  17. package/dist/api/server-boot-checks.js +2 -2
  18. package/dist/api/server-routes.js +3 -1
  19. package/dist/config/index.d.ts +1 -1
  20. package/dist/db/derived-field-sql.d.ts +23 -0
  21. package/dist/db/derived-field-sql.js +115 -0
  22. package/dist/db/derived-related-sql.d.ts +31 -0
  23. package/dist/db/derived-related-sql.js +137 -0
  24. package/dist/db/driver-row-coercion.d.ts +1 -0
  25. package/dist/db/driver-row-coercion.js +13 -0
  26. package/dist/db/eagerload.d.ts +3 -1
  27. package/dist/db/eagerload.js +4 -6
  28. package/dist/db/event-store-executor-context.js +1 -1
  29. package/dist/db/event-store-executor-read-paging.js +1 -1
  30. package/dist/db/event-store-executor-read-search.d.ts +3 -0
  31. package/dist/db/event-store-executor-read-search.js +17 -9
  32. package/dist/db/event-store-executor-read-sort.d.ts +9 -2
  33. package/dist/db/event-store-executor-read-sort.js +27 -14
  34. package/dist/db/event-store-executor-read-where.d.ts +0 -5
  35. package/dist/db/event-store-executor-read-where.js +3 -9
  36. package/dist/db/event-store-executor-read.js +23 -4
  37. package/dist/db/event-store-executor-write-steps.d.ts +6 -1
  38. package/dist/db/event-store-executor-write-steps.js +20 -1
  39. package/dist/db/event-store-executor-write.js +8 -8
  40. package/dist/db/money.d.ts +4 -0
  41. package/dist/db/money.js +34 -29
  42. package/dist/db/parent-ref-clause.d.ts +1 -0
  43. package/dist/db/parent-ref-clause.js +18 -8
  44. package/dist/db/read-gate-clause.d.ts +7 -0
  45. package/dist/db/read-gate-clause.js +201 -0
  46. package/dist/db/read-ownership.d.ts +9 -0
  47. package/dist/db/read-ownership.js +12 -0
  48. package/dist/engine/boot-validator/boot-check.d.ts +3 -1
  49. package/dist/engine/boot-validator/boot-check.js +7 -11
  50. package/dist/engine/boot-validator/boot-context.d.ts +26 -0
  51. package/dist/engine/boot-validator/boot-context.js +57 -0
  52. package/dist/engine/boot-validator/boot-options.d.ts +38 -0
  53. package/dist/engine/boot-validator/boot-options.js +1 -0
  54. package/dist/engine/boot-validator/boot-phases-app.d.ts +5 -0
  55. package/dist/engine/boot-validator/boot-phases-app.js +107 -0
  56. package/dist/engine/boot-validator/boot-phases.d.ts +11 -0
  57. package/dist/engine/boot-validator/boot-phases.js +140 -0
  58. package/dist/engine/boot-validator/boot-report.d.ts +6 -0
  59. package/dist/engine/boot-validator/boot-report.js +47 -0
  60. package/dist/engine/boot-validator/boot-rule.d.ts +12 -0
  61. package/dist/engine/boot-validator/boot-rule.js +14 -0
  62. package/dist/engine/boot-validator/boot-warnings.d.ts +2 -0
  63. package/dist/engine/boot-validator/boot-warnings.js +20 -0
  64. package/dist/engine/boot-validator/derived-sql-fields.d.ts +4 -0
  65. package/dist/engine/boot-validator/derived-sql-fields.js +184 -0
  66. package/dist/engine/boot-validator/entity-handler.d.ts +3 -0
  67. package/dist/engine/boot-validator/entity-handler.js +6 -3
  68. package/dist/engine/boot-validator/entity-list-screens.js +3 -1
  69. package/dist/engine/boot-validator/field-conditions.d.ts +2 -0
  70. package/dist/engine/boot-validator/field-conditions.js +196 -0
  71. package/dist/engine/boot-validator/i18n-keys.d.ts +3 -2
  72. package/dist/engine/boot-validator/i18n-keys.js +18 -4
  73. package/dist/engine/boot-validator/index.d.ts +5 -34
  74. package/dist/engine/boot-validator/index.js +24 -204
  75. package/dist/engine/boot-validator/pii-retention.js +15 -9
  76. package/dist/engine/boot-validator/projection-list-screens.js +19 -1
  77. package/dist/engine/boot-validator/read-gate.d.ts +2 -0
  78. package/dist/engine/boot-validator/read-gate.js +59 -0
  79. package/dist/engine/boot-validator/screens-forms.js +19 -6
  80. package/dist/engine/boot-validator/screens-kind-entity-list.js +8 -6
  81. package/dist/engine/boot-validator/screens-lists.js +8 -0
  82. package/dist/engine/boot-validator/zod-shape.js +4 -2
  83. package/dist/engine/build-app-schema.js +11 -5
  84. package/dist/engine/embedded-derived.d.ts +8 -6
  85. package/dist/engine/embedded-derived.js +25 -7
  86. package/dist/engine/entity-handlers.d.ts +13 -6
  87. package/dist/engine/entity-handlers.js +17 -18
  88. package/dist/engine/entity-list-runtime-options.d.ts +2 -1
  89. package/dist/engine/entity-list-runtime-options.js +86 -5
  90. package/dist/engine/factories.d.ts +18 -6
  91. package/dist/engine/factories.js +21 -5
  92. package/dist/engine/field-access.d.ts +2 -0
  93. package/dist/engine/field-access.js +48 -1
  94. package/dist/engine/field-state.d.ts +12 -0
  95. package/dist/engine/field-state.js +67 -0
  96. package/dist/engine/handler-helpers.d.ts +3 -0
  97. package/dist/engine/handler-helpers.js +7 -1
  98. package/dist/engine/index.d.ts +3 -3
  99. package/dist/engine/index.js +3 -3
  100. package/dist/engine/ownership.d.ts +1 -1
  101. package/dist/engine/ownership.js +9 -5
  102. package/dist/engine/registry-state.d.ts +1 -0
  103. package/dist/engine/registry-state.js +1 -1
  104. package/dist/engine/schema-builder.js +3 -0
  105. package/dist/engine/strip-unreadable-related-lists.d.ts +4 -0
  106. package/dist/engine/strip-unreadable-related-lists.js +80 -0
  107. package/dist/engine/user-entity-reference.d.ts +2 -0
  108. package/dist/engine/user-entity-reference.js +7 -0
  109. package/dist/files/assert-file-refs-belong-to-tenant.js +26 -6
  110. package/dist/files/bind-pending-file-refs.d.ts +3 -2
  111. package/dist/files/bind-pending-file-refs.js +11 -12
  112. package/dist/files/bind-referenced-files.write.js +5 -5
  113. package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
  114. package/dist/files/create-file-ref-from-bytes.js +2 -0
  115. package/dist/files/create-file-ref.d.ts +2 -0
  116. package/dist/files/create-file-ref.js +39 -22
  117. package/dist/files/dispose-removed-file-refs.js +5 -8
  118. package/dist/files/file-field-slots.d.ts +10 -0
  119. package/dist/files/file-field-slots.js +53 -0
  120. package/dist/files/file-ref-entity.d.ts +1 -1
  121. package/dist/files/file-ref-table.d.ts +1 -1
  122. package/dist/files/file-routes.js +12 -3
  123. package/dist/files/index.d.ts +3 -0
  124. package/dist/files/index.js +2 -0
  125. package/dist/files/load-save-file-refs.js +3 -4
  126. package/dist/files/rebind-file-ref.d.ts +41 -0
  127. package/dist/files/rebind-file-ref.js +170 -0
  128. package/dist/files/record-read-check.js +2 -30
  129. package/dist/files/record-write-check.d.ts +2 -0
  130. package/dist/files/record-write-check.js +18 -3
  131. package/dist/files/upload-field-resolution.d.ts +2 -1
  132. package/dist/files/upload-field-resolution.js +33 -13
  133. package/dist/i18n/required-surface-keys.d.ts +6 -0
  134. package/dist/i18n/required-surface-keys.js +61 -0
  135. package/dist/pipeline/dispatch-batch.js +6 -5
  136. package/dist/pipeline/dispatch-write.js +2 -1
  137. package/dist/pipeline/write-result-masking.js +2 -1
  138. package/dist/schema-cli/validate-boot-input.js +2 -0
  139. package/dist/schema-cli/validate-command.js +46 -12
  140. package/dist/schema-cli.js +1 -1
  141. package/dist/ui-types/index.d.ts +2 -1
  142. package/dist/ui-types/index.js +2 -1
  143. package/package.json +3 -3
  144. package/src/changes.json +68 -0
@@ -1,35 +1,7 @@
1
- import { toKebab } from "../engine/qualified-name.js";
1
+ import { collectEntityDetailQueries } from "../api/entity-read-access.js";
2
2
  import { isKumikoError } from "../errors/index.js";
3
3
  import { createFallbackLogger } from "../logging/index.js";
4
4
  const log = createFallbackLogger("files");
5
- // Only the "<entity>:detail" handler counts: other "<entity>:...:detail"
6
- // queries map to the same entity but may return a different shape or scope.
7
- // Entity names are globally unique and only the owning feature maps handlers
8
- // to its entity, so at most one QN matches per entity.
9
- function isEntityDetailQuery(qn, entityName) {
10
- return qn.endsWith(`:query:${toKebab(entityName)}:detail`);
11
- }
12
- function buildDetailQueryIndex(registry) {
13
- const detailQueryByEntity = new Map();
14
- for (const qn of registry.getAllQueryHandlers().keys()) {
15
- const entityName = registry.getHandlerEntity(qn);
16
- if (entityName !== undefined && isEntityDetailQuery(qn, entityName)) {
17
- detailQueryByEntity.set(entityName, qn);
18
- }
19
- }
20
- for (const projection of registry.getAllProjections().values()) {
21
- const entityName = projection.source;
22
- if (typeof entityName !== "string")
23
- continue;
24
- const owner = registry.getReadModelEntityOwner(entityName);
25
- if (owner === undefined)
26
- continue;
27
- const qn = `${owner}:query:${toKebab(entityName)}:detail`;
28
- if (registry.getAllQueryHandlers().has(qn))
29
- detailQueryByEntity.set(entityName, qn);
30
- }
31
- return detailQueryByEntity;
32
- }
33
5
  // "May this user read the record?" is answered by the entity's own detail
34
6
  // query run as that user: handler access, row ownership and parent visibility
35
7
  // all apply, exactly as when the record is opened. null (not found or not
@@ -38,7 +10,7 @@ function buildDetailQueryIndex(registry) {
38
10
  export function createRecordReadCheck(registry, query) {
39
11
  let detailQueryByEntity;
40
12
  return async ({ entityType, entityId, user }) => {
41
- detailQueryByEntity ??= buildDetailQueryIndex(registry);
13
+ detailQueryByEntity ??= collectEntityDetailQueries(registry);
42
14
  const detailQuery = detailQueryByEntity.get(entityType);
43
15
  if (detailQuery === undefined)
44
16
  return false;
@@ -12,6 +12,8 @@ export type RecordWriteCheck = (args: {
12
12
  readonly entityType: string;
13
13
  readonly entityId: string;
14
14
  readonly fieldName: string | undefined;
15
+ /** Embedded file sub-field of `fieldName` the upload is meant for. */
16
+ readonly subField?: string | undefined;
15
17
  readonly user: SessionUser;
16
18
  }) => Promise<RecordWriteDecision>;
17
19
  export declare function findImplicitEntityTable(registry: Registry, entityType: string): import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable | undefined;
@@ -1,7 +1,9 @@
1
1
  import { hasAccess } from "../engine/access.js";
2
+ import { userHasWriteRole } from "../engine/field-access.js";
2
3
  import { normalizeAccessEntry, userCanWriteFieldRow } from "../engine/ownership.js";
3
4
  import { toKebab } from "../engine/qualified-name.js";
4
5
  import { isRecordLocked } from "../engine/screen-helpers.js";
6
+ import { resolveFileSubField } from "./file-field-slots.js";
5
7
  export const RecordWriteDecisions = {
6
8
  writable: "writable",
7
9
  notFound: "not_found",
@@ -19,6 +21,20 @@ export function findImplicitEntityTable(registry, entityType) {
19
21
  function isEntityUpdateWrite(qn, entityName) {
20
22
  return qn.endsWith(`:write:${toKebab(entityName)}:update`);
21
23
  }
24
+ // The named field's own write rules against the stored row. The upload names no embedded row,
25
+ // so a sub-field only gets the role gate; its row-level rules run when the record is saved.
26
+ function mayWriteNamedField(args) {
27
+ const { entity, fieldName, subField, user, row } = args;
28
+ if (fieldName === undefined)
29
+ return true;
30
+ const fieldRules = normalizeAccessEntry(entity.fields[fieldName]?.access?.write);
31
+ if (!userCanWriteFieldRow(user, fieldRules, row, row))
32
+ return false;
33
+ const subFieldRules = subField === undefined
34
+ ? undefined
35
+ : normalizeAccessEntry(resolveFileSubField(entity.fields[fieldName], subField)?.access?.write);
36
+ return subFieldRules === undefined || userHasWriteRole(user, subFieldRules);
37
+ }
22
38
  // "May this user attach a file to the record?" asks what saving the record would ask: the
23
39
  // entity's update handler, the row-level and field-level write rules against the stored row,
24
40
  // and the lock. Not-readable, foreign-tenant and missing records answer alike (not_found) so
@@ -33,7 +49,7 @@ export function createRecordWriteCheck(args) {
33
49
  }
34
50
  return undefined;
35
51
  }
36
- return async ({ entityType, entityId, fieldName, user }) => {
52
+ return async ({ entityType, entityId, fieldName, subField, user }) => {
37
53
  if (!(await canReadRecord({ entityType, entityId, user }))) {
38
54
  return RecordWriteDecisions.notFound;
39
55
  }
@@ -53,8 +69,7 @@ export function createRecordWriteCheck(args) {
53
69
  if (!userCanWriteFieldRow(user, entity.access?.write, row, row)) {
54
70
  return RecordWriteDecisions.forbidden;
55
71
  }
56
- const fieldWriteRules = fieldName === undefined ? undefined : entity.fields[fieldName]?.access?.write;
57
- if (!userCanWriteFieldRow(user, normalizeAccessEntry(fieldWriteRules), row, row)) {
72
+ if (!mayWriteNamedField({ entity, fieldName, subField, user, row })) {
58
73
  return RecordWriteDecisions.forbidden;
59
74
  }
60
75
  if (isRecordLocked(entity.lockedWhen, row))
@@ -4,6 +4,7 @@ type UploadTarget = {
4
4
  readonly entityType?: string | undefined;
5
5
  readonly entityId?: string | undefined;
6
6
  readonly fieldName?: string | undefined;
7
+ readonly subField?: string | undefined;
7
8
  readonly actionForm?: string | undefined;
8
9
  };
9
10
  export type AttachTargets = "entities" | "entities-and-read-models";
@@ -15,7 +16,7 @@ type AttachedFieldResolution = {
15
16
  } | {
16
17
  readonly kind: "unresolvable";
17
18
  };
18
- export declare function resolveAttachedField(registry: Registry | undefined, entityType: string | undefined, fieldName: string | undefined, attachTargets?: AttachTargets): AttachedFieldResolution;
19
+ export declare function resolveAttachedField(registry: Registry | undefined, entityType: string | undefined, fieldName: string | undefined, attachTargets?: AttachTargets, subField?: string): AttachedFieldResolution;
19
20
  type UploadFieldResolution = AttachedFieldResolution | {
20
21
  readonly kind: "rejected";
21
22
  readonly error: string;
@@ -1,30 +1,48 @@
1
1
  import { isFileField } from "../engine/field-helpers.js";
2
2
  import { isActionFormScreenQn, resolveActionFormFileField } from "./action-form-upload.js";
3
+ import { resolveFileSubField } from "./file-field-slots.js";
4
+ function findAttachEntity(registry, entityType, attachTargets) {
5
+ return (registry?.getEntity(entityType) ??
6
+ (attachTargets === "entities-and-read-models"
7
+ ? registry?.getReadModelEntity(entityType)
8
+ : undefined));
9
+ }
3
10
  // entityType/fieldName drive the GDPR-forget decision downstream
4
11
  // (fileRefDeleteHook resolves the field's PII annotation from exactly this
5
12
  // pairing) — attaching to a field nobody can resolve would leave that
6
- // decision hanging on an unverified client string. Either both are omitted
13
+ // decision hanging on an unverified client string. `subField` names a file sub-field of an
14
+ // embedded `fieldName`; the binding stays on the top-level field. Either both are omitted
7
15
  // (unattached upload, allowed) or both must resolve to a real registered
8
16
  // field. "entities-and-read-models" is for server-side callers only: a client upload must
9
17
  // never name a projection entity, whose rows nobody can write-check.
10
- export function resolveAttachedField(registry, entityType, fieldName, attachTargets = "entities") {
18
+ export function resolveAttachedField(registry, entityType, fieldName, attachTargets = "entities", subField) {
11
19
  if (entityType === undefined && fieldName === undefined)
12
20
  return { kind: "unattached" };
13
- const entity = entityType !== undefined
14
- ? (registry?.getEntity(entityType) ??
15
- (attachTargets === "entities-and-read-models"
16
- ? registry?.getReadModelEntity(entityType)
17
- : undefined))
18
- : undefined;
19
- const fieldDef = entity && fieldName !== undefined ? entity.fields[fieldName] : undefined;
20
- if (!entityType || !fieldName || !entity || !fieldDef || !isFileField(fieldDef))
21
+ if (!entityType || !fieldName)
22
+ return { kind: "unresolvable" };
23
+ const fieldDef = findAttachEntity(registry, entityType, attachTargets)?.fields[fieldName];
24
+ if (!fieldDef)
25
+ return { kind: "unresolvable" };
26
+ if (subField !== undefined) {
27
+ const subFieldDef = resolveFileSubField(fieldDef, subField);
28
+ return subFieldDef === undefined
29
+ ? { kind: "unresolvable" }
30
+ : { kind: "resolved", fieldDef: subFieldDef };
31
+ }
32
+ if (!isFileField(fieldDef))
21
33
  return { kind: "unresolvable" };
22
34
  return { kind: "resolved", fieldDef };
23
35
  }
24
36
  export function resolveUploadField(registry, input, attachTargets) {
25
- const { entityType, entityId, fieldName, actionForm } = input;
37
+ const { entityType, entityId, fieldName, subField, actionForm } = input;
26
38
  if (actionForm === undefined) {
27
- return resolveAttachedField(registry, entityType, fieldName, attachTargets);
39
+ return resolveAttachedField(registry, entityType, fieldName, attachTargets, subField);
40
+ }
41
+ if (subField !== undefined) {
42
+ return {
43
+ kind: "rejected",
44
+ error: "invalid_action_form: actionForm cannot be combined with subField",
45
+ };
28
46
  }
29
47
  if (entityType !== undefined || entityId !== undefined) {
30
48
  return {
@@ -72,5 +90,7 @@ export function isAnonymousPendingUploadAllowed(registry, target) {
72
90
  if (target.entityId !== undefined || target.actionForm !== undefined)
73
91
  return false;
74
92
  const resolution = resolveAttachedField(registry, target.entityType, target.fieldName);
75
- return resolution.kind === "resolved" && resolution.fieldDef.anonymousUpload === true;
93
+ return (resolution.kind === "resolved" &&
94
+ isFileField(resolution.fieldDef) &&
95
+ resolution.fieldDef.anonymousUpload === true);
76
96
  }
@@ -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[];
@@ -596,3 +596,64 @@ export function findRequiredLocaleGaps(features, requiredLocales) {
596
596
  }
597
597
  return gaps;
598
598
  }
599
+ export function embeddedCellLabelKey(featureName, entityName, fieldName, subFieldName) {
600
+ return `${fieldLabelKey(featureName, entityName, fieldName)}:cell:${subFieldName}`;
601
+ }
602
+ export function embeddedCellOptionLabelKey(featureName, entityName, fieldName, subFieldName, value) {
603
+ return `${embeddedCellLabelKey(featureName, entityName, fieldName, subFieldName)}:option:${value}`;
604
+ }
605
+ function pushFieldValueTranslationKeys(out, featureName, entityName, fieldName, def, includeEmbeddedCells) {
606
+ if (def.type === "select" || def.type === "multiSelect") {
607
+ for (const value of def.options) {
608
+ out.add(selectFacetOptionKey(featureName, entityName, fieldName, value));
609
+ }
610
+ // skip: option keys are complete, nothing else to collect for select fields
611
+ return;
612
+ }
613
+ // skip: only multi-row embedded fields render cell labels
614
+ if (!includeEmbeddedCells || def.type !== "embedded" || def.multiple !== true)
615
+ return;
616
+ for (const [subName, sub] of Object.entries(def.schema)) {
617
+ out.add(embeddedCellLabelKey(featureName, entityName, fieldName, subName));
618
+ if (sub.type !== "select")
619
+ continue;
620
+ for (const value of sub.options) {
621
+ out.add(embeddedCellOptionLabelKey(featureName, entityName, fieldName, subName, value));
622
+ }
623
+ }
624
+ }
625
+ /** Keys for select option labels and embedded-list cells that the screens of one feature
626
+ * display. Opt-in via `requireFieldValueTranslations`: without them the renderer falls back
627
+ * to the raw option value or sub-field name. */
628
+ export function fieldValueTranslationKeysFromFeature(feature) {
629
+ const out = new Set();
630
+ for (const screen of Object.values(feature.screens)) {
631
+ if (screen.type === "entityList") {
632
+ const entity = feature.entities?.[screen.entity];
633
+ if (!entity)
634
+ continue;
635
+ for (const col of screen.columns) {
636
+ const { field } = normalizeListColumn(col);
637
+ const def = entity.fields[field];
638
+ if (def)
639
+ pushFieldValueTranslationKeys(out, feature.name, screen.entity, field, def, false);
640
+ }
641
+ }
642
+ else if (screen.type === "entityEdit") {
643
+ const entity = feature.entities?.[screen.entity];
644
+ if (!entity)
645
+ continue;
646
+ for (const section of screen.layout.sections) {
647
+ if (isExtensionEditSection(section) || section.kind === "relatedList")
648
+ continue;
649
+ for (const spec of sectionFieldSpecs(section)) {
650
+ const field = editFieldName(spec);
651
+ const def = entity.fields[field];
652
+ if (def)
653
+ pushFieldValueTranslationKeys(out, feature.name, screen.entity, field, def, true);
654
+ }
655
+ }
656
+ }
657
+ }
658
+ return [...out];
659
+ }
@@ -176,12 +176,13 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
176
176
  for (const r of results) {
177
177
  if (!r.isSuccess)
178
178
  continue;
179
- if (!isLifecycleResult(r.data))
179
+ const lifecycleResult = r.lifecycle ?? r.data;
180
+ if (!isLifecycleResult(lifecycleResult))
180
181
  continue;
181
- if (r.data.kind === "save")
182
- saves.push(r.data);
183
- else if (r.data.kind === "delete")
184
- deletes.push(r.data);
182
+ if (lifecycleResult.kind === "save")
183
+ saves.push(lifecycleResult);
184
+ else if (lifecycleResult.kind === "delete")
185
+ deletes.push(lifecycleResult);
185
186
  }
186
187
  // skip: nothing to flush when there are no lifecycle hooks or no successful save/delete
187
188
  if (!lifecycle || (saves.length === 0 && deletes.length === 0))
@@ -508,7 +508,7 @@ async function invokeWriteHandler(handler, type, gated, user, handlerContext) {
508
508
  async function runWriteLifecycle(ctx, args) {
509
509
  try {
510
510
  const runner = resolveDbSource(ctx, args.tx);
511
- await runLifecycle(ctx, args.type, args.result.data, args.handlerContext, args.user, args.origin, args.afterCommitHooks, runner);
511
+ await runLifecycle(ctx, args.type, args.result.lifecycle ?? args.result.data, args.handlerContext, args.user, args.origin, args.afterCommitHooks, runner);
512
512
  }
513
513
  catch (e) {
514
514
  return writeFailure(wrapToKumiko(e));
@@ -584,5 +584,6 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
584
584
  // data / previous / changes). Feature code that fed a plaintext through
585
585
  // to the return payload fails here instead of hitting the client.
586
586
  assertNoSecretLeak(result.data);
587
+ assertNoSecretLeak(result.lifecycle);
587
588
  return result;
588
589
  }
@@ -25,10 +25,11 @@ function maskLifecycleOrRow(entity, data) {
25
25
  export function maskWriteResultForClient(registry, handlerType, result) {
26
26
  if (!result.isSuccess)
27
27
  return result;
28
+ const { lifecycle, ...clientResult } = result;
28
29
  const entityName = registry.getHandlerEntity(handlerType);
29
30
  const entity = entityName ? registry.getEntity(entityName) : undefined;
30
31
  const data = maskLifecycleOrRow(entity, result.data);
31
- return data === result.data ? result : { ...result, data };
32
+ return data === result.data && lifecycle === undefined ? result : { ...clientResult, data };
32
33
  }
33
34
  export function maskBatchResultForClient(registry, commands, batch) {
34
35
  const results = batch.results.map((result, index) => {
@@ -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,6 +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
8
  import { validateBoot } from "../engine/boot-validator.js";
8
9
  import { describeThrown, loadPreviousSnapshot, loadSchemaModOrReport, nextSequenceNumber, SNAPSHOT_FILENAME, } from "./shared.js";
9
10
  import { featureDefinitionsOf, parseValidateBootOptions, withFileStoragePresence, } from "./validate-boot-input.js";
@@ -35,6 +36,16 @@ function checkSchemaDrift(ctx, schemaMod, prevSnapshot) {
35
36
  out.err(" Fix: `kumiko-schema generate <name>`, then commit the migration.");
36
37
  return false;
37
38
  }
39
+ function unclassifiedViolation(check, e) {
40
+ return { ruleId: `unclassified/${check}`, check, message: describeThrown(e) };
41
+ }
42
+ function reportBootViolations(out, violations) {
43
+ for (const v of violations) {
44
+ out.err(` ✗ boot [${v.ruleId}] ${v.message}`);
45
+ if (v.fix !== undefined)
46
+ out.err(` Fix: ${v.fix}`);
47
+ }
48
+ }
38
49
  // 2. Boot validity — needs the composed feature set.
39
50
  function checkBootValidity(out, schemaMod) {
40
51
  let bootFeatures;
@@ -43,11 +54,11 @@ function checkBootValidity(out, schemaMod) {
43
54
  }
44
55
  catch (e) {
45
56
  out.err(` ✗ boot: ${describeThrown(e)}`);
46
- return false;
57
+ return { ok: false, violations: [unclassifiedViolation("featureDefinitionsOf", e)] };
47
58
  }
48
59
  if (bootFeatures === undefined) {
49
60
  out.log(" · boot: skipped — add `export const FEATURES = composeFeatures(APP_FEATURES, { includeBundled: true })` to kumiko/schema.ts to enable validateBoot.");
50
- return true;
61
+ return { ok: true, skipped: true, violations: [] };
51
62
  }
52
63
  try {
53
64
  validateBoot(bootFeatures, {
@@ -55,11 +66,15 @@ function checkBootValidity(out, schemaMod) {
55
66
  env: withFileStoragePresence(process.env),
56
67
  });
57
68
  out.log(" ✓ boot: feature configuration is valid");
58
- return true;
69
+ return { ok: true, violations: [] };
59
70
  }
60
71
  catch (e) {
72
+ if (e instanceof BootValidationError) {
73
+ reportBootViolations(out, e.violations);
74
+ return { ok: false, violations: e.violations };
75
+ }
61
76
  out.err(` ✗ boot: ${describeThrown(e)}`);
62
- return false;
77
+ return { ok: false, violations: [unclassifiedViolation("validateBoot", e)] };
63
78
  }
64
79
  }
65
80
  function declaredTablesOf(schemaMod) {
@@ -150,7 +165,8 @@ function checkMigrationContentDrift(ctx, prevSnapshot) {
150
165
  // added/changed but never generated → missing table → prod 500)
151
166
  // 2. boot validity: validateBoot over the composed FEATURES (QN/screen/
152
167
  // nav/role refs). Runs only if kumiko/schema.ts exports FEATURES;
153
- // honours its optional VALIDATE_BOOT_OPTIONS export.
168
+ // honours its optional VALIDATE_BOOT_OPTIONS export. `--json` prints
169
+ // this result as one JSON document on stdout.
154
170
  // 3. migration-content drift: replay every committed *.sql file and
155
171
  // diff the result against .snapshot.json — catches a migration
156
172
  // file whose SQL body doesn't match what its own snapshot entry
@@ -161,14 +177,32 @@ function checkMigrationContentDrift(ctx, prevSnapshot) {
161
177
  // 4. live row-level security (only with DATABASE_URL): the boot's
162
178
  // table-state check against the real database (not the role: that is a boot-time property).
163
179
  // The DB-level gate (assertKumikoSchemaCurrent) stays at boot/deploy.
180
+ function printBootJson(ctx, boot) {
181
+ const { ok, skipped, violations } = boot;
182
+ ctx.out.log(JSON.stringify({ boot: { ok, ...(skipped ? { skipped } : {}), violations } }));
183
+ }
164
184
  export async function runValidateCommand(ctx) {
165
- const schemaMod = await loadSchemaModOrReport(ctx);
166
- if (schemaMod === undefined)
185
+ const jsonMode = ctx.argv.includes("--json");
186
+ // stdout carries exactly one JSON document in --json mode.
187
+ const out = jsonMode ? { log: ctx.out.err, err: ctx.out.err } : ctx.out;
188
+ const humanCtx = { ...ctx, out };
189
+ const schemaMod = await loadSchemaModOrReport(humanCtx);
190
+ if (schemaMod === undefined) {
191
+ if (jsonMode) {
192
+ const message = "kumiko/schema.ts could not be loaded";
193
+ printBootJson(ctx, {
194
+ ok: false,
195
+ violations: [{ ruleId: "unclassified/loadSchemaMod", check: "loadSchemaMod", message }],
196
+ });
197
+ }
167
198
  return 1;
199
+ }
168
200
  const prevSnapshot = loadPreviousSnapshot(join(ctx.migrationsDir, SNAPSHOT_FILENAME));
169
- const schemaOk = checkSchemaDrift(ctx, schemaMod, prevSnapshot);
170
- const bootOk = checkBootValidity(ctx.out, schemaMod);
171
- const migrationsOk = checkMigrationContentDrift(ctx, prevSnapshot);
172
- const rowLevelSecurityOk = await checkLiveRowLevelSecurity(ctx.out, schemaMod);
173
- return schemaOk && bootOk && migrationsOk && rowLevelSecurityOk ? 0 : 1;
201
+ const schemaOk = checkSchemaDrift(humanCtx, schemaMod, prevSnapshot);
202
+ const boot = checkBootValidity(out, schemaMod);
203
+ const migrationsOk = checkMigrationContentDrift(humanCtx, prevSnapshot);
204
+ const rowLevelSecurityOk = await checkLiveRowLevelSecurity(out, schemaMod);
205
+ if (jsonMode)
206
+ printBootJson(ctx, boot);
207
+ return schemaOk && boot.ok && migrationsOk && rowLevelSecurityOk ? 0 : 1;
174
208
  }
@@ -28,7 +28,7 @@ function printSchemaCliUsage(out) {
28
28
  out.log("");
29
29
  out.log(" Subcommands:");
30
30
  out.log(" generate <name> Write a new migration from the EntityTableMeta diff");
31
- out.log(" validate Static CI gate (no DB): schema drift + validateBoot");
31
+ out.log(" validate [--json] Static CI gate (no DB): schema drift + validateBoot");
32
32
  out.log(" apply Apply pending checked-in SQL files");
33
33
  out.log(" baseline Mark checked-in migrations as applied (no SQL run)");
34
34
  out.log(" status List applied vs pending");
@@ -1,6 +1,7 @@
1
1
  export { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
2
2
  export type { DerivedCellRoundingTarget } from "../engine/embedded-derived.ts";
3
- export { computeDerivedCellValue, roundDerivedCellValue } from "../engine/embedded-derived.ts";
3
+ export { computeDerivedCellValue, roundDerivedCellValue, withDerivedCells, } from "../engine/embedded-derived.ts";
4
+ export { findMissingConditionalRequirements, isFieldRequired, isFieldVisible, isMissingRequiredValue, } from "../engine/field-state.ts";
4
5
  export type { FormDerivationScreen } from "../engine/form-derivations.ts";
5
6
  export { applyFormDerivations, isFormDerivationInputMode, isUnusedFallbackOperand, } from "../engine/form-derivations.ts";
6
7
  export type { ParsedRefTarget } from "../engine/parse-ref-target.ts";
@@ -20,7 +20,8 @@
20
20
  // When adding a symbol here, verify it's either a type or a pure
21
21
  // helper with no cross-module side-effects.
22
22
  export { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
23
- export { computeDerivedCellValue, roundDerivedCellValue } from "../engine/embedded-derived.js";
23
+ export { computeDerivedCellValue, roundDerivedCellValue, withDerivedCells, } from "../engine/embedded-derived.js";
24
+ export { findMissingConditionalRequirements, isFieldRequired, isFieldVisible, isMissingRequiredValue, } from "../engine/field-state.js";
24
25
  export { applyFormDerivations, isFormDerivationInputMode, isUnusedFallbackOperand, } from "../engine/form-derivations.js";
25
26
  export { parseRefTarget } from "../engine/parse-ref-target.js";
26
27
  export { ACTION_FORM_PSEUDO_ENTITY, availableSelectOptions, evalFieldCondition, findUnavailableSelectOptions, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isRecordLocked, isUnchangedValue, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../engine/screen-helpers.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.399.0",
3
+ "version": "0.401.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.399.0",
206
+ "@cosmicdrift/kumiko-types": "0.401.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.399.0",
228
+ "@cosmicdrift/kumiko-testing": "0.401.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,72 @@
1
1
  [
2
+ {
3
+ "version": "0.401.0",
4
+ "type": "improvement",
5
+ "title": "rebindFileRef, readGate allLinks with documents linkReadMode, documents tab entity fix",
6
+ "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."
7
+ },
8
+ {
9
+ "version": "0.401.0",
10
+ "type": "breaking",
11
+ "title": "Boot rejects a user reference with personal: false and file accept entries that never match",
12
+ "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.",
13
+ "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\"`)."
14
+ },
15
+ {
16
+ "version": "0.400.0",
17
+ "type": "improvement",
18
+ "title": "Boot validation reports all violations at once",
19
+ "detail": "validateBoot runs its checks in phases and collects the violations of each phase into one BootValidationError with a `violations` list (ruleId, check, feature, message). Structural violations (duplicate API names, screen short-id collisions, unresolved query references, circular dependencies, extension usages, API exposure, reference fields, parent refs) abort before the per-feature and app-wide checks, which would only echo them. The error message starts with `Boot validation failed: N violation(s)` followed by one line per violation, each keeping its original text. A feature whose action or field wiring is invalid skips its screen check. `kumiko-schema validate` prints one ✗ line per violation and takes `--json`, which prints a single JSON document on stdout and moves the human lines to stderr."
20
+ },
21
+ {
22
+ "version": "0.400.0",
23
+ "type": "improvement",
24
+ "title": "Slimmer checklist cards and locale numbers in embedded lists",
25
+ "detail": "On narrow screens an embedded list with an `inRange` derived cell no longer shows the min and max sub-fields as their own rows. The card shows them as a hint under the measured value (\"Limit 0.05 to 0.2\", \"At least …\", \"At most …\"; new keys `kumiko.field.embedded-list.limit-range`, `limit-min`, `limit-max`). The result badge sits next to the measured value without a label. The new `embeddedList.cardHiddenCells` on an entityEdit field hides sub-fields that only steer a row (such as the kind of a checkpoint) on phone cards; boot rejects names that are not sub-fields. The desktop table is unchanged. Number and decimal cells now use the locale text input (`NumberInput`) instead of a native `type=\"number\"`, so input and read-only cells share one locale format (\"0,31\" in de, \"0.31\" in en). Tests that located these cells by role `spinbutton` must use `textbox`."
26
+ },
27
+ {
28
+ "version": "0.400.0",
29
+ "type": "improvement",
30
+ "title": "SQL derived fields, query-gated relatedList tabs, count badge",
31
+ "detail": "A derived field with `sql` (coalesce over fields, or a related-table count, countDistinct or first with optional label and conditions incl. activeOn/today/now) is computed inside the list SELECT and may set `sortable: true`, also as entityList defaultSort; JS `derive` fields stay display-only and read the SQL values. Related rows and measured fields respect the viewer's read access, a gated value is NULL. GET /api/schema removes relatedList sections (tabs, entityEdit sections, expandableRow) whose query the caller's roles cannot run, and metrics linking to a removed tab lose their navigate. `count: true` on a relatedList tab shows the tab badge from the section's own query total. A metric only becomes clickable when its entityId target resolves, and groupBy labels now also resolve for entities with a backingTable. `valueType: \"money\"` is allowed for a SQL derived `related` + `first` on a money field: amount and currency are read together and the row carries the stored money shape (no key when unset), sorting is by minor units. Ownership where-rules of a label target or related table are qualified with the subquery's own alias (a label of a row the viewer cannot read no longer leaks when the target is the outer entity), the related entity and label target must also be readable through a query handler of the viewer's roles, backingTable entities are read from their real table (also for reference labels, reference search and label sort, which returned a 500 before), an entity may derive from itself, and a sortable related derived field needs an index on the join column."
32
+ },
33
+ {
34
+ "version": "0.400.0",
35
+ "type": "improvement",
36
+ "title": "Entity readGate and the documents feature",
37
+ "detail": "`createEntity({ readGate: { anyLink, fileOwner, linkExemptRoles } })` hides a row unless the viewer can read at least one linked host (join rows with a parentRef) and the owner of the referenced file; it is applied in list, detail, eager-load and relation reads, fail-closed without the registry, and the framework system user passes. Hand-written list/detail handlers for a gated entity are rejected at boot, live signals for gated entities stay anonymous. `ParentVisibilityOption` gains `canReadEntity` and `resolveTable`. The new `documents` feature (`createDocumentsFeature({ categories, linkTargets, capture?, retention? })`) files documents with category, issue and expiry date, links them to records (link, unlink, for-record and per-type queries, expiry overview), lists them on the `documents:screen:list` projection list through `documents:query:overview` (an `expiry` state per row and an \"expiring\" filter that returns expired and soon-expiring documents), captures files saved into configured entity fields via a postSave hook, and offers an idempotent SYSTEM-only import for jobs. Updating or deleting a document needs write access to one of its linked hosts (admin roles and the system user are exempt). Reference search and sort on a readGate target now apply the gate, and `parentVisibilityFor` decides `canReadEntity` by the entity's own detail query, the same rule the file route uses, so a `liveEntities` query no longer widens what a file owner shows. A document cannot claim another user's unbound upload. A document never defaults its title to the file name."
38
+ },
39
+ {
40
+ "version": "0.400.0",
41
+ "type": "fix",
42
+ "title": "Hooks run for handlers that return response data; readOnly related lists",
43
+ "detail": "A write handler wrapped by `withResponseData` (for example `tenant:write:remove-member`) lost its lifecycle result, so postSave/postDelete hooks and projections were skipped. `WriteResult` now carries an optional `lifecycle` that the dispatcher and batch flush use and that is stripped from client responses. `EditRelatedListSection` gains `readOnly: true` for plain tables without an entity; the boot validator rejects it together with rowClick/rowActions and guard R2 skips it. The bin test helpers and `envWithoutGitOverrides` now also strip GIT_COMMON_DIR and the other git location variables."
44
+ },
45
+ {
46
+ "version": "0.400.0",
47
+ "type": "breaking",
48
+ "title": "An untouched money field is no longer submitted as 0",
49
+ "detail": "Forms seed a money field with its currency only and drop that currency-only value on submit, so a handler can tell a deliberately typed 0 from an untouched field. A currency-only value counts as missing for required checks, and the input shows empty instead of 0.00; config forms keep their plain-number contract.",
50
+ "migration": "A form that left a required money field untouched used to submit 0 and now fails validation; type the amount (also in e2e and DOM tests) or give the field a value through `load` or the record. Handlers that read an absent optional money field as 0 must default it themselves."
51
+ },
52
+ {
53
+ "version": "0.400.0",
54
+ "type": "improvement",
55
+ "title": "documents entities are filed-document and filed-document-link; safe import; split access",
56
+ "detail": "The documents feature registers its entities as `filed-document` (table `read_filed_documents`) and `filed-document-link` (table `read_filed_document_links`), so an app that has its own `document` entity keeps its events and tables; handler and query names follow (`documents:write:filed-document:create`, `documents:query:filed-document:list`, `documents:query:filed-document-link:list`), and so do the field label keys `documents:entity:filed-document:field:*`. `documents:write:import` now fails with a conflict (`documents.errors.import-id-taken`) and links nothing when the id is already used by a stream that is not a document; a repeat import of the same document, deleted or not, still changes nothing; the check reads the stream, so a row-level `readAccess` cannot turn a retry into a refusal. With `readAccess` set, the system user keeps reading every document, so capture and import still find the ones the rule hides. An imported document's createdAt is the time of the import, because the event store does not backdate events; issuedAt carries the business date. `createDocumentsFeature({ access })` takes either one rule for everything, as before, or `{ read, write, delete }` (type `DocumentsAccess`): read covers the lists, detail, for-record, the overviews and the list screen, write covers create, update, link and unlink; a missing read or write keeps the TenantAdmin and TenantMember default and a missing delete follows write. The readGate on the document is unchanged."
57
+ },
58
+ {
59
+ "version": "0.400.0",
60
+ "type": "improvement",
61
+ "title": "Embedded list as inspection checklist",
62
+ "detail": "Every entity field and every embedded sub-field takes `visibleWhen` and `requiredWhen` (FieldCondition). The executor enforces `requiredWhen` on create and update; it returns `validation_error` with paths like `checkpoints.0.finding`, and on update it only checks fields whose value or condition field changed. A field is required only while it is visible, and hidden values are kept. Embedded sub-fields can be `images` (with `capture`) or `files` (`maxSize`, `accept`, `maxCount`). Uploads send `subField` next to `fieldName`, and `file_refs.fieldName` stays the embedded field, so binding, tenant check, disposal, read access, GDPR forget and retention work as for top-level file fields. The new derived op `inRange` (`from: [value, min, max]`, `inside`, `outside`) writes into a select sub-field. Any derived cell can carry a row-level `when`, and server and client compute through the same `withDerivedCells`. Select sub-fields take `optionTones`; segments show the tone of the selected option, and narrow screens render cards with 44px controls and the read-only label as the card title. Boot validates the condition fields and rejects `required: true` combined with `visibleWhen`/`requiredWhen`. Saving now enforces `access.write` on embedded sub-fields per row (`ownership_denied`, path `field.subField`); before, only the read side honored sub-field access. Saving also checks each newly placed file id against the `accept`/`maxSize` of the sub-field it lands in (`file_violates_bind_target`). Boot also rejects a numeric derived op whose target is not numeric, which can break an app that derived into a text or select cell. Recipe: `samples/recipes/inspection-checklist`."
63
+ },
64
+ {
65
+ "version": "0.400.0",
66
+ "type": "fix",
67
+ "title": "count badge works with strict query schemas",
68
+ "detail": "The boot check for a relatedList with `count: true` treated every Zod object with a catchall as having open keys and skipped the `limit` and `totalCount` lookup, so a query declared with `.strict()` (catchall `never`) failed boot although it accepts both keys. Only a catchall other than `never` now counts as open; `.loose()` and `.catchall()` schemas still fail the check."
69
+ },
2
70
  {
3
71
  "version": "0.399.0",
4
72
  "type": "improvement",