@cosmicdrift/kumiko-framework 0.354.1 → 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 (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 +8 -2
  4. package/dist/db/event-store-executor-context.js +41 -9
  5. package/dist/db/event-store-executor-read.js +7 -4
  6. package/dist/db/event-store-executor-write.js +95 -56
  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 +1 -1
  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 +1 -1
  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 +32 -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 +33 -6
  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 +30 -0
@@ -273,15 +273,42 @@ function projectScreens(featureName, screens, registry, urlPrefillFieldsByScreen
273
273
  return projectConfigEditScreen(screen, encryptedConfigKeyQns);
274
274
  if (screen.type === "projectionList")
275
275
  return projectProjectionListScreen(screen, registry);
276
- if (screen.type === "actionForm" ||
277
- screen.type === "secretMint" ||
278
- screen.type === "entityEdit") {
279
- const declared = urlPrefillFieldsByScreenQn.get(qualifyEntityName(featureName, "screen", shortId));
280
- return { ...screen, urlPrefillFields: declared === undefined ? [] : [...declared].sort() };
276
+ if (screen.type === "entityEdit") {
277
+ const { writeExcludedFields: _authoredExclusions, ...authoredScreen } = screen;
278
+ return {
279
+ ...authoredScreen,
280
+ urlPrefillFields: sortedUrlPrefillFields(urlPrefillFieldsByScreenQn.get(qualifyEntityName(featureName, "screen", shortId))),
281
+ ...entityEditWriteExcludedFields(featureName, screen.entity, registry),
282
+ };
283
+ }
284
+ if (screen.type === "actionForm" || screen.type === "secretMint") {
285
+ return {
286
+ ...screen,
287
+ urlPrefillFields: sortedUrlPrefillFields(urlPrefillFieldsByScreenQn.get(qualifyEntityName(featureName, "screen", shortId))),
288
+ };
281
289
  }
282
290
  return screen;
283
291
  });
284
292
  }
293
+ function sortedUrlPrefillFields(declared) {
294
+ return declared === undefined ? [] : [...declared].sort();
295
+ }
296
+ // The screen is the one thing the renderer reads, so the per-verb excludeFields
297
+ // of the entity's generic create/update handlers are projected onto it here.
298
+ function entityEditWriteExcludedFields(featureName, entityName, registry) {
299
+ const excludedFor = (verb) => registry.getWriteHandler(`${qualifyEntityName(featureName, QnTypes.write, entityName)}:${verb}`)
300
+ ?.excludedFields;
301
+ const create = excludedFor("create");
302
+ const update = excludedFor("update");
303
+ if (create === undefined && update === undefined)
304
+ return {};
305
+ return {
306
+ writeExcludedFields: {
307
+ ...(create !== undefined && { create }),
308
+ ...(update !== undefined && { update }),
309
+ },
310
+ };
311
+ }
285
312
  function collectEncryptedConfigKeyQns(registry) {
286
313
  const qns = new Set();
287
314
  for (const [featureName, feature] of registry.features) {
@@ -1,3 +1,4 @@
1
1
  import { AccessDeniedError } from "../errors/index.js";
2
2
  import type { SessionUser } from "./types/index.js";
3
- export declare function crossTenantOverrideDenied(user: SessionUser, tenantIdOverride: string | undefined, i18nKey: string): AccessDeniedError | undefined;
3
+ export declare function mayOverrideTenant(user: SessionUser): boolean;
4
+ export declare function tenantOverrideDenied(user: SessionUser, parsedPayload: unknown): AccessDeniedError | undefined;
@@ -1,18 +1,22 @@
1
- import { AccessDeniedError } from "../errors/index.js";
2
- // A payload-supplied target tenant (tenantIdOverride) on a TenantAdmin-reachable
3
- // handler is the cross-tenant escape hatch: hasAccess passes the handler for any
4
- // TenantAdmin, so without a SystemAdmin gate a TenantAdmin could act on another
5
- // tenant. Centralizes the check the override handlers used to inline. Returns
6
- // the denial to `throw` (queries) or wrap in `writeFailure` (writes), or
7
- // undefined when allowed. The i18nKey stays per-feature so existing
8
- // translations keep resolving.
9
- export function crossTenantOverrideDenied(user, tenantIdOverride, i18nKey) {
10
- if (tenantIdOverride === undefined)
11
- return undefined;
12
- if (user.roles.includes("SystemAdmin"))
1
+ import { ROLES } from "../auth/roles.js";
2
+ import { AccessDeniedError, FrameworkReasons } from "../errors/index.js";
3
+ export function mayOverrideTenant(user) {
4
+ return user.roles.includes(ROLES.SystemAdmin);
5
+ }
6
+ function carriesTenantOverride(payload) {
7
+ return (typeof payload === "object" &&
8
+ payload !== null &&
9
+ "tenantIdOverride" in payload &&
10
+ payload.tenantIdOverride !== undefined);
11
+ }
12
+ // A payload-supplied target tenant (tenantIdOverride) is the cross-tenant escape hatch:
13
+ // hasAccess passes a handler for any TenantAdmin, so the dispatcher refuses the field
14
+ // for everyone but a SystemAdmin before any handler sees it. Runs on the schema-parsed
15
+ // payload, so only handlers that declare the field are affected.
16
+ export function tenantOverrideDenied(user, parsedPayload) {
17
+ if (!carriesTenantOverride(parsedPayload) || mayOverrideTenant(user))
13
18
  return undefined;
14
19
  return new AccessDeniedError({
15
- i18nKey,
16
- details: { reason: "tenant_override_requires_system_admin" },
20
+ details: { reason: FrameworkReasons.tenantOverrideRequiresSystemAdmin },
17
21
  });
18
22
  }
@@ -217,15 +217,14 @@ export function defineEntityWriteHandler(name, entity, options) {
217
217
  // actingUserFor reads the row it is about to touch anyway, so for these calls it reports the
218
218
  // cross-tenant use per row (with that row as target) instead of dbFor reporting once up front.
219
219
  const reportsPerTouchedRow = (ctx) => ctx.systemDb === undefined && crossTenantReason !== undefined && !entity.systemStream;
220
- // The event stream is keyed by the acting user's tenantId (streamTenantFor in
221
- // event-store-executor-context.ts), not by the db handed to the executor — an
222
- // unfiltered "system"-mode db alone reads the foreign row but appends to an
223
- // empty stream in the operator's own tenant, surfacing as a bogus
224
- // version_conflict. Rewriting the acting tenant also keeps PII envelope keys
225
- // derived from the row's tenant rather than the operator's.
226
- const actingUserFor = async (user, id, db, ctx) => {
220
+ // The event stream defaults to the acting user's tenant (streamTenantFor in
221
+ // event-store-executor-context.ts), not the db's — an unfiltered "system"-mode db alone
222
+ // reads the foreign row but appends to an empty stream in the operator's own tenant,
223
+ // surfacing as a bogus version_conflict. The row's tenant goes to the executor as
224
+ // streamTenantId so stream and PII keys follow the row while the actor stays the operator.
225
+ const foreignRowTenantFor = async (user, id, db, ctx) => {
227
226
  if (crossTenantReason === undefined || entity.systemStream)
228
- return user;
227
+ return undefined;
229
228
  const row = await db.fetchOne(table, { id });
230
229
  const rowTenantId = row?.["tenantId"];
231
230
  if (reportsPerTouchedRow(ctx)) {
@@ -235,8 +234,8 @@ export function defineEntityWriteHandler(name, entity, options) {
235
234
  : undefined);
236
235
  }
237
236
  if (typeof rowTenantId !== "string" || rowTenantId === user.tenantId)
238
- return user;
239
- return { ...user, tenantId: rowTenantId };
237
+ return undefined;
238
+ return parseTenantId(rowTenantId) ?? undefined;
240
239
  };
241
240
  let schema;
242
241
  let handler;
@@ -272,14 +271,15 @@ export function defineEntityWriteHandler(name, entity, options) {
272
271
  ...submitted,
273
272
  changes: dropEmptyWriteOnlyValues(entity, submitted.changes),
274
273
  };
275
- const user = await actingUserFor(event.user, payload.id, db, ctx);
274
+ const streamTenantId = await foreignRowTenantFor(event.user, payload.id, db, ctx);
276
275
  // skipUnchanged (#464): API-driven updates diff against the stored
277
276
  // row so a resubmitted-but-identical field doesn't force a fresh
278
277
  // pii/encrypted ciphertext. Direct executor.update() callers (e.g.
279
278
  // KEK-rotation, the user-data-rights #494 backfill) don't go through
280
279
  // this handler and keep today's always-re-encrypt behavior, which
281
280
  // they rely on to intentionally force a fresh event/ciphertext.
282
- return executor.update(payload, user, db, {
281
+ return executor.update(payload, event.user, db, {
282
+ streamTenantId,
283
283
  skipUnchanged: true,
284
284
  preSave: runPreSave &&
285
285
  ((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
@@ -291,8 +291,8 @@ export function defineEntityWriteHandler(name, entity, options) {
291
291
  handler = async (event, ctx) => {
292
292
  const db = dbFor(ctx);
293
293
  const payload = event.payload; // @cast-boundary engine-payload
294
- const user = await actingUserFor(event.user, payload.id, db, ctx);
295
- return executor.delete(payload, user, db);
294
+ const streamTenantId = await foreignRowTenantFor(event.user, payload.id, db, ctx);
295
+ return executor.delete(payload, event.user, db, { streamTenantId });
296
296
  };
297
297
  break;
298
298
  case "restore":
@@ -300,8 +300,8 @@ export function defineEntityWriteHandler(name, entity, options) {
300
300
  handler = async (event, ctx) => {
301
301
  const db = dbFor(ctx);
302
302
  const payload = event.payload; // @cast-boundary engine-payload
303
- const user = await actingUserFor(event.user, payload.id, db, ctx);
304
- return executor.restore(payload, user, db);
303
+ const streamTenantId = await foreignRowTenantFor(event.user, payload.id, db, ctx);
304
+ return executor.restore(payload, event.user, db, { streamTenantId });
305
305
  };
306
306
  break;
307
307
  default:
@@ -315,6 +315,7 @@ export function defineEntityWriteHandler(name, entity, options) {
315
315
  access: options.access,
316
316
  ...(options.description !== undefined && { description: options.description }),
317
317
  ...(agentHints !== undefined && { agent: agentHints }),
318
+ ...(excludedFields.length > 0 && { excludedFields }),
318
319
  };
319
320
  }
320
321
  // Append read-time derived-field values (EntityDefinition.derivedFields) to
@@ -179,6 +179,8 @@ export declare function createEntity<F, const T extends EntityTenancy = "tenant"
179
179
  /** Event stream lives on SYSTEM_TENANT_ID (tenant-independent aggregate, e.g.
180
180
  * user) instead of the creator's tenant. See EntityDefinition.systemStream. */
181
181
  readonly systemStream?: boolean;
182
+ readonly eventVersion?: number;
183
+ readonly eventMigrations?: EntityDefinition["eventMigrations"];
182
184
  readonly searchWeight?: number;
183
185
  readonly defaultCurrency?: string;
184
186
  readonly transitions?: Readonly<Record<string, Readonly<Record<string, readonly string[]>>>>;
@@ -29,6 +29,7 @@ const WRITE_HANDLER_KEY_KINDS = {
29
29
  access: "modeled",
30
30
  description: "modeled",
31
31
  agent: "modeled",
32
+ excludedFields: "opaque",
32
33
  unsafeSkipTransitionGuard: "modeled",
33
34
  rateLimit: "modeled",
34
35
  additionalRateLimits: "modeled",
@@ -103,6 +103,7 @@ export function buildEntityHandlerMethods(state, name) {
103
103
  handler: def.handler,
104
104
  access: def.access,
105
105
  ...agentSlots(def),
106
+ ...(def.excludedFields && { excludedFields: def.excludedFields }),
106
107
  ...(def.unsafeSkipTransitionGuard && { unsafeSkipTransitionGuard: true }),
107
108
  ...(def.rateLimit && { rateLimit: def.rateLimit }),
108
109
  ...(def.additionalRateLimits && { additionalRateLimits: def.additionalRateLimits }),
@@ -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";
@@ -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,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",
@@ -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;