@cosmicdrift/kumiko-framework 0.331.0 → 0.333.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 (50) hide show
  1. package/dist/db/blind-index-cleanup.d.ts +1 -0
  2. package/dist/db/blind-index-cleanup.js +12 -0
  3. package/dist/db/index.d.ts +1 -1
  4. package/dist/db/index.js +1 -1
  5. package/dist/db/queries/event-consumer.d.ts +2 -1
  6. package/dist/db/queries/event-consumer.js +12 -2
  7. package/dist/engine/boot-validator/index.js +2 -0
  8. package/dist/engine/boot-validator/query-output-columns.js +15 -2
  9. package/dist/engine/boot-validator/query-refs.d.ts +2 -1
  10. package/dist/engine/boot-validator/query-refs.js +1 -1
  11. package/dist/engine/boot-validator/screens.d.ts +3 -1
  12. package/dist/engine/boot-validator/screens.js +33 -2
  13. package/dist/engine/boot-validator/selector-owner-panels.d.ts +2 -0
  14. package/dist/engine/boot-validator/selector-owner-panels.js +25 -0
  15. package/dist/engine/boot-validator/url-prefill-fields.js +7 -0
  16. package/dist/engine/build-config-feature-schema.js +31 -6
  17. package/dist/engine/changeset-changes.js +6 -0
  18. package/dist/engine/entity-handlers.js +1 -1
  19. package/dist/engine/extension-names.d.ts +12 -1
  20. package/dist/engine/extension-names.js +7 -0
  21. package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
  22. package/dist/engine/feature-ast/extractors/index.js +1 -1
  23. package/dist/engine/feature-ast/extractors/round5.d.ts +1 -0
  24. package/dist/engine/feature-ast/extractors/round5.js +5 -0
  25. package/dist/engine/feature-ast/parse.js +3 -1
  26. package/dist/engine/feature-ui-extensions.d.ts +4 -2
  27. package/dist/engine/feature-ui-extensions.js +40 -3
  28. package/dist/engine/index.d.ts +4 -3
  29. package/dist/engine/index.js +2 -2
  30. package/dist/engine/project-app-schema-for-roles.js +20 -1
  31. package/dist/engine/registry-facade.js +3 -0
  32. package/dist/engine/registry-ingest.js +2 -0
  33. package/dist/engine/registry-state.d.ts +2 -1
  34. package/dist/engine/registry-state.js +1 -0
  35. package/dist/engine/role-assignment.d.ts +11 -1
  36. package/dist/engine/role-assignment.js +64 -3
  37. package/dist/engine/schema-builder.d.ts +3 -1
  38. package/dist/engine/schema-builder.js +5 -6
  39. package/dist/engine/types/index.d.ts +2 -2
  40. package/dist/errors/reasons.js +7 -6
  41. package/dist/i18n/required-surface-keys.js +19 -3
  42. package/dist/pipeline/dispatch-write.js +26 -2
  43. package/dist/pipeline/event-consumer-state.js +6 -0
  44. package/dist/pipeline/event-dispatcher-admin.js +36 -16
  45. package/dist/pipeline/event-dispatcher-delivery.d.ts +2 -0
  46. package/dist/pipeline/event-dispatcher-delivery.js +22 -2
  47. package/dist/pipeline/event-dispatcher.js +1 -0
  48. package/dist/ui-types/index.d.ts +1 -1
  49. package/package.json +4 -4
  50. package/src/changes.json +40 -0
@@ -28,7 +28,7 @@ export { declareEscapeHatch } from "./escape-hatch-declaration.js";
28
28
  export type { EmitCtx } from "./event-helpers.js";
29
29
  export { emitEvent, typedPayload } from "./event-helpers.js";
30
30
  export type { KumikoExtensionName, TenantResourceExtensionName } from "./extension-names.js";
31
- export { EXT_DERIVATIVE_OVERLAY_RESOLVER, EXT_DERIVATIVE_PUBLIC_PREDICATE, EXT_DERIVATIVE_RENDERER, EXT_EXTERNAL_RESOURCE, EXT_FILE_PROVIDER, EXT_INFRA_RESOURCE, EXT_PRINCIPAL_STATUS, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, EXT_TENANT_LIFECYCLE_STATUS, EXT_USER_DATA, EXT_USER_DATA_ORDER, FILE_PROVIDER_CONFIG_KEY, FILE_STORAGE_PROVIDER_BOOT_SENTINEL, FILE_STORAGE_PROVIDER_ENV, TENANT_MEMBERSHIPS_QUERY, } from "./extension-names.js";
31
+ export { EXT_ASSIGNABLE_ROLE, EXT_DERIVATIVE_OVERLAY_RESOLVER, EXT_DERIVATIVE_PUBLIC_PREDICATE, EXT_DERIVATIVE_RENDERER, EXT_EXTERNAL_RESOURCE, EXT_FILE_PROVIDER, EXT_INFRA_RESOURCE, EXT_PRINCIPAL_STATUS, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, EXT_TENANT_LIFECYCLE_STATUS, EXT_USER_DATA, EXT_USER_DATA_ORDER, FILE_PROVIDER_CONFIG_KEY, FILE_STORAGE_PROVIDER_BOOT_SENTINEL, FILE_STORAGE_PROVIDER_ENV, TENANT_MEMBERSHIPS_QUERY, } from "./extension-names.js";
32
32
  export { EXTENSION_SELECTOR_HINT_KEY, extensionSelectorTargets, SELECTED_EXTENSIONS_QUERY, selectablePluginIds, } from "./extension-selector-plugins.js";
33
33
  export { extensionUsageEscapeHatchReason } from "./extensions/escape-hatch-usage.js";
34
34
  export type { StorageProviderDestroyTenantHook, StorageProviderExtensionHooks, StorageProviderHookCtx, } from "./extensions/storage-provider.js";
@@ -57,7 +57,8 @@ export { readClaim } from "./read-claim.js";
57
57
  export { createRegistry } from "./registry.js";
58
58
  export type { ClampInfo, ResolveOptions } from "./resolve-config-or-param.js";
59
59
  export { resolveConfigOrParam } from "./resolve-config-or-param.js";
60
- export { findForbiddenRoleAssignment } from "./role-assignment.js";
60
+ export type { AssignableAppRoles, AssignableFromRole } from "./role-assignment.js";
61
+ export { assignableAppRolesFromUsages, findForbiddenRoleAssignment, isAssignableByRole, } from "./role-assignment.js";
61
62
  export { runsInLane } from "./run-in.js";
62
63
  export type { StepListOutcome } from "./run-pipeline.js";
63
64
  export { runPipeline, runStepList } from "./run-pipeline.js";
@@ -71,7 +72,7 @@ export { describeWorkflowStepError } from "./steps/describe-workflow-step-error.
71
72
  export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
72
73
  export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.js";
73
74
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
74
- export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DateFieldDef, DecimalFieldDef, DeleteContext, 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, NotifyFactory, NotifyFn, NotifyOptions, NotifyPriority, 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, RecordHeaderSpec, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, 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";
75
+ export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DateFieldDef, DecimalFieldDef, DeleteContext, 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, NotifyFactory, NotifyFn, NotifyOptions, NotifyPriority, 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, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, 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";
75
76
  export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, HookPhases, isAgentVisibleScreen, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
76
77
  export type { AwaitedEventType, PipelineBuildCtx, PipelineCtx, PipelineDef, StepBuilder, StepDef, StepFailureStrategy, StepInstance, StepKind, StepNamespace, StepResolver, } from "./types/step.js";
77
78
  export { runValidation } from "./validation.js";
@@ -26,7 +26,7 @@ export { createEntityExecutor, defineEntityCreateHandler, defineEntityDeleteHand
26
26
  defineEntityQueryHandler, defineEntityRestoreHandler, defineEntityUpdateHandler, defineEntityWriteHandler, defineProjectionQueryHandler, entityListSchema, MAX_LIST_LIMIT, registerEntityCrud, } from "./entity-handlers.js";
27
27
  export { declareEscapeHatch } from "./escape-hatch-declaration.js";
28
28
  export { emitEvent, typedPayload } from "./event-helpers.js";
29
- export { EXT_DERIVATIVE_OVERLAY_RESOLVER, EXT_DERIVATIVE_PUBLIC_PREDICATE, EXT_DERIVATIVE_RENDERER, EXT_EXTERNAL_RESOURCE, EXT_FILE_PROVIDER, EXT_INFRA_RESOURCE, EXT_PRINCIPAL_STATUS, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, EXT_TENANT_LIFECYCLE_STATUS, EXT_USER_DATA, EXT_USER_DATA_ORDER, FILE_PROVIDER_CONFIG_KEY, FILE_STORAGE_PROVIDER_BOOT_SENTINEL, FILE_STORAGE_PROVIDER_ENV, TENANT_MEMBERSHIPS_QUERY, } from "./extension-names.js";
29
+ export { EXT_ASSIGNABLE_ROLE, EXT_DERIVATIVE_OVERLAY_RESOLVER, EXT_DERIVATIVE_PUBLIC_PREDICATE, EXT_DERIVATIVE_RENDERER, EXT_EXTERNAL_RESOURCE, EXT_FILE_PROVIDER, EXT_INFRA_RESOURCE, EXT_PRINCIPAL_STATUS, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, EXT_TENANT_LIFECYCLE_STATUS, EXT_USER_DATA, EXT_USER_DATA_ORDER, FILE_PROVIDER_CONFIG_KEY, FILE_STORAGE_PROVIDER_BOOT_SENTINEL, FILE_STORAGE_PROVIDER_ENV, TENANT_MEMBERSHIPS_QUERY, } from "./extension-names.js";
30
30
  export { EXTENSION_SELECTOR_HINT_KEY, extensionSelectorTargets, SELECTED_EXTENSIONS_QUERY, selectablePluginIds, } from "./extension-selector-plugins.js";
31
31
  export { extensionUsageEscapeHatchReason } from "./extensions/escape-hatch-usage.js";
32
32
  export { isTenantDataExtensionHooks, } from "./extensions/tenant-data.js";
@@ -54,7 +54,7 @@ export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name.js";
54
54
  export { readClaim } from "./read-claim.js";
55
55
  export { createRegistry } from "./registry.js";
56
56
  export { resolveConfigOrParam } from "./resolve-config-or-param.js";
57
- export { findForbiddenRoleAssignment } from "./role-assignment.js";
57
+ export { assignableAppRolesFromUsages, findForbiddenRoleAssignment, isAssignableByRole, } from "./role-assignment.js";
58
58
  export { runsInLane } from "./run-in.js";
59
59
  export { runPipeline, runStepList } from "./run-pipeline.js";
60
60
  export { buildInsertSchema, buildUpdateSchema, fieldToZod } from "./schema-builder.js";
@@ -401,6 +401,22 @@ function projectMetrics(metrics, indices, keptScreenQns) {
401
401
  });
402
402
  return changed ? next : metrics;
403
403
  }
404
+ function projectHeader(header, indices, keptScreenQns) {
405
+ const subtitle = header?.subtitle;
406
+ if (header === undefined || typeof subtitle === "string" || subtitle === undefined) {
407
+ return header;
408
+ }
409
+ let changed = false;
410
+ const nextParts = subtitle.map((part) => {
411
+ if (typeof part === "string" || part.navigate === undefined)
412
+ return part;
413
+ if (keepMetricNavigate(part.navigate, indices, keptScreenQns))
414
+ return part;
415
+ changed = true;
416
+ return part.field;
417
+ });
418
+ return changed ? { ...header, subtitle: nextParts } : header;
419
+ }
404
420
  function projectDashboardPanels(panels, feature, keptScreenQns) {
405
421
  const kept = panels.filter((p) => p.kind !== "screen" || isScreenTargetKept(feature.featureName, p.screen, keptScreenQns));
406
422
  return kept.length === panels.length ? panels : kept;
@@ -451,12 +467,14 @@ function projectListScreen(original, feature, indices, keptScreenQns) {
451
467
  };
452
468
  }
453
469
  function projectProjectionDetailScreen(screen, feature, indices, keptScreenQns) {
454
- const { metrics, actions, layout, listScreenId, ...rest } = screen;
470
+ const { metrics, header, actions, layout, listScreenId, ...rest } = screen;
455
471
  const nextMetrics = projectMetrics(metrics, indices, keptScreenQns);
472
+ const nextHeader = projectHeader(header, indices, keptScreenQns);
456
473
  const nextActions = projectRowActionArray(actions, feature, indices, keptScreenQns);
457
474
  const nextLayout = projectEditLayout(layout, feature, indices, keptScreenQns);
458
475
  const nextListScreenId = projectListScreenId(listScreenId, indices, keptScreenQns);
459
476
  const unchanged = nextMetrics === metrics &&
477
+ nextHeader === header &&
460
478
  nextActions === actions &&
461
479
  nextLayout === layout &&
462
480
  nextListScreenId === listScreenId;
@@ -465,6 +483,7 @@ function projectProjectionDetailScreen(screen, feature, indices, keptScreenQns)
465
483
  return {
466
484
  ...rest,
467
485
  layout: nextLayout,
486
+ ...(nextHeader !== undefined && { header: nextHeader }),
468
487
  ...(nextMetrics !== undefined && { metrics: nextMetrics }),
469
488
  ...(nextActions !== undefined && { actions: nextActions }),
470
489
  ...(nextListScreenId !== undefined && { listScreenId: nextListScreenId }),
@@ -149,6 +149,9 @@ export function buildRegistryFacade(state) {
149
149
  getAllExtensionSelectors() {
150
150
  return state.extensionSelectorMap;
151
151
  },
152
+ getExtensionSelectorPanels(extensionName) {
153
+ return state.extensionSelectorPanelsMap.get(extensionName) ?? [];
154
+ },
152
155
  getAllNotifications() {
153
156
  return state.notificationMap;
154
157
  },
@@ -189,6 +189,8 @@ export function populateExtensionsAndSeeds(state, feature) {
189
189
  `(feature "${feature.name}") — one owning feature declares the selector.`);
190
190
  }
191
191
  state.extensionSelectorMap.set(sel.extensionName, sel.qualifiedKey);
192
+ if (sel.panels !== undefined)
193
+ state.extensionSelectorPanelsMap.set(sel.extensionName, sel.panels);
192
194
  }
193
195
  state.allReferenceData.push(...(feature.referenceData ?? []));
194
196
  state.allConfigSeeds.push(...(feature.configSeeds ?? []));
@@ -1,5 +1,5 @@
1
1
  import { type QnType, qualifyEntityName } from "./qualified-name.js";
2
- import type { AuthClaimsHookDef, ClaimKeyDefinition, ConfigKeyDefinition, ConfigSeedDef, EntityDefinition, EntityProjectionExtension, EventDef, EventUpcastFn, FeatureDefinition, FeatureMetricDef, HookPhase, JobDefinition, MultiStreamProjectionDefinition, NavDefinition, NotificationDefinition, OwnedFn, PhasedHook, PostDeleteHookFn, PostQueryHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, QueryHandlerDef, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionRegistration, RelationDefinition, ScreenDefinition, SearchPayloadContributorFn, SecretKeyDefinition, StoreTableDef, StreamHandlerDef, TranslationValue, TreeActionDef, WorkspaceDefinition, WriteHandlerDef } from "./types/index.js";
2
+ import type { AuthClaimsHookDef, ClaimKeyDefinition, ConfigKeyDefinition, ConfigSeedDef, EntityDefinition, EntityProjectionExtension, EventDef, EventUpcastFn, ExtensionSelectorPanel, FeatureDefinition, FeatureMetricDef, HookPhase, JobDefinition, MultiStreamProjectionDefinition, NavDefinition, NotificationDefinition, OwnedFn, PhasedHook, PostDeleteHookFn, PostQueryHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, QueryHandlerDef, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionRegistration, RelationDefinition, ScreenDefinition, SearchPayloadContributorFn, SecretKeyDefinition, StoreTableDef, StreamHandlerDef, TranslationValue, TreeActionDef, WorkspaceDefinition, WriteHandlerDef } from "./types/index.js";
3
3
  export type IncomingRelation = {
4
4
  sourceEntity: string;
5
5
  relationName: string;
@@ -46,6 +46,7 @@ export type RegistryState = {
46
46
  extensionMap: Map<string, RegistrarExtensionDef>;
47
47
  extensionUsages: RegistrarExtensionRegistration[];
48
48
  extensionSelectorMap: Map<string, string>;
49
+ extensionSelectorPanelsMap: Map<string, readonly ExtensionSelectorPanel[]>;
49
50
  allReferenceData: ReferenceDataDef[];
50
51
  allConfigSeeds: ConfigSeedDef[];
51
52
  mergedTranslations: Record<string, Record<string, TranslationValue>>;
@@ -116,6 +116,7 @@ export function createInitialState() {
116
116
  extensionMap: new Map(),
117
117
  extensionUsages: [],
118
118
  extensionSelectorMap: new Map(),
119
+ extensionSelectorPanelsMap: new Map(),
119
120
  allReferenceData: [],
120
121
  allConfigSeeds: [],
121
122
  mergedTranslations: {},
@@ -1 +1,11 @@
1
- export declare function findForbiddenRoleAssignment(actorRoles: readonly string[], assignedRoles: readonly string[], targetCurrentRoles: readonly string[]): string | undefined;
1
+ export type AssignableFromRole = "User" | "Member" | "Editor" | "Admin" | "TenantAdmin" | "SystemAdmin";
2
+ /** App role → lowest built-in role allowed to assign it. */
3
+ export type AssignableAppRoles = ReadonlyMap<string, AssignableFromRole>;
4
+ export declare const DEFAULT_APP_ROLE_ASSIGNABLE_FROM: AssignableFromRole;
5
+ export declare function assignableAppRolesFromUsages(usages: readonly {
6
+ entityName: string;
7
+ options?: Record<string, unknown> | undefined;
8
+ }[]): AssignableAppRoles;
9
+ /** True when `actorRole` ranks at least as high as the role an app role is assignable from. */
10
+ export declare function isAssignableByRole(assignableFrom: AssignableFromRole, actorRole: AssignableFromRole): boolean;
11
+ export declare function findForbiddenRoleAssignment(actorRoles: readonly string[], assignedRoles: readonly string[], targetCurrentRoles: readonly string[], assignableAppRoles?: AssignableAppRoles): string | undefined;
@@ -1,8 +1,13 @@
1
+ import { ROLES } from "../auth/roles.js";
2
+ import { isForbiddenMembershipRole } from "./membership-roles.js";
3
+ const PLATFORM_ROLE_NAMES = new Set(Object.values(ROLES));
1
4
  // Prototype-free rank table — Object literals leak Object.prototype
2
5
  // (`constructor`, `toString`, …) into `ROLE_RANKS[role]` lookups and turn
3
6
  // Math.max into NaN, which fails every `rank > actorRank` check open.
4
7
  const ROLE_RANKS = new Map([
5
8
  ["User", 0],
9
+ // Default seed role — unranked it would fail closed and never be assignable.
10
+ ["Member", 0],
6
11
  // Matches DEFAULT_INVITE_ROLE_OPTIONS — must stay ranked or invite UI fails closed.
7
12
  ["Editor", 1],
8
13
  ["Admin", 2],
@@ -10,7 +15,55 @@ const ROLE_RANKS = new Map([
10
15
  ["SystemAdmin", 4],
11
16
  ["system", 5],
12
17
  ]);
13
- // Unknown roles: +∞ on the assigned path (cannot grant what we don't know),
18
+ // App roles are ordinary, non-privileged tiers: whoever may manage members
19
+ // (access.admin) may grant them; an app raises the bar via assignableFrom.
20
+ export const DEFAULT_APP_ROLE_ASSIGNABLE_FROM = "Admin";
21
+ const NO_ASSIGNABLE_APP_ROLES = new Map();
22
+ const ASSIGNABLE_FROM_ROLES = new Set([
23
+ "User",
24
+ "Member",
25
+ "Editor",
26
+ "Admin",
27
+ "TenantAdmin",
28
+ "SystemAdmin",
29
+ ]);
30
+ function isAssignableFromRole(value) {
31
+ return typeof value === "string" && ASSIGNABLE_FROM_ROLES.has(value);
32
+ }
33
+ export function assignableAppRolesFromUsages(usages) {
34
+ const result = new Map();
35
+ for (const usage of usages) {
36
+ const role = usage.entityName;
37
+ if (role === "") {
38
+ throw new Error("assignableRole: role name must not be empty");
39
+ }
40
+ // Platform roles (TenantOwner, DataProtectionOfficer) gate handlers that
41
+ // TenantAdmin/Admin cannot reach — declaring one would let an Admin
42
+ // self-grant it, so they stay out of reach of app declarations.
43
+ if (ROLE_RANKS.has(role) || PLATFORM_ROLE_NAMES.has(role)) {
44
+ throw new Error(`assignableRole: "${role}" is a built-in role and cannot be redeclared`);
45
+ }
46
+ if (isForbiddenMembershipRole(role)) {
47
+ throw new Error(`assignableRole: "${role}" is a reserved role and cannot be a membership role`);
48
+ }
49
+ const declaredAssignableFrom = usage.options?.["assignableFrom"];
50
+ if (declaredAssignableFrom !== undefined && !isAssignableFromRole(declaredAssignableFrom)) {
51
+ throw new Error(`assignableRole: "${role}" has invalid assignableFrom, expected one of ${[...ASSIGNABLE_FROM_ROLES].join(", ")}`);
52
+ }
53
+ const assignableFrom = declaredAssignableFrom ?? DEFAULT_APP_ROLE_ASSIGNABLE_FROM;
54
+ const existing = result.get(role);
55
+ if (existing !== undefined && existing !== assignableFrom) {
56
+ throw new Error(`assignableRole: "${role}" is declared with conflicting assignableFrom (${existing} vs ${assignableFrom})`);
57
+ }
58
+ result.set(role, assignableFrom);
59
+ }
60
+ return result;
61
+ }
62
+ /** True when `actorRole` ranks at least as high as the role an app role is assignable from. */
63
+ export function isAssignableByRole(assignableFrom, actorRole) {
64
+ return getRoleRank(assignableFrom) <= getRoleRank(actorRole);
65
+ }
66
+ // Unknown roles: +∞ on the assigned path (cannot grant what we don't know, unless declared),
14
67
  // -1 on the actor path (cannot elevate via an unrecognized self-role).
15
68
  function roleRankOr(role, unknownRank) {
16
69
  return ROLE_RANKS.get(role) ?? unknownRank;
@@ -27,15 +80,23 @@ function maxRoleRank(roles) {
27
80
  function knownRoleRank(role) {
28
81
  return ROLE_RANKS.get(role);
29
82
  }
83
+ // Built-ins win: a declaration can never re-tier a built-in role.
84
+ function requiredAssignRank(role, assignableAppRoles) {
85
+ const builtIn = ROLE_RANKS.get(role);
86
+ if (builtIn !== undefined)
87
+ return builtIn;
88
+ const assignableFrom = assignableAppRoles.get(role);
89
+ return assignableFrom === undefined ? Number.POSITIVE_INFINITY : getRoleRank(assignableFrom);
90
+ }
30
91
  export function findForbiddenRoleAssignment(actorRoles, assignedRoles,
31
92
  // Required so callers cannot accidentally disable the downgrade/
32
93
  // takeover guard by omitting the third argument (new users: pass []).
33
- targetCurrentRoles) {
94
+ targetCurrentRoles, assignableAppRoles = NO_ASSIGNABLE_APP_ROLES) {
34
95
  const actorRank = maxRoleRank(actorRoles);
35
96
  // Assign path: fail-closed on unknown / above-actor roles — except unranked
36
97
  // app roles the target already holds (round-trip restore after strip).
37
98
  const forbiddenAssigned = assignedRoles.find((role) => {
38
- if (getRoleRank(role) <= actorRank)
99
+ if (requiredAssignRank(role, assignableAppRoles) <= actorRank)
39
100
  return false;
40
101
  if (knownRoleRank(role) === undefined && targetCurrentRoles.includes(role))
41
102
  return false;
@@ -4,5 +4,7 @@ export declare function isRepresentableAtScale(n: number, scale: number): boolea
4
4
  export declare function fieldToZod(field: FieldDefinition, currencies: readonly string[], opts?: {
5
5
  readonly applyDefaults?: boolean;
6
6
  }): z.ZodTypeAny;
7
- export declare function buildInsertSchema(entity: EntityDefinition, currencies?: readonly string[], excludedFields?: readonly string[]): z.ZodObject<Record<string, z.ZodTypeAny>>;
7
+ export declare function buildInsertSchema(entity: EntityDefinition, currencies?: readonly string[], excludedFields?: readonly string[], options?: {
8
+ readonly allowCallerId?: boolean;
9
+ }): z.ZodObject<Record<string, z.ZodTypeAny>>;
8
10
  export declare function buildUpdateSchema(entity: EntityDefinition, currencies?: readonly string[], excludedFields?: readonly string[]): z.ZodObject<Record<string, z.ZodTypeAny>>;
@@ -366,7 +366,7 @@ function applyTotalsMatchRefinements(entity, schema) {
366
366
  function notWritableField() {
367
367
  return z.never().optional();
368
368
  }
369
- export function buildInsertSchema(entity, currencies = [...DEFAULT_CURRENCIES], excludedFields = []) {
369
+ export function buildInsertSchema(entity, currencies = [...DEFAULT_CURRENCIES], excludedFields = [], options = {}) {
370
370
  const shape = {};
371
371
  for (const [name, field] of Object.entries(entity.fields)) {
372
372
  if (excludedFields.includes(name)) {
@@ -378,11 +378,10 @@ export function buildInsertSchema(entity, currencies = [...DEFAULT_CURRENCIES],
378
378
  const isRequired = "required" in field && field.required === true;
379
379
  shape[name] = isRequired || hasDefault ? zodField : zodField.optional();
380
380
  }
381
- // Caller-chosen aggregate id for idempotent creates (deterministic-id
382
- // patterns). Accepting it here only lets it survive parsing — the create
383
- // handler still honors it only for a system-identity caller (jobs/hooks
384
- // via createSystemUser), never for an ordinary HTTP-authenticated user.
385
- shape["id"] = z.uuid().optional();
381
+ // Opt-in only for the generic create verb, which honors the id for system identities;
382
+ // other schema consumers must keep stripping an unknown `id`.
383
+ if (options.allowCallerId)
384
+ shape["id"] = z.uuid().optional();
386
385
  return applyTotalsMatchRefinements(entity, z.object(shape));
387
386
  }
388
387
  export function buildUpdateSchema(entity, currencies = [...DEFAULT_CURRENCIES], excludedFields = []) {
@@ -1,5 +1,5 @@
1
1
  export { isAgentVisibleScreen, resolveAgentExposure, } from "@cosmicdrift/kumiko-types/agent-exposure";
2
- export type { ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigBounds, ConfigCascade, ConfigCascadeLevel, ConfigComputedContext, ConfigComputedFn, ConfigDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValue, ConfigValueSource, ConfigValueWithSource, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, ExtensionSelectorDef, JobBackoff, JobBackoffStrategy, JobDefinition, JobHandlerFn, JobRunIn, JobTrigger, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, PluralForms, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionHooks, RegistrarExtensionRegistration, RunIn, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, UiExtensionDef, } from "@cosmicdrift/kumiko-types/config";
2
+ export type { ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigBounds, ConfigCascade, ConfigCascadeLevel, ConfigComputedContext, ConfigComputedFn, ConfigDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValue, ConfigValueSource, ConfigValueWithSource, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, ExtensionSelectorDef, ExtensionSelectorPanel, JobBackoff, JobBackoffStrategy, JobDefinition, JobHandlerFn, JobRunIn, JobTrigger, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, PluralForms, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionHooks, RegistrarExtensionRegistration, RunIn, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, UiExtensionDef, } from "@cosmicdrift/kumiko-types/config";
3
3
  export type { QueryHandlerDefinition, StreamHandlerDefinition, WriteHandlerDefinition, WriteHandlerInput, } from "@cosmicdrift/kumiko-types/define-handler";
4
4
  export type { EntityCrudRegistrar, EntityCrudVerb, EntityHandlerOptions, EntityQueryHandlerOptions, EntityWriteHandlerOptions, RegisterEntityCrudOptions, } from "@cosmicdrift/kumiko-types/entity-handlers";
5
5
  export type { KumikoEntityTypeMap, KumikoEventTypeMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, } from "@cosmicdrift/kumiko-types/event-type-map";
@@ -17,7 +17,7 @@ export type { ContentCollectionDefinition, NavDefinition, NavIconKey, } from "@c
17
17
  export type { FromRule, FromRuleKind, OwnershipClause, OwnershipMap, OwnershipRef, OwnershipRule, SqlFragment, WhereRule, WhereRuleContext, } from "@cosmicdrift/kumiko-types/ownership";
18
18
  export type { EntityProjectionExtension, MspErrorMode, MspErrorPolicy, MultiStreamApplyFn, MultiStreamProjectionDefinition, ProjectionDefinition, ProjectionTable, SingleStreamApplyFn, } from "@cosmicdrift/kumiko-types/projection";
19
19
  export type { BelongsToRelation, EntityRelations, HasManyRelation, ManyToManyRelation, RelationDefinition, } from "@cosmicdrift/kumiko-types/relations";
20
- export type { ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, EntityListExpandableRow, EntityListScreenDefinition, FieldCondition, FieldFormatRegistry, FieldRenderer, FormatSpec, FormWidth, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSpec, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, UnitKey, } from "@cosmicdrift/kumiko-types/screen";
20
+ export type { ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, EntityListExpandableRow, EntityListScreenDefinition, FieldCondition, FieldFormatRegistry, FieldRenderer, FormatSpec, FormWidth, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSpec, RecordHeaderSubtitlePart, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, UnitKey, } from "@cosmicdrift/kumiko-types/screen";
21
21
  export { metricField } from "@cosmicdrift/kumiko-types/screen";
22
22
  export type { TargetRef } from "@cosmicdrift/kumiko-types/target-ref";
23
23
  export type { Subscribe, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, } from "@cosmicdrift/kumiko-types/tree-node";
@@ -66,16 +66,17 @@ export const FrameworkReasons = {
66
66
  // sister catalog to FrameworkReasons instead of extending it — see the
67
67
  // file-header note above for why these carry the `agent.` prefix.
68
68
  export const AgentReasons = {
69
- // AccessDeniedError: the agent called a tool name outside its allowed
70
- // catalog for this run.
69
+ // Error turn in the agent loop when the model requests a tool outside the
70
+ // run's catalog; UnprocessableError in rule validation (handler missing from
71
+ // the caller's role-filtered manifest, or `ai-agent-edit` not mounted).
71
72
  toolNotAllowed: "agent.tool_not_allowed",
72
- // UnprocessableError: the agent loop hit its maximum round count before
73
- // producing a final answer.
73
+ // Not thrown: the run ends with a `clarify` turn (`payload.reason`) once
74
+ // MAX_AGENT_ROUNDS is reached.
74
75
  iterationLimit: "agent.iteration_limit",
75
76
  // AccessDeniedError: the tool exists, but the caller's roles don't cover
76
77
  // the handler behind it.
77
78
  permissionDenied: "agent.permission_denied",
78
- // AccessDeniedError: an `agent.risk: "high"` handler was invoked without
79
- // the required explicit confirmation.
79
+ // UnprocessableError: a permission rule with `always` was requested for an
80
+ // `agent: { risk: "high" }` handler; `details.handlerQn` names it.
80
81
  highRiskNoAlways: "agent.high_risk_no_always",
81
82
  };
@@ -42,6 +42,7 @@ function editFieldName(f) {
42
42
  // Group titles are translated exactly like the section title (computeEditViewModel).
43
43
  function pushSectionTitles(out, section, treatAsKey = false) {
44
44
  pushKey(out, section.title, treatAsKey);
45
+ pushKey(out, section.subtitle, treatAsKey);
45
46
  for (const group of section.groups ?? [])
46
47
  pushKey(out, group.title, treatAsKey);
47
48
  }
@@ -51,6 +52,10 @@ function pushRowActionKeys(out, action) {
51
52
  pushKey(out, action.confirm);
52
53
  pushKey(out, action.confirmLabel);
53
54
  }
55
+ if (action.kind === "drawer") {
56
+ pushKey(out, action.title);
57
+ pushKey(out, action.subtitle);
58
+ }
54
59
  }
55
60
  function pushDashboardScreenKeys(out, dashboard) {
56
61
  for (const panel of dashboard.panels)
@@ -103,9 +108,12 @@ function pushToolbarActionKeys(out, action) {
103
108
  pushKey(out, action.confirm);
104
109
  pushKey(out, action.confirmLabel);
105
110
  }
106
- // drawer-kind carries no confirm/confirmLabel — action.label (the
107
- // toolbar button AND the Drawer title, see ToolbarDrawerHost) is
108
- // already pushed above.
111
+ // drawer-kind carries no confirm/confirmLabel — action.label is the
112
+ // toolbar button and the Drawer title unless `title` overrides it.
113
+ if (action.kind === "drawer") {
114
+ pushKey(out, action.title);
115
+ pushKey(out, action.subtitle);
116
+ }
109
117
  }
110
118
  function pushRelatedListSectionKeys(out, section) {
111
119
  pushKey(out, section.title);
@@ -180,6 +188,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
180
188
  case "entityEdit": {
181
189
  const edit = screen;
182
190
  pushKey(out, edit.submitLabel);
191
+ pushKey(out, edit.titleTemplate);
183
192
  for (const section of edit.layout.sections) {
184
193
  if (isExtensionEditSection(section)) {
185
194
  pushKey(out, section.title);
@@ -204,6 +213,8 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
204
213
  pushKey(out, form.submitLabel);
205
214
  pushKey(out, form.summary?.title);
206
215
  pushKey(out, form.summary?.subtitle);
216
+ for (const action of form.footerActions ?? [])
217
+ pushKey(out, action.label);
207
218
  for (const fieldName of Object.keys(form.fields)) {
208
219
  const override = form.fieldLabels?.[fieldName];
209
220
  if (override !== undefined)
@@ -328,6 +339,11 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
328
339
  }
329
340
  if (section.kind === "relatedList") {
330
341
  pushRelatedListSectionKeys(out, section);
342
+ if (section.groupBy !== undefined) {
343
+ pushKey(out, section.groupBy.label);
344
+ for (const label of Object.values(section.groupBy.labels ?? {}))
345
+ pushKey(out, label);
346
+ }
331
347
  continue;
332
348
  }
333
349
  if (isWriteFormEditSection(section)) {
@@ -1,6 +1,7 @@
1
1
  import { selectRowForUpdateById } from "../db/queries/entity-read.js";
2
2
  import { asEntityTableMeta, selectMany } from "../db/query.js";
3
3
  import { buildEntityTable, toSnakeCase } from "../db/table-builder.js";
4
+ import { createTenantDb, hasTenantColumn } from "../db/tenant-db.js";
4
5
  import { tenantDbRunner } from "../db/tenant-db-runner.js";
5
6
  import { hasAccess } from "../engine/access.js";
6
7
  import { ConfigScopes } from "../engine/constants.js";
@@ -126,6 +127,24 @@ function isForeignTenantParentRow(parentRow, user) {
126
127
  const rowTenantId = parentRow["tenantId"];
127
128
  return typeof rowTenantId === "string" && rowTenantId !== user.tenantId;
128
129
  }
130
+ // A custom create handler may return a row without a string tenantId; the row's
131
+ // tenant is then unknown from the payload, so re-read it through the caller's tenant
132
+ // filter. Fail-closed: anything that cannot be verified counts as not visible.
133
+ // Tenant reads also return SYSTEM reference rows, so the row's own tenantId must match
134
+ // (same rule as the string path) unless the entity has no tenant column.
135
+ async function isParentRowHiddenFromCaller(ctx, parentEntityName, parentId, user, tx) {
136
+ if (parentEntityName === undefined)
137
+ return true;
138
+ const table = getTable(ctx, parentEntityName);
139
+ const source = resolveDbSource(ctx, tx);
140
+ if (!table || !source)
141
+ return true;
142
+ const callerDb = createTenantDb(source, user.tenantId, "tenant");
143
+ const visibleRow = await callerDb.fetchOne(table, { id: parentId });
144
+ if (visibleRow === undefined)
145
+ return true;
146
+ return hasTenantColumn(table) && visibleRow["tenantId"] !== user.tenantId;
147
+ }
129
148
  // Nested-write orchestration (v1: depth=1, create-only, hasMany-only).
130
149
  //
131
150
  // When a parent `:create` handler's payload carries values under keys
@@ -199,13 +218,18 @@ export async function executeNestedWrite(ctx, type, payload, user, origin, tx, a
199
218
  }));
200
219
  }
201
220
  // A custom create handler may return an existing foreign row; verify it before attaching children.
202
- if (!registry.isHandlerSystemScoped(type) && isForeignTenantParentRow(parentRow, user)) {
221
+ const parentEntityName = registry.getHandlerEntity(type);
222
+ const isCallerScoped = !registry.isHandlerSystemScoped(type);
223
+ const isParentForeign = isCallerScoped &&
224
+ (typeof parentRow["tenantId"] === "string"
225
+ ? isForeignTenantParentRow(parentRow, user)
226
+ : await isParentRowHiddenFromCaller(ctx, parentEntityName, parentId, user, tx));
227
+ if (isParentForeign) {
203
228
  return writeFailure(new AccessDeniedError({
204
229
  message: `nested-write: parent row belongs to another tenant — refusing to attach children to "${type}"`,
205
230
  details: { reason: FrameworkReasons.fieldAccessDenied, handler: type },
206
231
  }));
207
232
  }
208
- const parentEntityName = registry.getHandlerEntity(type);
209
233
  if (parentEntityName) {
210
234
  const parentEntity = registry.getEntity(parentEntityName);
211
235
  if (parentEntity) {
@@ -29,6 +29,10 @@ export const eventConsumerStateTable = pgTable("kumiko_event_consumers", {
29
29
  // skipPoisonEvent() — an operator vouching the consumer is healthy.
30
30
  rearmCount: integer("rearm_count").notNull().default(0),
31
31
  pendingGaps: jsonb("pending_gaps").$type().default([]).notNull(),
32
+ // Id of the event whose handler failed last (halt-on-poison). skipPoisonEvent
33
+ // skips exactly this id; guessing the smallest visible id would drop a healthy
34
+ // writer that committed late into a pending gap. Null once a delivery succeeds.
35
+ lastFailedEventId: bigint("last_failed_event_id", { mode: "bigint" }),
32
36
  lastError: text("last_error"),
33
37
  updatedAt: instant("updated_at", { precision: 3 }).notNull().default(sql `now()`),
34
38
  }, (t) => ({
@@ -78,6 +82,8 @@ export async function createEventConsumerStateTable(db) {
78
82
  /* ifNotExists */ true);
79
83
  await alterTableAddColumn(db, "kumiko_event_consumers", "pending_gaps", "jsonb", " DEFAULT '[]'::jsonb", " NOT NULL",
80
84
  /* ifNotExists */ true);
85
+ await alterTableAddColumn(db, "kumiko_event_consumers", "last_failed_event_id", "bigint", "", "",
86
+ /* ifNotExists */ true);
81
87
  // Runs on every boot, including mid-rolling-deploy: a new pod can delete
82
88
  // an old pod's still-in-use per-instance row out from under it. That old
83
89
  // pod's next acquireConsumer then finds no row, delivers no more SSE/
@@ -1,8 +1,8 @@
1
- import { advanceConsumerPastEventReturning, removePendingGapReturning, updateConsumerStatusReturning, } from "../db/queries/event-consumer.js";
1
+ import { advanceConsumerPastEventReturning, removePendingGapReturning, selectSnapshotXmax, updateConsumerStatusReturning, } from "../db/queries/event-consumer.js";
2
2
  import { selectNextEventIdAfter, selectSmallestVisibleIdInRanges, } from "../db/queries/event-store.js";
3
3
  import { coerceRow, extractTableInfo, selectMany } from "../db/query.js";
4
4
  import { getEventsHighWaterMark } from "../event-store/index.js";
5
- import { eventConsumerStateTable, SHARED_INSTANCE_SENTINEL } from "./event-consumer-state.js";
5
+ import { eventConsumerStateTable, SHARED_INSTANCE_SENTINEL, } from "./event-consumer-state.js";
6
6
  import { rangeContainsId, splitRangeExcludingIds, toIdRanges } from "./pending-gap-ranges.js";
7
7
  // --- Ops recovery surface ---
8
8
  //
@@ -76,29 +76,38 @@ export async function enableConsumer(db, name, instanceId = SHARED_INSTANCE_SENT
76
76
  }
77
77
  return applyConsumerStatusTransition(db, name, instanceId, "idle");
78
78
  }
79
- // skipPoisonEvent advances past whatever is currently blocking the consumer.
80
- // If the smallest visible id inside pending_gaps exists, THAT'S the poison —
81
- // halt-on-poison walks gap ids before any id past the cursor — so it's split
82
- // out of its range directly; the cursor stays put (it's already above this
83
- // id). Otherwise the poison is the usual next event after the cursor. Single
84
- // TX so concurrent dispatcher passes can't double-advance. Neither exists →
85
- // idempotent no-op.
79
+ // skipPoisonEvent skips the event whose handler failed last
80
+ // (last_failed_event_id, set by the delivery's halt-on-poison branch): inside
81
+ // pending_gaps it is split out of its range, otherwise the cursor moves onto
82
+ // it. Without a recorded id (consumer parked before that column existed) it
83
+ // falls back to a guess: the smallest visible pending-gap id, else the next
84
+ // event after the cursor. Single TX so concurrent dispatcher passes can't
85
+ // double-advance. Nothing to skip → idempotent no-op.
86
86
  export async function skipPoisonEvent(db, name, instanceId = SHARED_INSTANCE_SENTINEL) {
87
87
  const before = await requireConsumerRow(db, name, instanceId);
88
88
  return db.begin(async (tx) => {
89
89
  const pendingGaps = before.pendingGaps;
90
- const smallestVisiblePending = await selectSmallestVisibleIdInRanges(tx, toIdRanges(pendingGaps));
91
- if (smallestVisiblePending !== null) {
92
- const newPendingGaps = pendingGaps.flatMap((gap) => rangeContainsId(gap, smallestVisiblePending)
93
- ? splitRangeExcludingIds(gap, [smallestVisiblePending])
90
+ const failedEventId = before.lastFailedEventId;
91
+ const failedIsPending = failedEventId !== null && pendingGaps.some((gap) => rangeContainsId(gap, failedEventId));
92
+ const failedIsAboveCursor = failedEventId !== null && failedEventId > before.lastProcessedEventId;
93
+ const pendingPoisonId = failedIsPending
94
+ ? failedEventId
95
+ : failedIsAboveCursor
96
+ ? null
97
+ : await selectSmallestVisibleIdInRanges(tx, toIdRanges(pendingGaps));
98
+ if (pendingPoisonId !== null) {
99
+ const newPendingGaps = pendingGaps.flatMap((gap) => rangeContainsId(gap, pendingPoisonId)
100
+ ? splitRangeExcludingIds(gap, [pendingPoisonId])
94
101
  : [gap]);
95
102
  const raw = await removePendingGapReturning(tx, name, instanceId, newPendingGaps);
96
103
  const updated = raw && coerceRow(raw, extractTableInfo(eventConsumerStateTable));
97
104
  if (!updated)
98
105
  throw new Error(`Consumer "${name}" (instance_id="${instanceId}") vanished mid-skip — retry.`);
99
- return { ...normalizeConsumerState(updated), skippedEventId: smallestVisiblePending };
106
+ return { ...normalizeConsumerState(updated), skippedEventId: pendingPoisonId };
100
107
  }
101
- const poisonId = await selectNextEventIdAfter(tx, before.lastProcessedEventId);
108
+ const poisonId = failedIsAboveCursor
109
+ ? failedEventId
110
+ : await selectNextEventIdAfter(tx, before.lastProcessedEventId);
102
111
  if (poisonId === null) {
103
112
  const [unchanged] = await selectMany(tx, eventConsumerStateTable, {
104
113
  name,
@@ -108,7 +117,18 @@ export async function skipPoisonEvent(db, name, instanceId = SHARED_INSTANCE_SEN
108
117
  throw new Error(`Consumer "${name}" (instance_id="${instanceId}") vanished — retry.`);
109
118
  return { ...normalizeConsumerState(unchanged), skippedEventId: null };
110
119
  }
111
- const raw = await advanceConsumerPastEventReturning(tx, name, instanceId, poisonId);
120
+ // Delivery stops at the poison event, so ids between the cursor and it were
121
+ // invisible during that pass (in-flight commits). Jumping the cursor over them
122
+ // would lose them; keep them as a pending gap like the dispatcher's own gaps.
123
+ const skippedWindowStart = before.lastProcessedEventId + 1n;
124
+ const gapToAdd = poisonId > skippedWindowStart
125
+ ? {
126
+ from: skippedWindowStart.toString(),
127
+ to: (poisonId - 1n).toString(),
128
+ xmax: await selectSnapshotXmax(tx),
129
+ }
130
+ : null;
131
+ const raw = await advanceConsumerPastEventReturning(tx, name, instanceId, poisonId, gapToAdd);
112
132
  const updated = raw && coerceRow(raw, extractTableInfo(eventConsumerStateTable));
113
133
  if (!updated)
114
134
  throw new Error(`Consumer "${name}" (instance_id="${instanceId}") vanished mid-skip — retry.`);
@@ -13,6 +13,7 @@ export type ConsumerStateRowShape = {
13
13
  readonly attempts: number;
14
14
  readonly rearmCount: number;
15
15
  readonly pendingGaps: readonly PendingGapEntry[];
16
+ readonly lastFailedEventId: bigint | null;
16
17
  readonly lastError: string | null;
17
18
  readonly updatedAt: Temporal.Instant;
18
19
  };
@@ -58,6 +59,7 @@ export type DeliveryOutcome = {
58
59
  readonly deadLettered: boolean;
59
60
  readonly processed: number;
60
61
  readonly failed: number;
62
+ readonly failedEventId: bigint | null;
61
63
  readonly resolvedPendingIds: readonly bigint[];
62
64
  };
63
65
  export declare function deliverEvents(consumer: EventConsumer, events: ReadonlyArray<StoredEventRow>, context: AppContext, maxAttempts: number, state: ConsumerStateRow): Promise<DeliveryOutcome>;