@cosmicdrift/kumiko-framework 0.352.0 → 0.354.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 (188) hide show
  1. package/dist/api/index.d.ts +1 -1
  2. package/dist/api/index.js +1 -1
  3. package/dist/api/pii-leak-guard.js +3 -0
  4. package/dist/api/redis-sse-broker.d.ts +1 -0
  5. package/dist/api/redis-sse-broker.js +16 -10
  6. package/dist/api/request-context.d.ts +1 -0
  7. package/dist/api/request-context.js +5 -0
  8. package/dist/api/route-registrars.d.ts +2 -1
  9. package/dist/api/route-registrars.js +8 -0
  10. package/dist/api/routes.js +4 -2
  11. package/dist/api/server.js +39 -6
  12. package/dist/api/sse-broker.d.ts +7 -3
  13. package/dist/api/sse-route.js +7 -1
  14. package/dist/api/token-expiry-timer.d.ts +1 -0
  15. package/dist/api/token-expiry-timer.js +10 -0
  16. package/dist/bun-db/query.js +36 -4
  17. package/dist/compliance/sub-processors.js +5 -0
  18. package/dist/crypto/event-pii.js +4 -2
  19. package/dist/crypto/kms-wiring.js +31 -5
  20. package/dist/crypto/pii-field-encryption.d.ts +2 -3
  21. package/dist/crypto/pii-field-encryption.js +2 -3
  22. package/dist/db/eagerload.d.ts +8 -3
  23. package/dist/db/eagerload.js +85 -81
  24. package/dist/db/entity-table-meta.js +11 -0
  25. package/dist/db/event-store-executor-context.d.ts +12 -0
  26. package/dist/db/event-store-executor-context.js +78 -42
  27. package/dist/db/event-store-executor-read.js +67 -25
  28. package/dist/db/event-store-executor-write.js +54 -3
  29. package/dist/db/index.d.ts +1 -1
  30. package/dist/db/migrate-generator.js +10 -2
  31. package/dist/db/queries/backfill-pii.d.ts +2 -0
  32. package/dist/db/queries/backfill-pii.js +24 -6
  33. package/dist/db/queries/event-consumer.d.ts +2 -3
  34. package/dist/db/queries/event-consumer.js +54 -43
  35. package/dist/db/queries/event-store.d.ts +4 -0
  36. package/dist/db/queries/event-store.js +40 -3
  37. package/dist/db/queries/pii-backfill-state.d.ts +8 -1
  38. package/dist/db/queries/pii-backfill-state.js +16 -5
  39. package/dist/db/queries/shadow-swap.js +37 -5
  40. package/dist/db/render-ddl.js +6 -6
  41. package/dist/db/table-builder.js +6 -7
  42. package/dist/db/tenant-db.d.ts +5 -2
  43. package/dist/db/tenant-db.js +47 -7
  44. package/dist/derivatives/variant-key.d.ts +1 -0
  45. package/dist/derivatives/variant-key.js +3 -0
  46. package/dist/engine/boot-validator/access-declarations.js +21 -1
  47. package/dist/engine/boot-validator/config-deps.js +3 -0
  48. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  49. package/dist/engine/boot-validator/entity-handler.js +53 -0
  50. package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
  51. package/dist/engine/boot-validator/handler-field-references.js +25 -0
  52. package/dist/engine/boot-validator/index.js +6 -1
  53. package/dist/engine/boot-validator/nav.js +3 -1
  54. package/dist/engine/boot-validator/ownership.d.ts +2 -1
  55. package/dist/engine/boot-validator/ownership.js +32 -1
  56. package/dist/engine/boot-validator/pii-retention.js +8 -1
  57. package/dist/engine/boot-validator/query-output-columns.js +9 -9
  58. package/dist/engine/boot-validator/screens.js +136 -15
  59. package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
  60. package/dist/engine/boot-validator/select-option-tone.js +4 -0
  61. package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
  62. package/dist/engine/boot-validator/transfer-graph.js +8 -63
  63. package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
  64. package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
  65. package/dist/engine/build-app-schema.js +33 -2
  66. package/dist/engine/build-config-feature-schema.js +6 -0
  67. package/dist/engine/changeset-changes.d.ts +1 -0
  68. package/dist/engine/changeset-changes.js +49 -33
  69. package/dist/engine/dedupe-features.js +17 -1
  70. package/dist/engine/define-feature.js +11 -0
  71. package/dist/engine/entity-handlers.d.ts +3 -3
  72. package/dist/engine/entity-handlers.js +70 -40
  73. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  74. package/dist/engine/escape-hatch-reason.js +8 -0
  75. package/dist/engine/extension-names.d.ts +8 -9
  76. package/dist/engine/extension-names.js +8 -9
  77. package/dist/engine/extensions/tenant-data.d.ts +3 -1
  78. package/dist/engine/extensions/tenant-resource.d.ts +9 -1
  79. package/dist/engine/factories.d.ts +1 -1
  80. package/dist/engine/feature-builder-state.d.ts +2 -7
  81. package/dist/engine/feature-changelog.d.ts +2 -0
  82. package/dist/engine/feature-changelog.js +6 -0
  83. package/dist/engine/feature-config-events-jobs.js +44 -4
  84. package/dist/engine/feature-entity-handlers.js +1 -1
  85. package/dist/engine/handler-field-references.d.ts +3 -0
  86. package/dist/engine/handler-field-references.js +31 -0
  87. package/dist/engine/i18n-key.js +13 -3
  88. package/dist/engine/index.d.ts +9 -3
  89. package/dist/engine/index.js +7 -2
  90. package/dist/engine/ownership.js +3 -4
  91. package/dist/engine/pipeline.js +18 -1
  92. package/dist/engine/project-app-schema-for-roles.js +11 -3
  93. package/dist/engine/registry-ingest.d.ts +1 -0
  94. package/dist/engine/registry-ingest.js +8 -3
  95. package/dist/engine/role-assignment.d.ts +2 -0
  96. package/dist/engine/role-assignment.js +2 -0
  97. package/dist/engine/run-pipeline.js +12 -1
  98. package/dist/engine/schema-builder.js +8 -0
  99. package/dist/engine/screen-helpers.d.ts +13 -0
  100. package/dist/engine/screen-helpers.js +31 -0
  101. package/dist/engine/soft-delete-cleanup.js +3 -2
  102. package/dist/engine/steps/_read-source.d.ts +4 -0
  103. package/dist/engine/steps/_read-source.js +22 -0
  104. package/dist/engine/steps/describe-workflow-step-error.js +3 -1
  105. package/dist/engine/steps/read-find-many.js +2 -1
  106. package/dist/engine/steps/read-find-one.js +2 -1
  107. package/dist/engine/steps/workflow-step-error.d.ts +5 -0
  108. package/dist/engine/steps/workflow-step-error.js +13 -0
  109. package/dist/engine/transfer-adjacency.d.ts +24 -0
  110. package/dist/engine/transfer-adjacency.js +107 -0
  111. package/dist/engine/where-rule-lint.js +6 -3
  112. package/dist/entrypoint/index.d.ts +2 -0
  113. package/dist/entrypoint/index.js +2 -0
  114. package/dist/env/index.d.ts +4 -0
  115. package/dist/env/index.js +49 -23
  116. package/dist/errors/classes.d.ts +8 -0
  117. package/dist/errors/classes.js +13 -0
  118. package/dist/errors/index.d.ts +2 -1
  119. package/dist/errors/index.js +2 -1
  120. package/dist/errors/reasons.d.ts +1 -0
  121. package/dist/errors/reasons.js +3 -0
  122. package/dist/errors/subject-erased.d.ts +2 -0
  123. package/dist/errors/subject-erased.js +9 -0
  124. package/dist/es-ops/context.js +19 -1
  125. package/dist/event-store/admin-api.js +14 -10
  126. package/dist/event-store/event-store.js +11 -9
  127. package/dist/event-store/events-schema.js +2 -1
  128. package/dist/files/file-handle.d.ts +1 -1
  129. package/dist/files/file-handle.js +30 -6
  130. package/dist/files/file-ref-entity.d.ts +6 -0
  131. package/dist/files/file-ref-table.d.ts +6 -0
  132. package/dist/files/file-routes.js +13 -7
  133. package/dist/files/resolve-content-type.d.ts +2 -0
  134. package/dist/files/resolve-content-type.js +8 -0
  135. package/dist/files/types.js +21 -1
  136. package/dist/i18n/required-surface-keys.d.ts +2 -0
  137. package/dist/i18n/required-surface-keys.js +8 -0
  138. package/dist/jobs/index.d.ts +1 -1
  139. package/dist/jobs/job-runner.d.ts +13 -0
  140. package/dist/jobs/job-runner.js +128 -22
  141. package/dist/migrations/pii-event-backfill.js +46 -25
  142. package/dist/observability/index.d.ts +1 -1
  143. package/dist/observability/index.js +1 -1
  144. package/dist/observability/metrics-wiring.d.ts +1 -1
  145. package/dist/observability/metrics-wiring.js +2 -2
  146. package/dist/observability/standard-metrics.d.ts +4 -0
  147. package/dist/observability/standard-metrics.js +12 -0
  148. package/dist/pipeline/dispatch-shared.d.ts +2 -0
  149. package/dist/pipeline/dispatch-shared.js +74 -9
  150. package/dist/pipeline/dispatch-stream.d.ts +5 -1
  151. package/dist/pipeline/dispatch-stream.js +19 -12
  152. package/dist/pipeline/dispatcher.d.ts +3 -1
  153. package/dist/pipeline/dispatcher.js +10 -15
  154. package/dist/pipeline/event-consumer-state.js +0 -26
  155. package/dist/pipeline/event-dispatcher-admin.js +17 -11
  156. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
  157. package/dist/pipeline/event-dispatcher-delivery.js +3 -2
  158. package/dist/pipeline/event-dispatcher.js +48 -7
  159. package/dist/pipeline/index.d.ts +2 -0
  160. package/dist/pipeline/index.js +1 -0
  161. package/dist/pipeline/member-read-only-transaction.js +25 -5
  162. package/dist/pipeline/member-reader.js +1 -0
  163. package/dist/pipeline/msp-rebuild.js +3 -3
  164. package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
  165. package/dist/pipeline/pending-gap-ranges.js +22 -0
  166. package/dist/pipeline/system-hooks.js +4 -4
  167. package/dist/pipeline/system-identity-switch.d.ts +1 -1
  168. package/dist/pipeline/system-identity-switch.js +5 -4
  169. package/dist/pipeline/tenant-timezone-cache.js +5 -3
  170. package/dist/redis/cache-sync-topics.d.ts +1 -0
  171. package/dist/redis/cache-sync-topics.js +1 -0
  172. package/dist/redis/client.d.ts +1 -0
  173. package/dist/redis/client.js +7 -0
  174. package/dist/redis/index.d.ts +1 -1
  175. package/dist/redis/index.js +1 -1
  176. package/dist/redis/pubsub-signal.d.ts +1 -0
  177. package/dist/redis/pubsub-signal.js +3 -0
  178. package/dist/search/meilisearch-adapter.js +34 -4
  179. package/dist/stack/test-stack.d.ts +4 -1
  180. package/dist/stack/test-stack.js +9 -2
  181. package/dist/testing/e2e-generator.js +7 -2
  182. package/dist/testing/shared-entities.d.ts +10 -0
  183. package/dist/ui-types/index.d.ts +3 -3
  184. package/dist/ui-types/index.js +2 -2
  185. package/dist/upgrade-cli.js +7 -2
  186. package/package.json +12 -4
  187. package/src/changes.json +228 -0
  188. package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
@@ -13,6 +13,39 @@ function isAbsentable(field) {
13
13
  return parsed.success && (parsed.data === null || parsed.data === undefined);
14
14
  });
15
15
  }
16
+ const NON_STRING_SCHEMA_TYPES = new Set([
17
+ "number",
18
+ "int",
19
+ "boolean",
20
+ "bigint",
21
+ "date",
22
+ "object",
23
+ "array",
24
+ ]);
25
+ // An owner id is resolved through "non-empty string or absent" at append time,
26
+ // so a schema that can never yield a string would make every append throw in
27
+ // prod. Wrappers (optional/nullable/default/...) are looked through.
28
+ function neverYieldsString(field) {
29
+ let current = field;
30
+ for (;;) {
31
+ const def = current._zod.def;
32
+ if (def.innerType === undefined)
33
+ return NON_STRING_SCHEMA_TYPES.has(def.type);
34
+ current = def.innerType;
35
+ }
36
+ }
37
+ function assertOwnerFieldYieldsPresentString(args) {
38
+ const { featureName, eventName, field, ownerField, owner } = args;
39
+ if (neverYieldsString(owner)) {
40
+ throw new Error(`[Feature ${featureName}] defineEvent("${eventName}"): piiFields."${field}" is owned by "${ownerField}", whose payload schema is not a string — the subject id must be a non-empty string, so every append would fail. ` +
41
+ `Use a string owner field, or declare whenAbsent ("tenant" | "plaintext").`);
42
+ }
43
+ if (isAbsentable(owner)) {
44
+ throw new Error(`[Feature ${featureName}] defineEvent("${eventName}"): piiFields."${field}" is owned by "${ownerField}", which the payload schema allows to be null/undefined. ` +
45
+ `Declare what happens then: { personal: { of: "${ownerField}", whenAbsent: "tenant" } } encrypts under the envelope tenant key, ` +
46
+ `whenAbsent: "plaintext" acknowledges that the value ships unencrypted and cannot be crypto-shredded (fw#2776).`);
47
+ }
48
+ }
16
49
  // Builds config/secrets/claims/events/jobs/notifications registrar methods.
17
50
  export function buildConfigEventsJobsMethods(state, name) {
18
51
  function config(arg1, arg2) {
@@ -73,16 +106,23 @@ export function buildConfigEventsJobsMethods(state, name) {
73
106
  if (field === normalized.ownerField) {
74
107
  throw new Error(`[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" cannot use itself as the owner field — the subject id is a plaintext pseudonymous fk, the pii field is the value it owns.`);
75
108
  }
109
+ if (normalized.ownerField === "id") {
110
+ throw new Error(`[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" uses owner field "id" — on entities \`of: "id"\` means record-owned, but on events it would encrypt under a phantom user key that a forget never reaches. Use \`personal: "self"\` for a record-owned event field.`);
111
+ }
76
112
  for (const required of [field, normalized.ownerField]) {
77
113
  if (shape && !(required in shape)) {
78
114
  throw new Error(`[Feature ${name}] defineEvent("${eventName}"): piiFields references "${required}" which is not a field of the payload schema.`);
79
115
  }
80
116
  }
81
117
  const owner = shape?.[normalized.ownerField];
82
- if (normalized.whenAbsent === undefined && owner !== undefined && isAbsentable(owner)) {
83
- throw new Error(`[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" is owned by "${normalized.ownerField}", which the payload schema allows to be null/undefined. ` +
84
- `Declare what happens then: { personal: { of: "${normalized.ownerField}", whenAbsent: "tenant" } } encrypts under the envelope tenant key, ` +
85
- `whenAbsent: "plaintext" acknowledges that the value ships unencrypted and cannot be crypto-shredded (fw#2776).`);
118
+ if (normalized.whenAbsent === undefined && owner !== undefined) {
119
+ assertOwnerFieldYieldsPresentString({
120
+ featureName: name,
121
+ eventName,
122
+ field,
123
+ ownerField: normalized.ownerField,
124
+ owner,
125
+ });
86
126
  }
87
127
  }
88
128
  }
@@ -55,7 +55,7 @@ function agentSlots(source) {
55
55
  };
56
56
  }
57
57
  function requireInlineHandlerArgs(kind, schema, handler, options) {
58
- if (!schema || !handler || !options) {
58
+ if (!schema || !handler || !options?.access) {
59
59
  throw new Error(`${kind} inline form requires schema + handler + options.access`);
60
60
  }
61
61
  return { schema, handler, options };
@@ -0,0 +1,3 @@
1
+ import { type ZodType } from "zod";
2
+ export declare const HANDLER_FIELD_REFERENCES_META_KEY = "references";
3
+ export declare function handlerFieldReference(schema: ZodType, field: string): string | undefined;
@@ -0,0 +1,31 @@
1
+ import { ZodObject } from "zod";
2
+ import { zodDef, zodMeta, zodShapeField } from "../env/_zod-introspect.js";
3
+ export const HANDLER_FIELD_REFERENCES_META_KEY = "references";
4
+ const MAX_WRAPPER_DEPTH = 8;
5
+ function referencesFromMeta(field) {
6
+ const meta = zodMeta(field);
7
+ if (typeof meta !== "object" || meta === null)
8
+ return undefined;
9
+ const value = Reflect.get(meta, HANDLER_FIELD_REFERENCES_META_KEY);
10
+ return typeof value === "string" ? value : undefined;
11
+ }
12
+ // `.optional()` / `.nullable()` / `.default()` after `.meta()` hide the meta
13
+ // from the outer type, so every wrapper layer is probed on the way in.
14
+ export function handlerFieldReference(schema, field) {
15
+ let current = schema;
16
+ for (let depth = 0; depth < MAX_WRAPPER_DEPTH && current !== undefined; depth++) {
17
+ if (current instanceof ZodObject)
18
+ break;
19
+ current = zodDef(current)?.innerType;
20
+ }
21
+ if (!(current instanceof ZodObject))
22
+ return undefined;
23
+ let fieldSchema = zodShapeField(current, field);
24
+ for (let depth = 0; depth < MAX_WRAPPER_DEPTH && fieldSchema !== undefined; depth++) {
25
+ const reference = referencesFromMeta(fieldSchema);
26
+ if (reference !== undefined)
27
+ return reference;
28
+ fieldSchema = zodDef(fieldSchema)?.innerType;
29
+ }
30
+ return undefined;
31
+ }
@@ -1,10 +1,20 @@
1
1
  // Dot-form labels are indistinguishable from literal display text ("actions.open"),
2
2
  // so authors mark the i18n ones explicitly; the registry is what boot validation reads.
3
- const explicitDotFormKeys = new Set();
3
+ // A mark applies process-wide, by string value, across all features and framework copies.
4
+ // The registry sits on globalThis so a second installed copy reads the other's marks.
5
+ const REGISTRY_KEY = Symbol.for("kumiko.i18n.explicitDotFormKeys");
6
+ function explicitDotFormKeys() {
7
+ const existing = Reflect.get(globalThis, REGISTRY_KEY);
8
+ if (existing instanceof Set)
9
+ return existing;
10
+ const created = new Set();
11
+ Reflect.set(globalThis, REGISTRY_KEY, created);
12
+ return created;
13
+ }
4
14
  export function i18nKey(value) {
5
- explicitDotFormKeys.add(value);
15
+ explicitDotFormKeys().add(value);
6
16
  return value;
7
17
  }
8
18
  export function isExplicitDotFormKey(value) {
9
- return explicitDotFormKeys.has(value);
19
+ return explicitDotFormKeys().has(value);
10
20
  }
@@ -1,3 +1,4 @@
1
+ export { handlerFieldLabelKey, handlerTitleKey } from "../i18n/required-surface-keys.js";
1
2
  export { hasAccess } from "./access.js";
2
3
  export { isPrincipalStatusPlugin, isTenantLifecycleStatusPlugin, type PrincipalProfile, type PrincipalStatus, type PrincipalStatusPlugin, TENANT_TEARDOWN_STATUSES, type TenantLifecycleStatusPlugin, } from "./active-membership.js";
3
4
  export { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./boot-validator/entity-handler.js";
@@ -34,7 +35,7 @@ export { EXTENSION_SELECTOR_HINT_KEY, extensionSelectorTargets, SELECTED_EXTENSI
34
35
  export { extensionUsageEscapeHatchReason } from "./extensions/escape-hatch-usage.js";
35
36
  export type { StorageProviderDestroyTenantHook, StorageProviderExtensionHooks, StorageProviderHookCtx, } from "./extensions/storage-provider.js";
36
37
  export { isTenantDataExtensionHooks, type TenantDataDestroyHook, type TenantDataExtensionHooks, type TenantDataHookCtx, } from "./extensions/tenant-data.js";
37
- export { isTenantResourceExtensionHooks, type TenantResourceDestroyHook, type TenantResourceExtensionHooks, type TenantResourceHookCtx, } from "./extensions/tenant-resource.js";
38
+ export { isTenantResourceExtensionHooks, type TenantDestroyHookResult, type TenantResourceDestroyHook, type TenantResourceExtensionHooks, type TenantResourceHookCtx, } from "./extensions/tenant-resource.js";
38
39
  export type { TenantUserModel, UserDataDeleteHook, UserDataDeleteStrategy, UserDataExportHook, UserDataExportSnippet, UserDataExtensionHooks, UserDataExtensionOptions, UserDataHookCtx, UserDataStorageProvider, } from "./extensions/user-data.js";
39
40
  export { createBigIntField, createBooleanField, createDateField, createDecimalField, createDerivedField, createEmbeddedField, createEmbeddedListField, createEntity, createFileField, createFilesField, createImageField, createImagesField, createJsonbField, createLocatedTimestampField, createLongTextField, createMoneyField, createMultiSelectField, createNumberField, createSelectField, createTextField, createTimestampField, createTzField, } from "./factories.js";
40
41
  export type { AddEntityArgs, AddHookArgs, AddRelationArgs, AddWriteHandlerArgs, FeaturePatcher, FeaturePattern, FeaturePatternKind, FormFieldLabel, FormFieldSpec, FormInputType, HandlerHeaderUpdate, ParseError, ParseResult, PatternCategory, PatternChange, PatternChangeIssue, PatternChangesParseResult, PatternFormSchema, PatternId, RenderFeatureFileInput, SourceLocation, } from "./feature-ast/index.js";
@@ -42,6 +43,7 @@ export { addPattern, applyChanges, createFeaturePatcher, getPatternSchema, group
42
43
  export { type ChangelogEntry, type ChangelogType, compareVersions, type FeatureChangelog, filterEntriesAfter, parseFeatureChangelog, sortEntries, validateChangelog, } from "./feature-changelog.js";
43
44
  export { type BuildManifestOptions, buildManifestFromRegistry, type FeatureManifest, type ManifestConfigKey, type ManifestExtension, type ManifestFeature, type ManifestSecret, type ManifestSystemEvent, serializeManifest, } from "./feature-manifest.js";
44
45
  export { checkWriteFieldOwnership, checkWriteFieldRoles, filterReadFields, maskWriteOnlyFields, } from "./field-access.js";
46
+ export { HANDLER_FIELD_REFERENCES_META_KEY, handlerFieldReference, } from "./handler-field-references.js";
45
47
  export { resolveName, withResponseData } from "./handler-helpers.js";
46
48
  export { i18nKey } from "./i18n-key.js";
47
49
  export { buildSessionRoles, findForbiddenMembershipRole, isForbiddenMembershipRole, stripForbiddenMembershipRoles, } from "./membership-roles.js";
@@ -56,23 +58,27 @@ export type { BuiltinQnType, ParsedQn, QnType } from "./qualified-name.js";
56
58
  export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name.js";
57
59
  export { readClaim } from "./read-claim.js";
58
60
  export { createRegistry } from "./registry.js";
61
+ export { qualifyTranslationKey } from "./registry-ingest.js";
59
62
  export type { ClampInfo, ResolveOptions } from "./resolve-config-or-param.js";
60
63
  export { resolveConfigOrParam } from "./resolve-config-or-param.js";
61
64
  export type { AssignableAppRoles, AssignableFromRole } from "./role-assignment.js";
62
- export { assignableAppRolesFromUsages, assignableAppRolesOf, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
65
+ export { assignableAppRolesFromUsages, assignableAppRolesOf, BUILT_IN_MEMBERSHIP_ROLES, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
63
66
  export { runsInLane } from "./run-in.js";
64
67
  export type { StepListOutcome } from "./run-pipeline.js";
65
68
  export { runPipeline, runStepList } from "./run-pipeline.js";
66
69
  export { buildInsertSchema, buildUpdateSchema, fieldToZod, SYSTEM_ONLY_JSON_SCHEMA_KEY, } from "./schema-builder.js";
67
- export { isExtensionEditSection, isFieldsEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, sectionFieldSpecs, } from "./screen-helpers.js";
70
+ export type { UnavailableSelectOption } from "./screen-helpers.js";
71
+ export { availableSelectOptions, findUnavailableSelectOptions, isExtensionEditSection, isFieldsEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, sectionFieldSpecs, } from "./screen-helpers.js";
68
72
  export type { TransitionGraph } from "./state-machine.js";
69
73
  export { defineTransitions, guardTransition } from "./state-machine.js";
70
74
  export { evaluateEventMatch } from "./steps/_event-match.js";
71
75
  export { STEP_DISPATCH_AGGREGATE_TYPE, STEP_DISPATCH_FAILED_TYPE, STEP_DISPATCH_REQUESTED_TYPE, STEP_DISPATCHED_TYPE, SUSPEND_SENTINEL, WORKFLOW_AGGREGATE_TYPE, WORKFLOW_RESUMED_TYPE, WORKFLOW_RETRY_SCHEDULED_TYPE, WORKFLOW_RUN_COMPLETED_TYPE, WORKFLOW_RUN_FAILED_TYPE, WORKFLOW_RUN_STARTED_TYPE, WORKFLOW_WAITING_FOR_EVENT_TYPE, WORKFLOW_WAITING_TYPE, } from "./steps/_step-dispatch-constants.js";
72
76
  export { describeWorkflowStepError } from "./steps/describe-workflow-step-error.js";
77
+ export { unwrapWorkflowStepError, WorkflowStepError } from "./steps/workflow-step-error.js";
73
78
  export { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
74
79
  export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
75
80
  export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.js";
81
+ export { findOverDeepTransferChain, resolveTransferAdjacency as resolveEntityTransferAdjacency, type TransferAdjacency, type TransferEdge, type TransferEdgeLink, } from "./transfer-adjacency.js";
76
82
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
77
83
  export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardValueFormat, DateFieldDef, DecimalFieldDef, DeleteContext, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EntityDefinition, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, NotifyDelivery, NotifyDeliveryStatus, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TenantId, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
78
84
  export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HookPhases, HTTP_ERROR_CODE_PATTERN, isAgentVisibleScreen, isDeliveryErrorCode, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
@@ -1,4 +1,5 @@
1
1
  // Public API
2
+ export { handlerFieldLabelKey, handlerTitleKey } from "../i18n/required-surface-keys.js";
2
3
  export { hasAccess } from "./access.js";
3
4
  export { isPrincipalStatusPlugin, isTenantLifecycleStatusPlugin, TENANT_TEARDOWN_STATUSES, } from "./active-membership.js";
4
5
  export { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./boot-validator/entity-handler.js";
@@ -37,6 +38,7 @@ export { addPattern, applyChanges, createFeaturePatcher, getPatternSchema, group
37
38
  export { compareVersions, filterEntriesAfter, parseFeatureChangelog, sortEntries, validateChangelog, } from "./feature-changelog.js";
38
39
  export { buildManifestFromRegistry, serializeManifest, } from "./feature-manifest.js";
39
40
  export { checkWriteFieldOwnership, checkWriteFieldRoles, filterReadFields, maskWriteOnlyFields, } from "./field-access.js";
41
+ export { HANDLER_FIELD_REFERENCES_META_KEY, handlerFieldReference, } from "./handler-field-references.js";
40
42
  export { resolveName, withResponseData } from "./handler-helpers.js";
41
43
  export { i18nKey } from "./i18n-key.js";
42
44
  // findForbiddenMembershipRole/isForbiddenMembershipRole/
@@ -54,19 +56,22 @@ export { defineApply, defineMspApply, setFields } from "./projection-helpers.js"
54
56
  export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name.js";
55
57
  export { readClaim } from "./read-claim.js";
56
58
  export { createRegistry } from "./registry.js";
59
+ export { qualifyTranslationKey } from "./registry-ingest.js";
57
60
  export { resolveConfigOrParam } from "./resolve-config-or-param.js";
58
- export { assignableAppRolesFromUsages, assignableAppRolesOf, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
61
+ export { assignableAppRolesFromUsages, assignableAppRolesOf, BUILT_IN_MEMBERSHIP_ROLES, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
59
62
  export { runsInLane } from "./run-in.js";
60
63
  export { runPipeline, runStepList } from "./run-pipeline.js";
61
64
  export { buildInsertSchema, buildUpdateSchema, fieldToZod, SYSTEM_ONLY_JSON_SCHEMA_KEY, } from "./schema-builder.js";
62
- export { isExtensionEditSection, isFieldsEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, sectionFieldSpecs, } from "./screen-helpers.js";
65
+ export { availableSelectOptions, findUnavailableSelectOptions, isExtensionEditSection, isFieldsEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, sectionFieldSpecs, } from "./screen-helpers.js";
63
66
  export { defineTransitions, guardTransition } from "./state-machine.js";
64
67
  export { evaluateEventMatch } from "./steps/_event-match.js";
65
68
  export { STEP_DISPATCH_AGGREGATE_TYPE, STEP_DISPATCH_FAILED_TYPE, STEP_DISPATCH_REQUESTED_TYPE, STEP_DISPATCHED_TYPE, SUSPEND_SENTINEL, WORKFLOW_AGGREGATE_TYPE, WORKFLOW_RESUMED_TYPE, WORKFLOW_RETRY_SCHEDULED_TYPE, WORKFLOW_RUN_COMPLETED_TYPE, WORKFLOW_RUN_FAILED_TYPE, WORKFLOW_RUN_STARTED_TYPE, WORKFLOW_WAITING_FOR_EVENT_TYPE, WORKFLOW_WAITING_TYPE, } from "./steps/_step-dispatch-constants.js";
66
69
  export { describeWorkflowStepError } from "./steps/describe-workflow-step-error.js";
70
+ export { unwrapWorkflowStepError, WorkflowStepError } from "./steps/workflow-step-error.js";
67
71
  export { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
68
72
  export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
69
73
  export { findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, } from "./tier-resolver-extension.js";
74
+ export { findOverDeepTransferChain, resolveTransferAdjacency as resolveEntityTransferAdjacency, } from "./transfer-adjacency.js";
70
75
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
71
76
  export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HookPhases, HTTP_ERROR_CODE_PATTERN, isAgentVisibleScreen, isDeliveryErrorCode, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
72
77
  export { runValidation } from "./validation.js";
@@ -108,10 +108,9 @@ export function userCanReadFieldRow(user, accessMap, row) {
108
108
  const rule = accessMap[role];
109
109
  if (!rule)
110
110
  continue;
111
- // where-rules are SQL predicates (buildOwnershipClause); matchesRule
112
- // can't evaluate them in-memory and throws. Read maps may legitimately
113
- // carry one — it just belongs on the SQL path, so an in-memory read
114
- // check denies rather than crashing.
111
+ // where-rules are SQL predicates; matchesRule can't evaluate them in-memory.
112
+ // The boot validator rejects them on field maps, so this only fails closed
113
+ // for maps that bypassed boot validation.
115
114
  // skip: where-rules are SQL-layer only — fail closed instead of throwing.
116
115
  if (rule !== "all" && rule.kind === "where")
117
116
  continue;
@@ -79,9 +79,26 @@ export function stepsPipeline(closure) {
79
79
  // carried in (D4, workflow-resume-loop.md), never an unchecked external
80
80
  // value.
81
81
  export function buildPipelineSteps(pipelineDef, event) {
82
- return pipelineDef.build({
82
+ const steps = pipelineDef.build({
83
83
  event,
84
84
  r: stepBuilder,
85
85
  awaits: (pipelineDef.awaits ?? {}),
86
86
  });
87
+ assertUniqueWaitForEventTypes(steps);
88
+ return steps;
89
+ }
90
+ // waitForEvent's result key is the awaited event type, so a second wait on the
91
+ // same type would overwrite the first one's resumed payload.
92
+ function assertUniqueWaitForEventTypes(steps) {
93
+ const seen = new Set();
94
+ for (const step of steps) {
95
+ if (step.kind !== "workflow.waitForEvent")
96
+ continue;
97
+ const { event } = step.args; // @cast-boundary engine-payload
98
+ if (seen.has(event)) {
99
+ throw new Error(`r.step.waitForEvent: event "${String(event)}" is awaited more than once in one workflow — ` +
100
+ "each awaits entry can back only one waitForEvent step.");
101
+ }
102
+ seen.add(event);
103
+ }
87
104
  }
@@ -443,7 +443,9 @@ function dropUnreachableCreateScreen(screen, feature, keptScreenQns) {
443
443
  if (isScreenTargetKept(feature.featureName, screen.createScreen, keptScreenQns))
444
444
  return screen;
445
445
  const { createScreen: _unreachable, ...rest } = screen;
446
- return rest;
446
+ // Marker, not a bare delete: the renderer treats a missing createScreen as
447
+ // "not set" and would fall back to the entity's edit form.
448
+ return { ...rest, createUnavailable: true };
447
449
  }
448
450
  function projectListScreen(original, feature, indices, keptScreenQns) {
449
451
  const screen = dropUnreachableCreateScreen(original, feature, keptScreenQns);
@@ -530,20 +532,26 @@ function projectActionFormScreen(screen, feature, indices, keptScreenQns) {
530
532
  };
531
533
  }
532
534
  function projectSecretMintScreen(screen, feature, indices, keptScreenQns) {
533
- const { redirect, cancelTarget, layout, listScreenId, ...rest } = screen;
535
+ const { redirect, cancelTarget, layout, listScreenId, confirm, ...rest } = screen;
534
536
  const nextRedirect = projectRedirect(feature.featureName, redirect, keptScreenQns);
535
537
  const nextCancelTarget = projectCancelTarget(feature.featureName, cancelTarget, keptScreenQns);
536
538
  const nextLayout = projectEditLayout(layout, feature, indices, keptScreenQns);
537
539
  const nextListScreenId = projectListScreenId(listScreenId, indices, keptScreenQns);
540
+ const nextConfirmLayout = confirm === undefined
541
+ ? undefined
542
+ : projectEditLayout(confirm.layout, feature, indices, keptScreenQns);
538
543
  const unchanged = nextRedirect === redirect &&
539
544
  nextCancelTarget === cancelTarget &&
540
545
  nextLayout === layout &&
541
- nextListScreenId === listScreenId;
546
+ nextListScreenId === listScreenId &&
547
+ nextConfirmLayout === confirm?.layout;
542
548
  if (unchanged)
543
549
  return screen;
544
550
  return {
545
551
  ...rest,
546
552
  layout: nextLayout,
553
+ ...(confirm !== undefined &&
554
+ nextConfirmLayout !== undefined && { confirm: { ...confirm, layout: nextConfirmLayout } }),
547
555
  ...(nextRedirect !== undefined && { redirect: nextRedirect }),
548
556
  ...(nextCancelTarget !== undefined && { cancelTarget: nextCancelTarget }),
549
557
  ...(nextListScreenId !== undefined && { listScreenId: nextListScreenId }),
@@ -7,6 +7,7 @@ export declare function populateJobsAndNotifications(state: RegistryState, featu
7
7
  export declare function populateEvents(state: RegistryState, feature: FeatureDefinition): void;
8
8
  export declare function populateSystemEvents(state: RegistryState): void;
9
9
  export declare function populateTranslations(state: RegistryState, feature: FeatureDefinition): void;
10
+ export declare function qualifyTranslationKey(featureName: string, key: string): string;
10
11
  export declare function populateHooks(state: RegistryState, feature: FeatureDefinition): void;
11
12
  export declare function populateExtensionsAndSeeds(state: RegistryState, feature: FeatureDefinition): void;
12
13
  export declare function populateMetricsAndSecrets(state: RegistryState, feature: FeatureDefinition): void;
@@ -1,5 +1,6 @@
1
1
  import { resolveTableName } from "../db/entity-table-meta.js";
2
2
  import { buildMetricName, validateMetricName } from "../observability/index.js";
3
+ import { toKebab } from "./qualified-name.js";
3
4
  import { mergeHookList, mergeHookListQualified, qualify } from "./registry-state.js";
4
5
  import { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
5
6
  // Feature registration + entities (globally-unique, physical-table-checked) + relations
@@ -149,12 +150,16 @@ export function populateTranslations(state, feature) {
149
150
  // bare and qualified spellings for the same key are accepted; if a
150
151
  // feature defines both, whichever is declared last wins (Object.entries
151
152
  // = insertion order, not "qualified always wins").
152
- const prefix = `${feature.name}:`;
153
153
  for (const [key, value] of Object.entries(feature.translations ?? {})) {
154
- const qualifiedKey = key.startsWith(prefix) ? key : `${prefix}${key}`;
155
- state.mergedTranslations[qualifiedKey] = value;
154
+ state.mergedTranslations[qualifyTranslationKey(feature.name, key)] = value;
156
155
  }
157
156
  }
157
+ // Handler QNs use the kebab scope, so a camelCase feature's full QN key
158
+ // (`my-feature:write:x:title`) must count as qualified too.
159
+ export function qualifyTranslationKey(featureName, key) {
160
+ const isQualified = key.startsWith(`${featureName}:`) || key.startsWith(`${toKebab(featureName)}:`);
161
+ return isQualified ? key : `${featureName}:${key}`;
162
+ }
158
163
  // Lifecycle hooks (handler-targeted, qualified) + entity hooks (entity-targeted,
159
164
  // unprefixed) + search-payload-extensions (additive per entity).
160
165
  export function populateHooks(state, feature) {
@@ -1,4 +1,6 @@
1
1
  import type { Registry } from "./types/feature.js";
2
+ /** Every membership role the engine ranks, excluding the internal `system` actor. */
3
+ export declare const BUILT_IN_MEMBERSHIP_ROLES: readonly string[];
2
4
  declare const ASSIGNABLE_FROM_ROLE_LIST: readonly ["User", "Member", "Editor", "Admin", "TenantAdmin", "SystemAdmin"];
3
5
  export type AssignableFromRole = (typeof ASSIGNABLE_FROM_ROLE_LIST)[number];
4
6
  /** App role → lowest built-in role allowed to assign it. */
@@ -16,6 +16,8 @@ const ROLE_RANKS = new Map([
16
16
  ["SystemAdmin", 4],
17
17
  ["system", 5],
18
18
  ]);
19
+ /** Every membership role the engine ranks, excluding the internal `system` actor. */
20
+ export const BUILT_IN_MEMBERSHIP_ROLES = [...ROLE_RANKS.keys()].filter((role) => role !== "system");
19
21
  const ASSIGNABLE_FROM_ROLE_LIST = [
20
22
  "User",
21
23
  "Member",
@@ -25,6 +25,7 @@ import { getStep } from "./define-step.js";
25
25
  import { buildPipelineSteps } from "./pipeline.js";
26
26
  import { SUSPEND_SENTINEL } from "./steps/_step-dispatch-constants.js";
27
27
  import { RETURN_RESULT_KEY } from "./steps/return.js";
28
+ import { unwrapWorkflowStepError, WorkflowStepError } from "./steps/workflow-step-error.js";
28
29
  export async function runPipeline(pipelineDef, event, handlerCtx, workflow, resumeFrom) {
29
30
  const steps = buildPipelineSteps(pipelineDef, event);
30
31
  const stepsAcc = {};
@@ -92,7 +93,17 @@ export async function runStepList(steps, event, handlerCtx, stepsAcc, scopeAcc,
92
93
  workflow: { ...workflow, stepIndex: i },
93
94
  }),
94
95
  };
95
- const value = await stepDef.run(instance.args, pipelineCtx);
96
+ let value;
97
+ try {
98
+ value = await stepDef.run(instance.args, pipelineCtx);
99
+ }
100
+ catch (error) {
101
+ // Workflow runs only: plain handler pipelines must keep surfacing the raw error to the
102
+ // dispatcher's error mapping. Nested lists re-wrap, so the outermost index wins.
103
+ if (!workflow)
104
+ throw error;
105
+ throw new WorkflowStepError(i, unwrapWorkflowStepError(error));
106
+ }
96
107
  // Tier-3 suspension: the step wrote a waiting event and returned
97
108
  // SUSPEND_SENTINEL to signal the pipeline should stop. The caller
98
109
  // (defineWorkflow/workflow-engine) persists the suspension state.
@@ -87,6 +87,14 @@ export function fieldToZod(field, currencies, opts = {}) {
87
87
  let schema = z.string();
88
88
  if (field.maxLength)
89
89
  schema = schema.max(field.maxLength);
90
+ if (field.minLength) {
91
+ // "" must stay valid for optional fields and writeOnly updates (""
92
+ // = unchanged); `required` rejects it separately via min(1) below.
93
+ const minLength = field.minLength;
94
+ schema = schema.refine((value) => value.length === 0 || value.length >= minLength, {
95
+ message: `at least ${minLength} characters`,
96
+ });
97
+ }
90
98
  if (field.writeOnly === true) {
91
99
  // Update: "" = unchanged (dropped before the executor), null = clear,
92
100
  // unless required. Create: "" would leave a required field unset.
@@ -1,3 +1,4 @@
1
+ import type { FieldDefinition, SelectFieldDef } from "./types/fields.js";
1
2
  import type { ActionFormScreenDefinition, ConfigEditScreenDefinition, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, FieldCondition, FormatSpec, ListColumnSpec, ProjectionDetailScreenDefinition, ScreenDefinition, SecretMintScreenDefinition } from "./types/screen.js";
2
3
  export declare function explicitListScreenId(screen: ScreenDefinition): string | undefined;
3
4
  export declare function resolveNavParentScreen(screens: readonly ScreenDefinition[], detail: ScreenDefinition, getId: (screen: ScreenDefinition) => string): ScreenDefinition | undefined;
@@ -24,4 +25,16 @@ export declare function normalizeListColumn(c: ListColumnSpec): Exclude<ListColu
24
25
  * (form-condition closures) reuse it; three hand-rolled copies had
25
26
  * already drifted in shape. */
26
27
  export declare function evalFieldCondition(cond: FieldCondition, values: Record<string, unknown>): boolean;
28
+ type SelectOptionRules = Pick<SelectFieldDef, "options" | "conditionalOptions">;
29
+ export type UnavailableSelectOption = {
30
+ readonly field: string;
31
+ readonly value: string;
32
+ readonly allowed: readonly string[];
33
+ };
34
+ /** Options of a select field that are offered for the given row values, in declaration order. */
35
+ export declare function availableSelectOptions(field: SelectOptionRules, values: Record<string, unknown>): readonly string[];
36
+ /** Select values that violate a `conditionalOptions` rule. With `previous`, a field is only
37
+ * checked when it or one of its condition fields changed, so legacy rows stay editable. */
38
+ export declare function findUnavailableSelectOptions(fields: Readonly<Record<string, FieldDefinition>>, values: Record<string, unknown>, previous?: Record<string, unknown>): readonly UnavailableSelectOption[];
27
39
  export declare function normalizeEditField(f: EditFieldSpec): Exclude<EditFieldSpec, string>;
40
+ export {};
@@ -110,6 +110,37 @@ export function evalFieldCondition(cond, values) {
110
110
  return !cond.notIn.includes(val);
111
111
  return val !== cond.ne;
112
112
  }
113
+ /** Options of a select field that are offered for the given row values, in declaration order. */
114
+ export function availableSelectOptions(field, values) {
115
+ const rules = field.conditionalOptions;
116
+ if (!rules || rules.length === 0)
117
+ return field.options;
118
+ return field.options.filter((option) => rules.every((rule) => !rule.options.includes(option) || evalFieldCondition(rule.when, values)));
119
+ }
120
+ function isSameValue(a, b) {
121
+ return JSON.stringify(a) === JSON.stringify(b);
122
+ }
123
+ /** Select values that violate a `conditionalOptions` rule. With `previous`, a field is only
124
+ * checked when it or one of its condition fields changed, so legacy rows stay editable. */
125
+ export function findUnavailableSelectOptions(fields, values, previous) {
126
+ const unavailable = [];
127
+ for (const [name, def] of Object.entries(fields)) {
128
+ if (def.type !== "select" || !def.conditionalOptions)
129
+ continue;
130
+ const value = values[name];
131
+ if (typeof value !== "string" || value === "")
132
+ continue;
133
+ if (previous) {
134
+ const watched = [name, ...def.conditionalOptions.map((rule) => rule.when.field)];
135
+ if (watched.every((key) => isSameValue(values[key], previous[key])))
136
+ continue;
137
+ }
138
+ const allowed = availableSelectOptions(def, values);
139
+ if (!allowed.includes(value))
140
+ unavailable.push({ field: name, value, allowed });
141
+ }
142
+ return unavailable;
143
+ }
113
144
  export function normalizeEditField(f) {
114
145
  return typeof f === "string" ? { field: f } : f;
115
146
  }
@@ -101,7 +101,8 @@ export const softDeleteCleanupSystemJob = async (_payload, ctx) => {
101
101
  continue;
102
102
  const where = { isDeleted: true, deletedAt: { lt: cutoff } };
103
103
  if (isGlobalTenancyTable(proj.table)) {
104
- await db.global(proj.table).deleteMany(where);
104
+ // db.global() rejects writes on executor-managed tables; a retention purge has no event to append.
105
+ await deleteMany(db.unsafeRaw(), proj.table, where);
105
106
  }
106
107
  else {
107
108
  await deleteMany(db, proj.table, where);
@@ -127,7 +128,7 @@ export function buildSoftDeleteCleanupSystemJob() {
127
128
  runIn: "worker",
128
129
  escapeHatch: {
129
130
  reason: "hard-deletes expired soft-deleted rows of global entities (e.g. user) once, system-wide",
130
- grants: ["globalWrites"],
131
+ grants: ["unsafeRaw"],
131
132
  },
132
133
  };
133
134
  }
@@ -1,6 +1,10 @@
1
1
  import type { DbRunner } from "../../db/connection.js";
2
+ import type { WhereObject } from "../../db/query.js";
2
3
  import { type TenantDb } from "../../db/tenant-db.js";
3
4
  import type { PipelineCtx } from "../types/step.js";
4
5
  export declare function readSourceFor(ctx: PipelineCtx, unsafeAllTenants: {
5
6
  readonly reason: string;
6
7
  } | undefined): TenantDb | DbRunner;
8
+ export declare function warnOnNarrowedForeignTenantFilter(ctx: PipelineCtx, stepKind: string, stepName: string, where: WhereObject | undefined, unsafeAllTenants: {
9
+ readonly reason: string;
10
+ } | undefined): void;
@@ -1,5 +1,6 @@
1
1
  import { unsafeRawForDeclaredStep } from "../../db/tenant-db.js";
2
2
  import { InternalError } from "../../errors/classes.js";
3
+ import { SYSTEM_TENANT_ID } from "../types/identifiers.js";
3
4
  // unsafeRaw fails closed without the handler's escapeHatch (or systemScope) and reports the use.
4
5
  export function readSourceFor(ctx, unsafeAllTenants) {
5
6
  if (!unsafeAllTenants)
@@ -13,3 +14,24 @@ export function readSourceFor(ctx, unsafeAllTenants) {
13
14
  }
14
15
  return unsafeRawForDeclaredStep(ctx.systemDb ?? ctx.db, unsafeAllTenants.reason);
15
16
  }
17
+ // TenantDb silently narrows a foreign where.tenantId to the caller's scope; the step keeps that
18
+ // behavior but makes the dropped filter visible, since it usually means a cross-tenant read was intended.
19
+ export function warnOnNarrowedForeignTenantFilter(ctx, stepKind, stepName, where, unsafeAllTenants) {
20
+ // skip: explicit cross-tenant read, nothing was narrowed.
21
+ if (unsafeAllTenants)
22
+ return;
23
+ const requested = where?.["tenantId"];
24
+ // skip: no tenantId filter requested.
25
+ if (requested === undefined)
26
+ return;
27
+ const allowed = [ctx.db.tenantId, SYSTEM_TENANT_ID];
28
+ const requestedList = Array.isArray(requested) ? requested : [requested];
29
+ // skip: every requested tenant is within the caller's scope.
30
+ if (requestedList.every((t) => typeof t === "string" && allowed.includes(t)))
31
+ return;
32
+ ctx.log?.warn(`${stepKind} "${stepName}": where.tenantId is outside the caller's tenant scope and was narrowed`, {
33
+ step: stepKind,
34
+ name: stepName,
35
+ hint: "pass unsafeAllTenants: { reason } (with an escapeHatch on the handler) to read across tenants",
36
+ });
37
+ }
@@ -1,6 +1,8 @@
1
1
  // Workflow events (run-failed, retry.scheduled) outlive an Art. 17 erase, and a
2
2
  // raw error message can echo recipients or payload fragments. Events carry only
3
3
  // this generic text; the raw error goes to the log.
4
+ import { unwrapWorkflowStepError } from "./workflow-step-error.js";
4
5
  export function describeWorkflowStepError(error) {
5
- return `workflow step failed (${error instanceof Error ? error.name : "unknown error"})`;
6
+ const original = unwrapWorkflowStepError(error);
7
+ return `workflow step failed (${original instanceof Error ? original.name : "unknown error"})`;
6
8
  }
@@ -11,7 +11,7 @@
11
11
  // `limit` explicitly when the row-count could grow without bound.
12
12
  import { selectMany } from "../../db/query.js";
13
13
  import { defineStep } from "../define-step.js";
14
- import { readSourceFor } from "./_read-source.js";
14
+ import { readSourceFor, warnOnNarrowedForeignTenantFilter } from "./_read-source.js";
15
15
  import { resolveOptional } from "./_resolver-utils.js";
16
16
  defineStep({
17
17
  kind: "read.findMany",
@@ -19,6 +19,7 @@ defineStep({
19
19
  resultKey: (args) => args.name,
20
20
  run: async (args, ctx) => {
21
21
  const where = resolveOptional(args.where, ctx);
22
+ warnOnNarrowedForeignTenantFilter(ctx, "read.findMany", args.name, where, args.unsafeAllTenants);
22
23
  const source = readSourceFor(ctx, args.unsafeAllTenants);
23
24
  const rows = await selectMany(source, args.table, where, args.limit !== undefined ? { limit: args.limit } : undefined);
24
25
  return rows;
@@ -20,7 +20,7 @@
20
20
  // runtime check; reviewer responsibility.
21
21
  import { selectMany } from "../../db/query.js";
22
22
  import { defineStep } from "../define-step.js";
23
- import { readSourceFor } from "./_read-source.js";
23
+ import { readSourceFor, warnOnNarrowedForeignTenantFilter } from "./_read-source.js";
24
24
  import { resolveRequired } from "./_resolver-utils.js";
25
25
  defineStep({
26
26
  kind: "read.findOne",
@@ -28,6 +28,7 @@ defineStep({
28
28
  resultKey: (args) => args.name,
29
29
  run: async (args, ctx) => {
30
30
  const where = resolveRequired(args.where, ctx);
31
+ warnOnNarrowedForeignTenantFilter(ctx, "read.findOne", args.name, where, args.unsafeAllTenants);
31
32
  const source = readSourceFor(ctx, args.unsafeAllTenants);
32
33
  const rows = await selectMany(source, args.table, where, { limit: 1 });
33
34
  return rows[0] ?? null;
@@ -0,0 +1,5 @@
1
+ export declare class WorkflowStepError extends Error {
2
+ readonly stepIndex: number;
3
+ constructor(stepIndex: number, cause: unknown);
4
+ }
5
+ export declare function unwrapWorkflowStepError(error: unknown): unknown;
@@ -0,0 +1,13 @@
1
+ // Carries the top-level step index a workflow run failed at, so run-failed
2
+ // events don't have to guess it. `cause` stays the original throw.
3
+ export class WorkflowStepError extends Error {
4
+ stepIndex;
5
+ constructor(stepIndex, cause) {
6
+ super(`workflow step ${stepIndex} failed`, { cause });
7
+ this.stepIndex = stepIndex;
8
+ this.name = "WorkflowStepError";
9
+ }
10
+ }
11
+ export function unwrapWorkflowStepError(error) {
12
+ return error instanceof WorkflowStepError ? error.cause : error;
13
+ }