@cosmicdrift/kumiko-framework 0.354.1 → 0.356.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 (40) hide show
  1. package/dist/db/entity-field-encryption.d.ts +10 -1
  2. package/dist/db/entity-field-encryption.js +31 -2
  3. package/dist/db/event-store-executor-context.d.ts +9 -2
  4. package/dist/db/event-store-executor-context.js +45 -9
  5. package/dist/db/event-store-executor-read.js +7 -4
  6. package/dist/db/event-store-executor-write.js +105 -61
  7. package/dist/engine/build-app-schema.js +32 -5
  8. package/dist/engine/cross-tenant.d.ts +2 -1
  9. package/dist/engine/cross-tenant.js +18 -14
  10. package/dist/engine/entity-handlers.js +17 -16
  11. package/dist/engine/factories.d.ts +2 -0
  12. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  13. package/dist/engine/feature-entity-handlers.js +1 -0
  14. package/dist/engine/index.d.ts +2 -2
  15. package/dist/engine/index.js +1 -1
  16. package/dist/engine/registry-validate.d.ts +7 -1
  17. package/dist/engine/registry-validate.js +82 -2
  18. package/dist/engine/registry.js +2 -1
  19. package/dist/engine/types/index.d.ts +3 -3
  20. package/dist/errors/reasons.d.ts +1 -0
  21. package/dist/errors/reasons.js +2 -0
  22. package/dist/event-store/snapshot.d.ts +1 -0
  23. package/dist/event-store/snapshot.js +12 -3
  24. package/dist/jobs/job-runner.js +31 -17
  25. package/dist/observability/escape-hatch-report.d.ts +5 -2
  26. package/dist/observability/escape-hatch-report.js +52 -22
  27. package/dist/observability/index.d.ts +1 -1
  28. package/dist/observability/index.js +1 -1
  29. package/dist/pipeline/dispatch-query.js +4 -0
  30. package/dist/pipeline/dispatch-shared.js +62 -10
  31. package/dist/pipeline/dispatch-stream.js +4 -0
  32. package/dist/pipeline/dispatcher-utils.d.ts +4 -0
  33. package/dist/pipeline/dispatcher-utils.js +10 -0
  34. package/dist/pipeline/dispatcher.js +7 -7
  35. package/dist/pipeline/index.d.ts +1 -1
  36. package/dist/pipeline/index.js +1 -1
  37. package/dist/pipeline/pre-handler-gates.js +4 -0
  38. package/dist/ui-types/index.d.ts +1 -1
  39. package/package.json +4 -4
  40. package/src/changes.json +42 -0
@@ -14,7 +14,7 @@ export type { SystemHookName } from "./constants.js";
14
14
  export { ConcurrencyModes, ConfigScopes, LifecycleHookTypes, MessageKind, OnDeleteStrategies, SystemHookNames, SystemHookPriorities, TENANT_CURRENCY_CONFIG_KEY, tenantChannel, } from "./constants.js";
15
15
  export type { App, AppConfig } from "./create-app.js";
16
16
  export { createApp } from "./create-app.js";
17
- export { crossTenantOverrideDenied } from "./cross-tenant.js";
17
+ export { mayOverrideTenant, tenantOverrideDenied } from "./cross-tenant.js";
18
18
  export { dedupeFeatures } from "./dedupe-features.js";
19
19
  export { defineFeature } from "./define-feature.js";
20
20
  export type { PagedQueryHandlerDefinition, QueryHandlerDefinition, StreamHandlerDefinition, WriteHandlerDefinition, WriteHandlerInput, } from "./define-handler.js";
@@ -80,7 +80,7 @@ export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUse
80
80
  export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.js";
81
81
  export { findOverDeepTransferChain, resolveTransferAdjacency as resolveEntityTransferAdjacency, type TransferAdjacency, type TransferEdge, type TransferEdgeLink, } from "./transfer-adjacency.js";
82
82
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
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";
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, EntityEditAction, EntityEditActionPlacement, 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, TenantWriteTarget, 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";
84
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";
85
85
  export type { AwaitedEventType, PipelineBuildCtx, PipelineCtx, PipelineDef, StepBuilder, StepDef, StepFailureStrategy, StepInstance, StepKind, StepNamespace, StepResolver, } from "./types/step.js";
86
86
  export { runValidation } from "./validation.js";
@@ -12,7 +12,7 @@ export { parseChangesetChanges } from "./changeset-changes.js";
12
12
  export { access, createSeed, createSystemConfig, createSystemSeed, createTenantConfig, createTenantSeed, createUserConfig, createUserSeed, isEncryptedAtRest, } from "./config-helpers.js";
13
13
  export { ConcurrencyModes, ConfigScopes, LifecycleHookTypes, MessageKind, OnDeleteStrategies, SystemHookNames, SystemHookPriorities, TENANT_CURRENCY_CONFIG_KEY, tenantChannel, } from "./constants.js";
14
14
  export { createApp } from "./create-app.js";
15
- export { crossTenantOverrideDenied } from "./cross-tenant.js";
15
+ export { mayOverrideTenant, tenantOverrideDenied } from "./cross-tenant.js";
16
16
  export { dedupeFeatures } from "./dedupe-features.js";
17
17
  export { defineFeature } from "./define-feature.js";
18
18
  export { definePagedQueryHandler, defineQueryHandler, defineWriteHandler, isPagedQueryHandler, } from "./define-handler.js";
@@ -1,5 +1,6 @@
1
+ import { type EntityLifecycleVerb } from "../db/event-store-executor-context.js";
1
2
  import type { RegistryState } from "./registry-state.js";
2
- import type { FeatureDefinition } from "./types/index.js";
3
+ import type { EntityEventMigration, EventUpcastFn, FeatureDefinition } from "./types/index.js";
3
4
  export declare function finalizeWorkspaceNavMembership(state: RegistryState): void;
4
5
  export declare function populateHandlerEntityMappings(state: RegistryState, features: readonly FeatureDefinition[]): void;
5
6
  export declare function validateExtensionSelectors(state: RegistryState): void;
@@ -10,6 +11,10 @@ export declare function buildIncomingRelations(state: RegistryState): void;
10
11
  export declare function validateFieldAccessHandlersAreEntityMapped(state: RegistryState, features: readonly FeatureDefinition[]): void;
11
12
  export declare function validateRelationTargetsExist(state: RegistryState): void;
12
13
  export declare function validateEventMigrationVersions(state: RegistryState, features: readonly FeatureDefinition[]): void;
14
+ export declare const ENTITY_LIFECYCLE_VERBS: readonly ["created", "updated", "deleted", "restored", "forgotten"];
15
+ type RowTransform = EntityEventMigration["transform"];
16
+ export declare function adaptRowTransformToPayload(verb: EntityLifecycleVerb, transform: RowTransform): EventUpcastFn;
17
+ export declare function buildEntityEventUpcasterChains(state: RegistryState): void;
13
18
  export declare function buildEventUpcasterChains(state: RegistryState, features: readonly FeatureDefinition[]): void;
14
19
  export declare function validateProjectionApplyKeys(state: RegistryState): void;
15
20
  export declare function validateRequiredFeatures(state: RegistryState, features: readonly FeatureDefinition[]): void;
@@ -24,3 +29,4 @@ export declare function validateExtensionUsageTargets(state: RegistryState): voi
24
29
  export declare function computeHasRateLimitedHandler(state: RegistryState): void;
25
30
  export declare function publishEventPiiCatalog(state: RegistryState): void;
26
31
  export declare function autoWireSoftDeleteJobs(state: RegistryState): void;
32
+ export {};
@@ -1,5 +1,6 @@
1
1
  import { configureEventPiiCatalog } from "../crypto/event-pii.js";
2
2
  import { hasSearchablePlaintext, isSensitiveLabelField } from "../db/entity-field-encryption.js";
3
+ import { entityEventName } from "../db/event-store-executor-context.js";
3
4
  import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch.js";
4
5
  import { resolveName } from "./handler-helpers.js";
5
6
  import { parseRefTargetEntityName } from "./parse-ref-target.js";
@@ -327,6 +328,86 @@ export function validateEventMigrationVersions(state, features) {
327
328
  }
328
329
  }
329
330
  }
331
+ export const ENTITY_LIFECYCLE_VERBS = [
332
+ "created",
333
+ "updated",
334
+ "deleted",
335
+ "restored",
336
+ "forgotten",
337
+ ];
338
+ const MAX_ENTITY_EVENT_VERSION = 999;
339
+ function isRecord(value) {
340
+ return typeof value === "object" && value !== null && !Array.isArray(value);
341
+ }
342
+ // created is the flat row, updated carries changes + previous, the rest only previous.
343
+ function rowPartKeys(verb) {
344
+ if (verb === "created")
345
+ return "whole";
346
+ if (verb === "updated")
347
+ return ["changes", "previous"];
348
+ return ["previous"];
349
+ }
350
+ export function adaptRowTransformToPayload(verb, transform) {
351
+ const parts = rowPartKeys(verb);
352
+ return async (payload, ctx) => {
353
+ if (!isRecord(payload))
354
+ return payload;
355
+ if (parts === "whole")
356
+ return transform(payload, ctx);
357
+ const result = { ...payload };
358
+ for (const key of parts) {
359
+ const part = payload[key];
360
+ if (isRecord(part))
361
+ result[key] = await transform(part, ctx);
362
+ }
363
+ return result;
364
+ };
365
+ }
366
+ // Boot-time check of an entity's eventVersion/eventMigrations; returns the steps keyed by fromVersion, empty for v1.
367
+ function entityEventMigrationSteps(entityName, eventVersion, migrations) {
368
+ if (!Number.isInteger(eventVersion) ||
369
+ eventVersion < 1 ||
370
+ eventVersion > MAX_ENTITY_EVENT_VERSION) {
371
+ throw new Error(`Entity "${entityName}" declares eventVersion ${String(eventVersion)}; it must be an integer between 1 and ${MAX_ENTITY_EVENT_VERSION}.`);
372
+ }
373
+ if (eventVersion === 1 && migrations.length > 0) {
374
+ throw new Error(`Entity "${entityName}" declares eventMigrations but no eventVersion > 1. Set eventVersion to the target version.`);
375
+ }
376
+ const steps = new Map();
377
+ for (const m of migrations) {
378
+ if (m.toVersion !== m.fromVersion + 1 || m.toVersion > eventVersion) {
379
+ throw new Error(`Entity "${entityName}" declares an eventMigration v${m.fromVersion} → v${m.toVersion}; ` +
380
+ `steps must advance by exactly one and not exceed eventVersion ${eventVersion}.`);
381
+ }
382
+ steps.set(m.fromVersion, m.transform);
383
+ }
384
+ for (let v = 1; v < eventVersion; v++) {
385
+ if (!steps.has(v)) {
386
+ throw new Error(`Entity "${entityName}" declares eventVersion ${eventVersion} but no eventMigration ` +
387
+ `covers the step v${v} → v${v + 1}.`);
388
+ }
389
+ }
390
+ return steps;
391
+ }
392
+ export function buildEntityEventUpcasterChains(state) {
393
+ for (const [entityName, entity] of state.entityMap) {
394
+ const eventVersion = entity.eventVersion ?? 1;
395
+ const steps = entityEventMigrationSteps(entityName, eventVersion, entity.eventMigrations ?? []);
396
+ if (eventVersion === 1)
397
+ continue;
398
+ for (const verb of ENTITY_LIFECYCLE_VERBS) {
399
+ const key = entityEventName(entityName, verb);
400
+ if (state.eventUpcasterMap.has(key) || state.eventMap.has(key)) {
401
+ throw new Error(`Entity event type "${key}" collides with an already registered event; event types must be unique.`);
402
+ }
403
+ const chain = new Map();
404
+ for (const [from, transform] of steps) {
405
+ chain.set(from, adaptRowTransformToPayload(verb, transform));
406
+ }
407
+ state.eventUpcasterMap.set(key, { currentVersion: eventVersion, chain });
408
+ }
409
+ }
410
+ }
330
411
  export function buildEventUpcasterChains(state, features) {
331
412
  // Stitch the upcaster chain per qualified event. At this point, gaps in
332
413
  // the chain (e.g. defineEvent version=3 but only a 1→2 migration exists)
@@ -371,7 +452,6 @@ export function validateProjectionApplyKeys(state) {
371
452
  // CRUD types per source entity PLUS every domain event registered via
372
453
  // r.defineEvent — an apply-handler for a domain event is how a projection
373
454
  // reacts to ctx.appendEvent writes on the same aggregate stream.
374
- const AUTO_EVENT_VERBS = ["created", "updated", "deleted", "restored", "forgotten"];
375
455
  const allDomainEventNames = new Set(state.eventMap.keys());
376
456
  for (const [projName, projDef] of state.projectionMap) {
377
457
  const sources = Array.isArray(projDef.source) ? projDef.source : [projDef.source];
@@ -397,7 +477,7 @@ export function validateProjectionApplyKeys(state) {
397
477
  const isEventsOnlySource = !sources.every((src) => state.entityMap.has(src));
398
478
  for (const src of rebuildSources) {
399
479
  if (state.entityMap.has(src)) {
400
- for (const verb of AUTO_EVENT_VERBS)
480
+ for (const verb of ENTITY_LIFECYCLE_VERBS)
401
481
  validEventTypes.add(`${src}.${verb}`);
402
482
  }
403
483
  }
@@ -3,7 +3,7 @@ import { dedupeFeatures } from "./dedupe-features.js";
3
3
  import { buildRegistryFacade } from "./registry-facade.js";
4
4
  import { populateClaimsAndAuth, populateConfigKeys, populateEvents, populateExtensionsAndSeeds, populateFeatureCore, populateHandlers, populateHooks, populateJobsAndNotifications, populateMetricsAndSecrets, populateProjectionsAndTables, populateScreensNavWorkspaces, populateSystemEvents, populateTranslations, } from "./registry-ingest.js";
5
5
  import { createInitialState } from "./registry-state.js";
6
- import { applyExtensionUsages, autoWireSoftDeleteJobs, buildEventUpcasterChains, buildImplicitProjections, buildIncomingRelations, buildSearchableSortableCaches, computeHasRateLimitedHandler, finalizeWorkspaceNavMembership, populateHandlerEntityMappings, publishEventPiiCatalog, resolveJobTriggers, resolveNotificationTriggersAndRegisterHooks, validateBootGates, validateEntityHookTargets, validateEventMigrationVersions, validateExtensionSelectors, validateExtensionUsageTargets, validateFieldAccessHandlersAreEntityMapped, validateJobBackoff, validateLifecycleHookTargets, validateLiveEntities, validateProjectionApplyKeys, validateRelationTargetsExist, validateRequiredFeatures, } from "./registry-validate.js";
6
+ import { applyExtensionUsages, autoWireSoftDeleteJobs, buildEntityEventUpcasterChains, buildEventUpcasterChains, buildImplicitProjections, buildIncomingRelations, buildSearchableSortableCaches, computeHasRateLimitedHandler, finalizeWorkspaceNavMembership, populateHandlerEntityMappings, publishEventPiiCatalog, resolveJobTriggers, resolveNotificationTriggersAndRegisterHooks, validateBootGates, validateEntityHookTargets, validateEventMigrationVersions, validateExtensionSelectors, validateExtensionUsageTargets, validateFieldAccessHandlersAreEntityMapped, validateJobBackoff, validateLifecycleHookTargets, validateLiveEntities, validateProjectionApplyKeys, validateRelationTargetsExist, validateRequiredFeatures, } from "./registry-validate.js";
7
7
  // This is where the magic happens. By "magic" I mean: precomputed maps.
8
8
  // I build everything once at boot (hooks, relations, searchable fields, ...)
9
9
  // so nothing has to iterate over objects at runtime. O(1) instead of O(n*m).
@@ -37,6 +37,7 @@ export function createRegistry(rawFeatures) {
37
37
  validateRelationTargetsExist(state);
38
38
  validateEventMigrationVersions(state, features);
39
39
  buildEventUpcasterChains(state, features);
40
+ buildEntityEventUpcasterChains(state);
40
41
  validateProjectionApplyKeys(state);
41
42
  validateRequiredFeatures(state, features);
42
43
  resolveNotificationTriggersAndRegisterHooks(state);
@@ -6,8 +6,8 @@ export type { KumikoEntityTypeMap, KumikoEventTypeMap, KumikoHandlerPayloadMap,
6
6
  export type { ExtensionOptionsArgs, ExtensionOptionsFor, KumikoExtensionOptionsMap, } from "@cosmicdrift/kumiko-types/extension-options-map";
7
7
  export type { BootCheckContext, BootCheckFn, FeatureDefinition, FeatureMetricDef, FeatureMetricType, FeatureRegistrar, MetricOptions, Registry, SecretKeyDefinition, SecretKeyHandle, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretOptions, StoreTableDef, StoreTableEntry, StoreTableOptions, UiHintOption, UiHints, } from "@cosmicdrift/kumiko-types/feature";
8
8
  export type { FieldIconKey } from "@cosmicdrift/kumiko-types/field-icon";
9
- export type { AnyFileFieldDef, BigIntFieldDef, BooleanFieldDef, ClientDerivedFieldDef, DateFieldDef, DecimalFieldDef, DefaultCurrency, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EmbeddedDerivedCellDef, EmbeddedFieldDef, EmbeddedSubFieldDef, EntityDefinition, EntityIndexDef, FieldAccess, FieldDefinition, FieldsMap, FileFieldDef, FilesFieldDef, Findability, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, LongTextFindability, MoneyCurrencySource, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, OptionsQueryPayload, OptionsQueryPayloadValue, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, SelectOptionTone, TextFieldDef, TimestampFieldDef, TransitionMap, TzFieldDef, } from "@cosmicdrift/kumiko-types/fields";
10
- export type { AccessRule, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AggregateStreamHandle, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, DeclarativeEventMigration, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DispatchWriteRef, EntityRef, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, EventMigrationDef, EventPiiFields, EventPiiStance, EventUpcastCtx, EventUpcastFn, FetchForWritingArgs, HandlerContext, HandlerRef, JobContext, JobRunnerRef, JobTriggerEvent, MemberReader, NameOrRef, NotifyDelivery, NotifyDeliveryStatus, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, PayloadRateLimitOption, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitDeclaration, RateLimitDisabled, RateLimitOption, RateLimitPer, RoleAccessPersonalData, RoleAccessRule, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, TryAppendEventFn, TryAppendEventResult, UnsafeAppendEventFn, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "@cosmicdrift/kumiko-types/handlers";
9
+ export type { AnyFileFieldDef, BigIntFieldDef, BooleanFieldDef, ClientDerivedFieldDef, DateFieldDef, DecimalFieldDef, DefaultCurrency, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EmbeddedDerivedCellDef, EmbeddedFieldDef, EmbeddedSubFieldDef, EntityDefinition, EntityEventMigration, EntityIndexDef, FieldAccess, FieldDefinition, FieldsMap, FileFieldDef, FilesFieldDef, Findability, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, LongTextFindability, MoneyCurrencySource, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, OptionsQueryPayload, OptionsQueryPayloadValue, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, SelectOptionTone, TextFieldDef, TimestampFieldDef, TransitionMap, TzFieldDef, } from "@cosmicdrift/kumiko-types/fields";
10
+ export type { AccessRule, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AggregateStreamHandle, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, DeclarativeEventMigration, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DispatchWriteRef, EntityRef, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, EventMigrationDef, EventPiiFields, EventPiiStance, EventUpcastCtx, EventUpcastFn, FetchForWritingArgs, HandlerContext, HandlerRef, JobContext, JobRunnerRef, JobTriggerEvent, MemberReader, NameOrRef, NotifyDelivery, NotifyDeliveryStatus, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, PayloadRateLimitOption, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitDeclaration, RateLimitDisabled, RateLimitOption, RateLimitPer, RoleAccessPersonalData, RoleAccessRule, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, TenantWriteTarget, TryAppendEventFn, TryAppendEventResult, UnsafeAppendEventFn, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "@cosmicdrift/kumiko-types/handlers";
11
11
  export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HTTP_ERROR_CODE_PATTERN, isDeliveryErrorCode, isOpenToAllGranted, isRateLimitDisabled, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
12
12
  export type { DeleteContext, EntityHookMap, HookMap, HookOwner, HookPhase, LifecycleHookFn, LifecycleResult, OwnedFn, PhasedHook, PostDeleteBatchHookFn, PostDeleteHookFn, PostQueryHookFn, PostSaveBatchHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, SaveContext, SearchPayloadContributorFn, ValidationError, ValidationHookFn, } from "@cosmicdrift/kumiko-types/hooks";
13
13
  export type { HttpRouteDefinition, HttpRouteHandler, HttpRouteHandlerDeps, HttpRouteMethod, } from "@cosmicdrift/kumiko-types/http-route";
@@ -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, ProjectionRowIdOf, 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, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelGate, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardValueFormat, 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";
20
+ export type { ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelGate, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardValueFormat, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditAction, EntityEditActionPlacement, 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, relatedListGroupHeaderLabel, relatedListGroupKey, } 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";
@@ -6,6 +6,7 @@ export declare const FrameworkReasons: {
6
6
  readonly featureDisabled: "feature_disabled";
7
7
  readonly systemIdentitySwitchDenied: "system_identity_switch_denied";
8
8
  readonly identitySwitchDenied: "identity_switch_denied";
9
+ readonly tenantOverrideRequiresSystemAdmin: "tenant_override_requires_system_admin";
9
10
  readonly unsafeAllTenantsDenied: "unsafe_all_tenants_denied";
10
11
  readonly memberResolutionDenied: "member_resolution_denied";
11
12
  readonly memberResolutionReadOnly: "member_resolution_read_only";
@@ -40,6 +40,8 @@ export const FrameworkReasons = {
40
40
  // non-SYSTEM identity other than its own caller (different user, tenant,
41
41
  // claims, origin or extra roles) without r.systemScope() or escapeHatch.
42
42
  identitySwitchDenied: "identity_switch_denied",
43
+ // AccessDeniedError: a payload carried tenantIdOverride but the caller is no SystemAdmin.
44
+ tenantOverrideRequiresSystemAdmin: "tenant_override_requires_system_admin",
43
45
  // AccessDeniedError: a handler/hook called ctx.queryProjection(..., { unsafeAllTenants: true })
44
46
  // without an r.systemScope() feature or a declared escapeHatch.
45
47
  unsafeAllTenantsDenied: "unsafe_all_tenants_denied",
@@ -4,6 +4,7 @@ import type { DbConnection, DbRunner } from "../db/connection.js";
4
4
  import type { TenantId } from "../engine/types/index.js";
5
5
  export declare const snapshotsTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
6
6
  export declare function createSnapshotsTable(db: DbConnection): Promise<void>;
7
+ export declare function effectiveSnapshotGeneration(callerVersion: number, entityEventVersion: number | undefined): number;
7
8
  export type Snapshot<TState extends Record<string, unknown> = Record<string, unknown>> = {
8
9
  readonly aggregateId: string;
9
10
  readonly tenantId: TenantId;
@@ -68,6 +68,14 @@ export async function createSnapshotsTable(db) {
68
68
  }
69
69
  await unsafePushTables(db, { kumikoSnapshots: snapshotsTable });
70
70
  }
71
+ const ENTITY_EVENT_VERSION_RADIX = 1000;
72
+ // Entity eventVersion 1 keeps the caller generation (existing snapshots stay valid);
73
+ // higher versions map to negative values, disjoint from caller generations (>= 1).
74
+ export function effectiveSnapshotGeneration(callerVersion, entityEventVersion) {
75
+ if (entityEventVersion === undefined || entityEventVersion === 1)
76
+ return callerVersion;
77
+ return -(callerVersion * ENTITY_EVENT_VERSION_RADIX + entityEventVersion);
78
+ }
71
79
  // Upsert-style save so re-snapshotting the same (aggregateId, version) is
72
80
  // idempotent. Caller can retake a snapshot at the same version without
73
81
  // bespoke error handling — useful when a feature's snapshot policy runs
@@ -120,9 +128,10 @@ export async function loadAggregateWithSnapshot(db, aggregateId, tenantId, reduc
120
128
  return { state: initial, version: 0, snapshotHit: false };
121
129
  }
122
130
  }
123
- const shapeVersion = options?.snapshotVersion ?? 1;
131
+ const callerVersion = options?.snapshotVersion ?? 1;
132
+ const generationFor = (aggregateType) => effectiveSnapshotGeneration(callerVersion, options?.entityEventVersionOf?.(aggregateType));
124
133
  const stored = await loadLatestSnapshot(db, aggregateId, tenantId);
125
- const snapshot = stored && stored.snapshotVersion === shapeVersion ? stored : null;
134
+ const snapshot = stored && stored.snapshotVersion === generationFor(stored.aggregateType) ? stored : null;
126
135
  const baseState = snapshot ? snapshot.state : initial;
127
136
  const afterVersion = snapshot ? snapshot.version : 0;
128
137
  const delta = await loadEventsAfterVersion(db, aggregateId, tenantId, afterVersion);
@@ -141,7 +150,7 @@ export async function loadAggregateWithSnapshot(db, aggregateId, tenantId, reduc
141
150
  aggregateType: lastDelta.aggregateType,
142
151
  version: latestVersion,
143
152
  state,
144
- snapshotVersion: shapeVersion,
153
+ snapshotVersion: generationFor(lastDelta.aggregateType),
145
154
  });
146
155
  }
147
156
  catch {
@@ -11,9 +11,10 @@ import { InternalError } from "../errors/index.js";
11
11
  import { isKumikoError } from "../errors/kumiko-error.js";
12
12
  import { createFileContext } from "../files/file-handle.js";
13
13
  import { createFallbackLogger } from "../logging/index.js";
14
- import { createEscapeHatchProcessDedup, createEscapeHatchReporter, emitJobLastSuccess, emitJobQueueDepth, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
14
+ import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, emitJobLastSuccess, emitJobQueueDepth, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
15
15
  import { createDistributedLock } from "../pipeline/distributed-lock.js";
16
16
  import { RedisKeys } from "../pipeline/redis-keys.js";
17
+ import { createGatedIdentitySwitch } from "../pipeline/system-identity-switch.js";
17
18
  import { buildPersonalDataGate, isPersonalDataGated, parseWriteOrigin, } from "../pipeline/write-origin.js";
18
19
  import { bridgeStub } from "../testing/handler-context.js";
19
20
  // Shape-checked after the BullMQ/Redis JSON round-trip. Unlike `_writeOrigin`
@@ -333,6 +334,7 @@ export function createJobRunner(options) {
333
334
  if (context.meter)
334
335
  registerStandardMetrics(context.meter);
335
336
  // Instance-scoped, not module-level: each runner (and each test) audits a system cron's declared hatch once.
337
+ const escapeHatchReportWindow = createEscapeHatchReportWindow();
336
338
  const systemCronEscapeHatchDedup = createEscapeHatchProcessDedup([
337
339
  "unsafe-raw",
338
340
  "global-write",
@@ -365,7 +367,8 @@ export function createJobRunner(options) {
365
367
  tenantId: SYSTEM_TENANT_ID,
366
368
  actor: systemUser.id,
367
369
  sink: context._escapeHatchAuditSink,
368
- log: context.log,
370
+ log: errorLogger,
371
+ window: escapeHatchReportWindow,
369
372
  });
370
373
  const result = await handler.handler({ type: ACTIVE_TENANT_IDS_QUERY_NAME, payload: {}, user: systemUser }, {
371
374
  db: systemModeDb,
@@ -815,7 +818,9 @@ export function createJobRunner(options) {
815
818
  tenantId,
816
819
  actor: jobSystemUser.id,
817
820
  sink: context._escapeHatchAuditSink,
818
- log: context.log,
821
+ log: errorLogger,
822
+ window: escapeHatchReportWindow,
823
+ ...(triggeredById !== null && { caller: triggeredById }),
819
824
  ...(isSystemCronRun && { processDedup: systemCronEscapeHatchDedup }),
820
825
  ...(context.meter && { meter: context.meter }),
821
826
  });
@@ -855,6 +860,24 @@ export function createJobRunner(options) {
855
860
  // Per-job-run reader, created lazily on queryAsMember's first call and
856
861
  // cached for the rest of this run.
857
862
  let memberReader;
863
+ const identitySwitchHatch = escapeHatchFor(jobDef.escapeHatch, "systemIdentity");
864
+ const identitySwitchGrantReason = identitySwitchHatch?.reason ??
865
+ (isSystemJob ? `r.systemScope() feature of job "${jobName}"` : undefined);
866
+ const ungatedQueryAs = (user, qn, payload) => {
867
+ if (!dispatchWriteRef) {
868
+ throw new Error("JobContext.queryAs called before dispatcher attached — call attachDispatcher() first");
869
+ }
870
+ return dispatchWriteRef.queryAs(user, qn, payload, jobOrigin);
871
+ };
872
+ const gatedIdentitySwitch = createGatedIdentitySwitch(`job "${jobName}"`, jobSystemUser, isSystemJob || identitySwitchHatch !== undefined, {
873
+ queryAs: ungatedQueryAs,
874
+ writeAs: async (user, qn, payload) => {
875
+ if (!dispatchWriteRef) {
876
+ throw new Error("JobContext.writeAs called before dispatcher attached — call attachDispatcher() first");
877
+ }
878
+ return dispatchWriteRef.write(user, qn, payload, jobOrigin);
879
+ },
880
+ }, { reason: identitySwitchGrantReason, report: reportEscapeHatch });
858
881
  const jobContext = {
859
882
  ...context,
860
883
  db: jobDb ?? missingDbProxy(jobName),
@@ -884,18 +907,8 @@ export function createJobRunner(options) {
884
907
  }
885
908
  return dispatchWriteRef.write(jobSystemUser, qn, payload, jobOrigin);
886
909
  },
887
- writeAs: (user, qn, payload) => {
888
- if (!dispatchWriteRef) {
889
- throw new Error("JobContext.writeAs called before dispatcher attached — call attachDispatcher() first");
890
- }
891
- return dispatchWriteRef.write(user, qn, payload, jobOrigin);
892
- },
893
- queryAs: (user, qn, payload) => {
894
- if (!dispatchWriteRef) {
895
- throw new Error("JobContext.queryAs called before dispatcher attached — call attachDispatcher() first");
896
- }
897
- return dispatchWriteRef.queryAs(user, qn, payload, jobOrigin);
898
- },
910
+ writeAs: gatedIdentitySwitch.writeAs,
911
+ queryAs: gatedIdentitySwitch.queryAs,
899
912
  queryAsMember: (userId, qn, payload) => {
900
913
  if (!dispatchWriteRef) {
901
914
  throw new Error("JobContext.queryAsMember called before dispatcher attached — call attachDispatcher() first");
@@ -970,7 +983,7 @@ export function createJobRunner(options) {
970
983
  // When parentContext is set, the new parent-aware StartSpanOptions
971
984
  // plumbs it through to startSpan — no manual try/finally needed.
972
985
  try {
973
- await tracer.withSpan("job.execute", {
986
+ await withEscapeHatchAuditScope(() => tracer.withSpan("job.execute", {
974
987
  attributes: {
975
988
  "job.name": jobName,
976
989
  "job.id": jobId,
@@ -988,7 +1001,7 @@ export function createJobRunner(options) {
988
1001
  ...(consumerLane !== undefined ? { "kumiko.job.consumer_lane": consumerLane } : {}),
989
1002
  },
990
1003
  ...(parentContext ? { parent: parentContext } : {}),
991
- }, runInSpan);
1004
+ }, runInSpan));
992
1005
  }
993
1006
  finally {
994
1007
  // Release the sequential lock value-matched (Lua compare-and-delete
@@ -1191,6 +1204,7 @@ export function createJobRunner(options) {
1191
1204
  readinessTimeout.cancel();
1192
1205
  }
1193
1206
  await Promise.all([queues.api.close(), queues.worker.close()]);
1207
+ await flushEscapeHatchAudits();
1194
1208
  if (lockRedis) {
1195
1209
  // quit() drains in-flight commands; disconnect() would cancel them
1196
1210
  // and risk a half-released lock.
@@ -14,16 +14,19 @@ export type EscapeHatchProcessDedup = {
14
14
  readonly seen: Set<string>;
15
15
  };
16
16
  export declare function createEscapeHatchProcessDedup(kinds: readonly EscapeHatchKind[]): EscapeHatchProcessDedup;
17
+ export declare function withEscapeHatchAuditScope<T>(run: () => Promise<T>): Promise<T>;
18
+ export declare function flushEscapeHatchAudits(): Promise<void>;
17
19
  export declare function reportEscapeHatchUse(event: EscapeHatchUseEvent, deps: {
18
20
  readonly sink?: EscapeHatchAuditSink;
19
- readonly log?: Logger;
21
+ readonly log: Pick<Logger, "warn" | "error">;
20
22
  }): void;
21
23
  export declare function createEscapeHatchReporter(opts: {
22
24
  readonly handler: string;
23
25
  readonly tenantId: TenantId;
24
26
  readonly actor: string;
27
+ readonly caller?: string | undefined;
25
28
  readonly sink?: EscapeHatchAuditSink;
26
- readonly log?: Logger;
29
+ readonly log: Pick<Logger, "warn" | "error">;
27
30
  readonly window?: EscapeHatchReportWindow;
28
31
  readonly processDedup?: EscapeHatchProcessDedup;
29
32
  readonly meter?: Meter;
@@ -1,3 +1,5 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { createFallbackLogger } from "../logging/utils.js";
1
3
  import { emitEscapeHatchUse } from "./standard-metrics.js";
2
4
  export const ESCAPE_HATCH_USED_SIGNAL = "security:escape-hatch-used";
3
5
  const DEFAULT_WINDOW_MS = 60_000;
@@ -30,21 +32,35 @@ export function createEscapeHatchProcessDedup(kinds) {
30
32
  }
31
33
  // Default window for createEscapeHatchReporter callers that don't pass their own.
32
34
  const fallbackReportWindow = createEscapeHatchReportWindow();
33
- const consoleLogger = {
34
- info() { },
35
- warn(msg, data) {
36
- // biome-ignore lint/suspicious/noConsole: fallback for callers without a logger — dropping the call would lose the report silently.
37
- console.warn(msg, data);
38
- },
39
- error(msg, data) {
40
- // biome-ignore lint/suspicious/noConsole: same fallback, see warn above.
41
- console.error(msg, data);
42
- },
43
- debug() { },
44
- child() {
45
- return consoleLogger;
46
- },
47
- };
35
+ // Sink writes that have started and not settled yet, process-wide: shutdown flushes them.
36
+ const processPendingAudits = new Set();
37
+ // The sink writes of the dispatch or job run currently executing.
38
+ const scopePendingAudits = new AsyncLocalStorage();
39
+ function trackPendingAudit(write) {
40
+ processPendingAudits.add(write);
41
+ scopePendingAudits.getStore()?.add(write);
42
+ void write.finally(() => processPendingAudits.delete(write));
43
+ }
44
+ // Awaits the audit writes started inside `run` once it has returned, so callers place it around
45
+ // the transaction and the write is durable when the dispatch or run ends. Nested scopes belong
46
+ // to the outermost one (a nested dispatch runs inside its caller's transaction, which must not
47
+ // wait on the sink). Sink failures are already logged by reportEscapeHatchUse and never throw here.
48
+ export async function withEscapeHatchAuditScope(run) {
49
+ if (scopePendingAudits.getStore() !== undefined)
50
+ return run();
51
+ const pending = new Set();
52
+ try {
53
+ return await scopePendingAudits.run(pending, run);
54
+ }
55
+ finally {
56
+ await Promise.allSettled([...pending]);
57
+ }
58
+ }
59
+ export async function flushEscapeHatchAudits() {
60
+ while (processPendingAudits.size > 0) {
61
+ await Promise.allSettled([...processPendingAudits]);
62
+ }
63
+ }
48
64
  function eventFields(event) {
49
65
  return {
50
66
  handler: event.handler,
@@ -52,26 +68,38 @@ function eventFields(event) {
52
68
  reason: event.reason,
53
69
  tenantId: event.tenantId,
54
70
  actor: event.actor,
71
+ ...(event.caller !== undefined && { caller: event.caller }),
55
72
  ...(event.target && { targetId: event.target.id, targetTenantId: event.target.tenantId }),
56
73
  };
57
74
  }
58
75
  export function reportEscapeHatchUse(event, deps) {
59
- const logger = deps.log ?? consoleLogger;
60
76
  const fields = eventFields(event);
61
77
  if (deps.sink) {
62
- void deps.sink(event).catch((err) => {
63
- logger.error(`${ESCAPE_HATCH_USED_SIGNAL}: audit sink failed`, {
78
+ const write = deps.sink(event).catch((err) => {
79
+ deps.log.error(`${ESCAPE_HATCH_USED_SIGNAL}: audit sink failed`, {
64
80
  ...fields,
65
81
  error: err instanceof Error ? err.message : String(err),
66
82
  });
67
83
  });
68
- return;
84
+ trackPendingAudit(write);
85
+ }
86
+ else {
87
+ deps.log.warn(ESCAPE_HATCH_USED_SIGNAL, fields);
69
88
  }
70
- logger.warn(ESCAPE_HATCH_USED_SIGNAL, fields);
71
89
  }
72
90
  export function createEscapeHatchReporter(opts) {
73
91
  const window = opts.window ?? fallbackReportWindow;
74
92
  const now = opts.now ?? Date.now;
93
+ const deps = { sink: opts.sink, log: opts.log };
94
+ const eventFor = (kind, reason, target) => ({
95
+ handler: opts.handler,
96
+ kind,
97
+ reason,
98
+ tenantId: opts.tenantId,
99
+ actor: opts.actor,
100
+ ...(opts.caller !== undefined && { caller: opts.caller }),
101
+ target,
102
+ });
75
103
  return (kind, reason, target) => {
76
104
  if (opts.meter)
77
105
  emitEscapeHatchUse(opts.meter, opts.handler, kind);
@@ -82,7 +110,7 @@ export function createEscapeHatchReporter(opts) {
82
110
  if (processDedup.seen.has(processKey))
83
111
  return;
84
112
  processDedup.seen.add(processKey);
85
- reportEscapeHatchUse({ handler: opts.handler, kind, reason, tenantId: opts.tenantId, actor: opts.actor, target }, { sink: opts.sink, log: opts.log });
113
+ reportEscapeHatchUse(eventFor(kind, reason, target), deps);
86
114
  // skip: audited above, the window dedup below only serves the other kinds
87
115
  return;
88
116
  }
@@ -92,13 +120,14 @@ export function createEscapeHatchReporter(opts) {
92
120
  reason,
93
121
  opts.tenantId,
94
122
  opts.actor,
123
+ opts.caller ?? null,
95
124
  target?.id ?? null,
96
125
  target?.tenantId ?? null,
97
126
  ]);
98
127
  // skip: same (handler, kind, reason, target) already reported within the window — dedup
99
128
  if (!window.shouldReport(key, now()))
100
129
  return;
101
- reportEscapeHatchUse({ handler: opts.handler, kind, reason, tenantId: opts.tenantId, actor: opts.actor, target }, { sink: opts.sink, log: opts.log });
130
+ reportEscapeHatchUse(eventFor(kind, reason, target), deps);
102
131
  };
103
132
  }
104
133
  // Actor for escape-hatch uses whose caller did not thread one through. Stays
@@ -109,5 +138,6 @@ export function fallbackEscapeHatchReporter(tenantId) {
109
138
  handler: "<unattributed>",
110
139
  tenantId,
111
140
  actor: "<unattributed>",
141
+ log: createFallbackLogger("escape-hatch"),
112
142
  });
113
143
  }
@@ -1,6 +1,6 @@
1
1
  export { type ConsoleProviderOptions, createConsoleProvider } from "./console-provider.js";
2
2
  export { observabilityContext } from "./context.js";
3
- export { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, type EscapeHatchProcessDedup, type EscapeHatchReportWindow, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "./escape-hatch-report.js";
3
+ export { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, type EscapeHatchProcessDedup, type EscapeHatchReportWindow, fallbackEscapeHatchReporter, flushEscapeHatchAudits, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, withEscapeHatchAuditScope, } from "./escape-hatch-report.js";
4
4
  export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
5
5
  export { generateSpanId, generateTraceId } from "./ids.js";
6
6
  export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
@@ -1,7 +1,7 @@
1
1
  // Public surface of the observability module.
2
2
  export { createConsoleProvider } from "./console-provider.js";
3
3
  export { observabilityContext } from "./context.js";
4
- export { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "./escape-hatch-report.js";
4
+ export { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, flushEscapeHatchAudits, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, withEscapeHatchAuditScope, } from "./escape-hatch-report.js";
5
5
  export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
6
6
  export { generateSpanId, generateTraceId } from "./ids.js";
7
7
  export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
@@ -1,4 +1,5 @@
1
1
  import { hasAccess } from "../engine/access.js";
2
+ import { tenantOverrideDenied } from "../engine/cross-tenant.js";
2
3
  import { filterReadFields } from "../engine/field-access.js";
3
4
  import { NotFoundError, validationErrorFromZod } from "../errors/index.js";
4
5
  import { assertNoSecretLeak } from "../secrets/index.js";
@@ -41,6 +42,9 @@ async function executeQueryInner(ctx, type, payload, user, origin, tx) {
41
42
  if (!parsed.success) {
42
43
  throw validationErrorFromZod(parsed.error);
43
44
  }
45
+ const overrideDenied = tenantOverrideDenied(user, parsed.data);
46
+ if (overrideDenied)
47
+ throw overrideDenied;
44
48
  // Trash opt-in rides the validated query payload: only the entity-list
45
49
  // schema (and custom query schemas that opt in) carries `includeDeleted`,
46
50
  // so other handlers never see the flag. Visibility filters still apply