@cosmicdrift/kumiko-framework 0.354.0 → 0.355.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 (47) 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 +8 -2
  4. package/dist/db/event-store-executor-context.js +41 -9
  5. package/dist/db/event-store-executor-read.js +22 -11
  6. package/dist/db/event-store-executor-write.js +95 -56
  7. package/dist/db/queries/event-store.d.ts +1 -0
  8. package/dist/db/queries/event-store.js +11 -0
  9. package/dist/engine/build-app-schema.js +32 -5
  10. package/dist/engine/cross-tenant.d.ts +2 -1
  11. package/dist/engine/cross-tenant.js +18 -14
  12. package/dist/engine/entity-handlers.js +17 -16
  13. package/dist/engine/factories.d.ts +2 -0
  14. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  15. package/dist/engine/feature-entity-handlers.js +1 -0
  16. package/dist/engine/index.d.ts +1 -1
  17. package/dist/engine/index.js +1 -1
  18. package/dist/engine/registry-validate.d.ts +7 -1
  19. package/dist/engine/registry-validate.js +82 -2
  20. package/dist/engine/registry.js +2 -1
  21. package/dist/engine/schema-builder.js +35 -2
  22. package/dist/engine/types/index.d.ts +1 -1
  23. package/dist/errors/reasons.d.ts +1 -0
  24. package/dist/errors/reasons.js +2 -0
  25. package/dist/event-store/event-store.d.ts +1 -0
  26. package/dist/event-store/event-store.js +4 -1
  27. package/dist/event-store/index.d.ts +1 -1
  28. package/dist/event-store/index.js +1 -1
  29. package/dist/event-store/snapshot.d.ts +1 -0
  30. package/dist/event-store/snapshot.js +12 -3
  31. package/dist/jobs/job-runner.js +32 -17
  32. package/dist/observability/escape-hatch-report.d.ts +5 -2
  33. package/dist/observability/escape-hatch-report.js +52 -22
  34. package/dist/observability/index.d.ts +1 -1
  35. package/dist/observability/index.js +1 -1
  36. package/dist/pipeline/dispatch-query.js +4 -0
  37. package/dist/pipeline/dispatch-shared.js +33 -6
  38. package/dist/pipeline/dispatch-stream.js +4 -0
  39. package/dist/pipeline/dispatcher-utils.d.ts +4 -0
  40. package/dist/pipeline/dispatcher-utils.js +10 -0
  41. package/dist/pipeline/dispatcher.js +7 -7
  42. package/dist/pipeline/index.d.ts +1 -1
  43. package/dist/pipeline/index.js +1 -1
  44. package/dist/pipeline/pre-handler-gates.js +4 -0
  45. package/dist/ui-types/index.d.ts +1 -1
  46. package/package.json +4 -4
  47. package/src/changes.json +41 -0
@@ -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);
@@ -406,6 +406,38 @@ export function buildInsertSchema(entity, currencies = [...DEFAULT_CURRENCIES],
406
406
  }
407
407
  return applyTotalsMatchRefinements(entity, z.object(shape));
408
408
  }
409
+ // Field types whose column is nullable and whose projection write accepts a
410
+ // plain `null`. Excluded: select/writeOnly text (fieldToZod is already
411
+ // nullable there), and types where null would not clear cleanly — NOT NULL
412
+ // jsonb columns (multiSelect, embedded, jsonb, multi reference), files/images
413
+ // (no column; stripped from updates), locatedTimestamp (split into _utc/_tz
414
+ // columns, a bare null targets a column that does not exist) and money (the
415
+ // currency column would keep its stale value).
416
+ const NULL_CLEARABLE_FIELD_TYPES = new Set([
417
+ "text",
418
+ "longText",
419
+ "boolean",
420
+ "number",
421
+ "bigInt",
422
+ "decimal",
423
+ "reference",
424
+ "date",
425
+ "timestamp",
426
+ "tz",
427
+ "file",
428
+ "image",
429
+ ]);
430
+ function isClearableByNull(field) {
431
+ if (!NULL_CLEARABLE_FIELD_TYPES.has(field.type))
432
+ return false;
433
+ if (field.type === "reference" && field.multiple === true)
434
+ return false;
435
+ if (field.type === "text" && field.writeOnly === true)
436
+ return false;
437
+ const isRequired = "required" in field && field.required === true;
438
+ const hasDefault = "default" in field && field.default !== undefined;
439
+ return !isRequired && !hasDefault;
440
+ }
409
441
  export function buildUpdateSchema(entity, currencies = [...DEFAULT_CURRENCIES], excludedFields = []) {
410
442
  const shape = {};
411
443
  for (const [name, field] of Object.entries(entity.fields)) {
@@ -421,10 +453,11 @@ export function buildUpdateSchema(entity, currencies = [...DEFAULT_CURRENCIES],
421
453
  // fieldToZod still knows the default for its "" → default mapping
422
454
  // (e.g. select) — applyDefaults: false only suppresses the schema-level
423
455
  // `.default(...)` fallback for a genuinely omitted key.
424
- shape[name] = fieldToZod(field, currencies, {
456
+ const zodField = fieldToZod(field, currencies, {
425
457
  applyDefaults: false,
426
458
  isUpdate: true,
427
- }).optional();
459
+ });
460
+ shape[name] = (isClearableByNull(field) ? zodField.nullable() : zodField).optional();
428
461
  }
429
462
  return applyTotalsMatchRefinements(entity, z.object(shape));
430
463
  }
@@ -6,7 +6,7 @@ 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";
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
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";
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";
@@ -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",
@@ -24,6 +24,7 @@ export declare function loadAggregateAsOf(db: DbRunner, aggregateId: string, ten
24
24
  readonly includeArchived?: boolean;
25
25
  }): Promise<readonly StoredEvent[]>;
26
26
  export declare function getStreamVersion(db: DbRunner, aggregateId: string, tenantId: TenantId): Promise<number>;
27
+ export declare function getStreamVersions(db: DbRunner, aggregateIds: readonly string[], tenantId: TenantId): Promise<Map<string, number>>;
27
28
  /** MAX(version) for one aggregate — no tenant filter. SECURITY: existence-oracle,
28
29
  * a caller can probe whether an aggregateId has any events regardless of tenant
29
30
  * membership. Only call from seed/system-internal paths (idempotency checks
@@ -4,7 +4,7 @@ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
4
4
  import { requestContext } from "../api/request-context.js";
5
5
  import { encryptEventPayloadPii } from "../crypto/event-pii.js";
6
6
  import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
7
- import { claimXactId, insertSubsequentEventRow, notifyPgChannel, runAtomicEventWrite, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, } from "../db/queries/event-store.js";
7
+ import { claimXactId, insertSubsequentEventRow, notifyPgChannel, runAtomicEventWrite, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, selectStreamMaxVersions, } from "../db/queries/event-store.js";
8
8
  import { insertOne, selectMany } from "../db/query.js";
9
9
  import { isStreamArchived } from "./archive.js";
10
10
  import { IdempotentAppendConflictError, VersionConflictError } from "./errors.js";
@@ -223,6 +223,9 @@ export async function loadAggregateAsOf(db, aggregateId, tenantId, asOf, options
223
223
  export async function getStreamVersion(db, aggregateId, tenantId) {
224
224
  return selectStreamMaxVersion(db, aggregateId, tenantId);
225
225
  }
226
+ export async function getStreamVersions(db, aggregateIds, tenantId) {
227
+ return selectStreamMaxVersions(db, aggregateIds, tenantId);
228
+ }
226
229
  /** MAX(version) for one aggregate — no tenant filter. SECURITY: existence-oracle,
227
230
  * a caller can probe whether an aggregateId has any events regardless of tenant
228
231
  * membership. Only call from seed/system-internal paths (idempotency checks
@@ -2,7 +2,7 @@ export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/syst
2
2
  export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, type PiiBackfillBatchOptions, type PiiBackfillBatchResult, type PiiBackfillFailure, type PiiBackfillOptions, type PiiBackfillResult, type PiiBackfillScanCache, } from "../db/queries/backfill-pii.js";
3
3
  export { type ArchiveStreamArgs, archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
4
4
  export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError, } from "./errors.js";
5
- export { append, EVENTS_PUBSUB_CHANNEL, type EventMetadata, type EventToAppend, getEventsHighWaterMark, getStreamVersion, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, type StoredEvent, streamAllEventsByType, } from "./event-store.js";
5
+ export { append, EVENTS_PUBSUB_CHANNEL, type EventMetadata, type EventToAppend, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, type StoredEvent, streamAllEventsByType, } from "./event-store.js";
6
6
  export { createEventsTable, eventsTable } from "./events-schema.js";
7
7
  export { appendProvenanceEvent, type ProvenanceEventInput } from "./provenance-append.js";
8
8
  export { createRebuildDeadLetterTable, listRebuildDeadLetters, type RebuildDeadLetterRow, rebuildDeadLetterTable, recordRebuildDeadLetters, type SkippedApply, } from "./rebuild-dead-letter.js";
@@ -2,7 +2,7 @@ export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/syst
2
2
  export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, } from "../db/queries/backfill-pii.js";
3
3
  export { archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
4
4
  export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError, } from "./errors.js";
5
- export { append, EVENTS_PUBSUB_CHANNEL, getEventsHighWaterMark, getStreamVersion, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, streamAllEventsByType, } from "./event-store.js";
5
+ export { append, EVENTS_PUBSUB_CHANNEL, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, streamAllEventsByType, } from "./event-store.js";
6
6
  export { createEventsTable, eventsTable } from "./events-schema.js";
7
7
  export { appendProvenanceEvent } from "./provenance-append.js";
8
8
  export { createRebuildDeadLetterTable, listRebuildDeadLetters, rebuildDeadLetterTable, recordRebuildDeadLetters, } from "./rebuild-dead-letter.js";
@@ -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,25 @@ 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
+ // Only writeAs is gated for jobs; queryAs stays ungated (fw#2859).
873
+ const gatedIdentitySwitch = createGatedIdentitySwitch(`job "${jobName}"`, jobSystemUser, isSystemJob || identitySwitchHatch !== undefined, {
874
+ queryAs: ungatedQueryAs,
875
+ writeAs: async (user, qn, payload) => {
876
+ if (!dispatchWriteRef) {
877
+ throw new Error("JobContext.writeAs called before dispatcher attached — call attachDispatcher() first");
878
+ }
879
+ return dispatchWriteRef.write(user, qn, payload, jobOrigin);
880
+ },
881
+ }, { reason: identitySwitchGrantReason, report: reportEscapeHatch });
858
882
  const jobContext = {
859
883
  ...context,
860
884
  db: jobDb ?? missingDbProxy(jobName),
@@ -884,18 +908,8 @@ export function createJobRunner(options) {
884
908
  }
885
909
  return dispatchWriteRef.write(jobSystemUser, qn, payload, jobOrigin);
886
910
  },
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
- },
911
+ writeAs: gatedIdentitySwitch.writeAs,
912
+ queryAs: ungatedQueryAs,
899
913
  queryAsMember: (userId, qn, payload) => {
900
914
  if (!dispatchWriteRef) {
901
915
  throw new Error("JobContext.queryAsMember called before dispatcher attached — call attachDispatcher() first");
@@ -970,7 +984,7 @@ export function createJobRunner(options) {
970
984
  // When parentContext is set, the new parent-aware StartSpanOptions
971
985
  // plumbs it through to startSpan — no manual try/finally needed.
972
986
  try {
973
- await tracer.withSpan("job.execute", {
987
+ await withEscapeHatchAuditScope(() => tracer.withSpan("job.execute", {
974
988
  attributes: {
975
989
  "job.name": jobName,
976
990
  "job.id": jobId,
@@ -988,7 +1002,7 @@ export function createJobRunner(options) {
988
1002
  ...(consumerLane !== undefined ? { "kumiko.job.consumer_lane": consumerLane } : {}),
989
1003
  },
990
1004
  ...(parentContext ? { parent: parentContext } : {}),
991
- }, runInSpan);
1005
+ }, runInSpan));
992
1006
  }
993
1007
  finally {
994
1008
  // Release the sequential lock value-matched (Lua compare-and-delete
@@ -1191,6 +1205,7 @@ export function createJobRunner(options) {
1191
1205
  readinessTimeout.cancel();
1192
1206
  }
1193
1207
  await Promise.all([queues.api.close(), queues.worker.close()]);
1208
+ await flushEscapeHatchAudits();
1194
1209
  if (lockRedis) {
1195
1210
  // quit() drains in-flight commands; disconnect() would cancel them
1196
1211
  // 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