@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
@@ -3,16 +3,18 @@ import { requestContext, runWithOrigin } from "../api/request-context.js";
3
3
  import { runInSavepoint, selectMany } from "../db/query.js";
4
4
  import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, runInOwnTransaction, } from "../db/tenant-db.js";
5
5
  import { createDerivativesContext } from "../derivatives/derivatives-context.js";
6
+ import { mayOverrideTenant } from "../engine/cross-tenant.js";
6
7
  import { createSystemUser } from "../engine/system-user.js";
7
8
  import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types/index.js";
8
- import { AccessDeniedError, FeatureDisabledError, InternalError, memberResolutionReadOnlyDenied, RateLimitError, RateLimitUnavailableError, VersionConflictError, } from "../errors/index.js";
9
+ import { AccessDeniedError, FeatureDisabledError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, RateLimitError, RateLimitUnavailableError, VersionConflictError, } from "../errors/index.js";
9
10
  import { archiveStream as archiveStreamHelper, isStreamArchived, restoreStream as restoreStreamHelper, } from "../event-store/archive.js";
10
11
  import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError, VersionConflictError as EventStoreVersionConflictError, } from "../event-store/errors.js";
11
12
  import { getStreamVersion, loadAggregate, loadAggregateAsOf, } from "../event-store/event-store.js";
12
- import { loadAggregateWithSnapshot, saveSnapshot, } from "../event-store/snapshot.js";
13
+ import { effectiveSnapshotGeneration, loadAggregateWithSnapshot, saveSnapshot, } from "../event-store/snapshot.js";
13
14
  import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster.js";
14
15
  import { createFileContext } from "../files/file-handle.js";
15
16
  import { DEFAULT_LOCALE, isValidLocaleTag } from "../i18n/request-locale.js";
17
+ import { createFallbackLogger } from "../logging/utils.js";
16
18
  import { createEscapeHatchReporter, createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
17
19
  import { buildBucketKey, buildPayloadBucketKey, normalizePayloadBucketValue, } from "../rate-limit/index.js";
18
20
  import { createTzContext, isValidIanaTimeZone } from "../time/index.js";
@@ -23,7 +25,7 @@ import { resolveAuthClaims as runAuthClaimsResolver } from "./auth-claims-resolv
23
25
  import { runBatch, unwrapSingle } from "./dispatch-batch.js";
24
26
  import { executeQuery } from "./dispatch-query.js";
25
27
  import { executeWrite } from "./dispatch-write.js";
26
- import { dispatcherSpanAttributes, isFailedWriteResult, } from "./dispatcher-utils.js";
28
+ import { dispatcherSpanAttributes, isFailedWriteResult, NestedWriteRollback, } from "./dispatcher-utils.js";
27
29
  import { createMemberReaderFn } from "./member-reader.js";
28
30
  import { createGatedIdentitySwitch, createGatedMemberReader, createGatedProjectionReader, isSystemIdentity, systemIdentitySwitchDenied, } from "./system-identity-switch.js";
29
31
  import { buildPersonalDataGate, rootWriteOrigin } from "./write-origin.js";
@@ -177,6 +179,7 @@ export function applyMemberResolutionReadOnly(handlerContext) {
177
179
  restoreStream: denyMemberResolutionWrite,
178
180
  snapshotAggregate: denyMemberResolutionWrite,
179
181
  queryAsMember: denyMemberResolutionWrite,
182
+ forTenant: undefined,
180
183
  resolveActiveMembership: denyMemberResolutionWrite,
181
184
  scheduleAfterCommit: () => {
182
185
  throw memberResolutionReadOnlyDenied();
@@ -220,14 +223,16 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
220
223
  tenantId: user.tenantId,
221
224
  actor: user.id,
222
225
  sink: context._escapeHatchAuditSink,
223
- log: context.log,
226
+ log: context.log ?? createFallbackLogger("dispatcher"),
224
227
  window: ctx.escapeHatchReportWindow,
225
228
  });
226
229
  const identitySwitchGrantReason = identitySwitchEscapeHatch?.reason ??
227
230
  (isSystem ? `r.systemScope() feature "${featureName ?? "unknown"}"` : undefined);
228
- const buildTenantScopedDb = (source, signal) => createTenantDb(source, user.tenantId, isSystem ? "system" : "tenant", context.tracer, context.meter, signal, {
229
- globalWrites: escapeHatchFor(writeEscapeHatch, "globalWrites"),
230
- unsafeRaw: escapeHatchFor(handlerEscapeHatch, "unsafeRaw"),
231
+ const buildTenantScopedDb = (source, signal, foreignTenantId) => createTenantDb(source, foreignTenantId ?? user.tenantId, isSystem && foreignTenantId === undefined ? "system" : "tenant", context.tracer, context.meter, signal, {
232
+ ...(foreignTenantId === undefined && {
233
+ globalWrites: escapeHatchFor(writeEscapeHatch, "globalWrites"),
234
+ unsafeRaw: escapeHatchFor(handlerEscapeHatch, "unsafeRaw"),
235
+ }),
231
236
  report: reportEscapeHatch,
232
237
  memberReadOnly: isMemberResolutionPrincipal(user),
233
238
  personalDataGate: buildPersonalDataGate(registry, origin),
@@ -308,6 +313,26 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
308
313
  });
309
314
  }
310
315
  : undefined;
316
+ // Write handlers only, and not r.systemScope() ones (they have ctx.systemDb). The gate is the
317
+ // dispatcher's tenantIdOverride predicate, so no caller reaches more than it could via
318
+ // tenantIdOverride. The foreign db carries no unsafeRaw/globalWrites grant and is tenant mode
319
+ // bound to the target; the executor accepts streamTenantId only on a db bound to that tenant.
320
+ const forTenant = !isSystem && dbSource && registry.getWriteHandler(type) !== undefined
321
+ ? (targetTenantId) => {
322
+ if (targetTenantId === user.tenantId && exposedDb) {
323
+ return { db: exposedDb, streamTenantId: undefined };
324
+ }
325
+ if (!mayOverrideTenant(user)) {
326
+ throw new AccessDeniedError({
327
+ details: { reason: FrameworkReasons.tenantOverrideRequiresSystemAdmin },
328
+ });
329
+ }
330
+ return {
331
+ db: buildTenantScopedDb(dbSource, reqCtx?.signal, targetTenantId),
332
+ streamTenantId: targetTenantId,
333
+ };
334
+ }
335
+ : undefined;
311
336
  // ctx.files resolved per-tenant through file-foundation (lazy — the
312
337
  // provider is only resolved when a handle actually does I/O). Boot wires
313
338
  // _fileProviderResolver when a file-provider plugin is mounted; falls back
@@ -339,13 +364,38 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
339
364
  // Cross-feature bridge: ctx.query/write share the current tx + afterCommitHooks sink.
340
365
  // queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
341
366
  const bridgeSink = afterCommitHooks ?? [];
367
+ // A failed inner write has already appended its event and projections when a later
368
+ // step (e.g. an inTransaction hook) fails, and the outer handler may swallow the
369
+ // failure result. The savepoint makes the failure undo exactly the inner write; the
370
+ // inner afterCommit hooks only reach the outer sink if it succeeded.
371
+ const executeNestedWriteInSavepoint = async (asUser, targetType, payload) => {
372
+ if (!tx)
373
+ return executeWrite(ctx, targetType, payload, asUser, origin, tx, bridgeSink);
374
+ const innerAfterCommitHooks = [];
375
+ try {
376
+ const result = await runInSavepoint(tx, async (savepointTx) => {
377
+ const innerResult = await executeWrite(ctx, targetType, payload, asUser, origin, savepointTx, // @cast-boundary driver savepoint handle is a transaction handle
378
+ innerAfterCommitHooks);
379
+ if (isFailedWriteResult(innerResult))
380
+ throw new NestedWriteRollback(innerResult);
381
+ return innerResult;
382
+ });
383
+ bridgeSink.push(...innerAfterCommitHooks);
384
+ return result;
385
+ }
386
+ catch (e) {
387
+ if (e instanceof NestedWriteRollback)
388
+ return e.failure;
389
+ throw e;
390
+ }
391
+ };
342
392
  // Post-commit hooks have no transaction left, so a bare executeWrite would
343
393
  // auto-commit event append, projections and inTransaction hooks separately
344
394
  // and its afterCommit hooks would land in an already-flushed sink. A
345
395
  // batch-of-one gives the write its own transaction and its own flush.
346
396
  const writeAsUser = async (asUser, targetType, payload) => isPostCommit
347
397
  ? unwrapSingle(await runBatch(ctx, [{ type: targetType, payload }], asUser, undefined, origin))
348
- : executeWrite(ctx, targetType, payload, asUser, origin, tx, bridgeSink);
398
+ : executeNestedWriteInSavepoint(asUser, targetType, payload);
349
399
  const scheduleAfterCommit = (hook) => {
350
400
  bridgeSink.push(hook);
351
401
  };
@@ -560,7 +610,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
560
610
  aggregateType: snapshotArgs.aggregateType,
561
611
  version: snapshotArgs.version,
562
612
  state: snapshotArgs.state,
563
- snapshotVersion: snapshotArgs.snapshotVersion,
613
+ snapshotVersion: effectiveSnapshotGeneration(snapshotArgs.snapshotVersion ?? 1, registry.getEntity(snapshotArgs.aggregateType)?.eventVersion),
564
614
  });
565
615
  },
566
616
  loadAggregateWithSnapshot: async (aggregateId, reducer, initial, loadOptions) => {
@@ -579,6 +629,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
579
629
  const upcastCtx = { db: dbSource, tenantId: user.tenantId };
580
630
  return loadAggregateWithSnapshot(dbSource, aggregateId, user.tenantId, reducer, initial, {
581
631
  ...loadOptions,
632
+ entityEventVersionOf: (aggregateType) => registry.getEntity(aggregateType)?.eventVersion,
582
633
  upcastEvent: (event) => upcastStoredEvent(event, upcasters, upcastCtx), // @wrapper-known semantic-alias
583
634
  });
584
635
  },
@@ -694,6 +745,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
694
745
  notify,
695
746
  ...(config && { config }),
696
747
  ...(configFor && { configFor }),
748
+ ...(forTenant && { forTenant }),
697
749
  ...(files && { files }),
698
750
  ...(derivatives && { derivatives }),
699
751
  // preSave hooks need `changes`/`previous`/`isNew`, which only exist once
@@ -1057,7 +1109,7 @@ function buildAuthClaimsContext(ctx, user) {
1057
1109
  tenantId: user.tenantId,
1058
1110
  actor: user.id,
1059
1111
  sink: context._escapeHatchAuditSink,
1060
- log: context.log,
1112
+ log: context.log ?? createFallbackLogger("dispatcher"),
1061
1113
  window: ctx.escapeHatchReportWindow,
1062
1114
  }),
1063
1115
  projectionRegistry: ctx.registry,
@@ -1,6 +1,7 @@
1
1
  import { accessInvalidationCredentialFor } from "../api/sse-broker.js";
2
2
  import { scheduleTokenExpiry } from "../api/token-expiry-timer.js";
3
3
  import { hasAccess } from "../engine/access.js";
4
+ import { tenantOverrideDenied } from "../engine/cross-tenant.js";
4
5
  import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, validationErrorFromZod, } from "../errors/index.js";
5
6
  import { assertNoSecretLeak } from "../secrets/index.js";
6
7
  import { buildHandlerContext, enforceRateLimit, ensureFeatureEnabled, isMemberResolutionPrincipal, runStreamInstrumented, } from "./dispatch-shared.js";
@@ -33,6 +34,9 @@ async function* executeStreamInner(ctx, type, payload, user, origin, options) {
33
34
  if (!parsed.success) {
34
35
  throw validationErrorFromZod(parsed.error);
35
36
  }
37
+ const overrideDenied = tenantOverrideDenied(user, parsed.data);
38
+ if (overrideDenied)
39
+ throw overrideDenied;
36
40
  // Idle (heartbeat-only) streams must also cut on access revoke or token expiry — race each
37
41
  // pull against an ended Deferred instead of a post-chunk boolean.
38
42
  let resolveEnded;
@@ -31,6 +31,10 @@ export declare class BatchRollback extends Error {
31
31
  readonly failureError: WriteErrorInfo;
32
32
  constructor(failedIndex: number, failureError: WriteErrorInfo);
33
33
  }
34
+ export declare class NestedWriteRollback extends Error {
35
+ readonly failure: WriteResult;
36
+ constructor(failure: WriteResult);
37
+ }
34
38
  export type HandlerType = string | HandlerRef;
35
39
  export declare function resolveType(type: HandlerType): string;
36
40
  export declare function wrapToKumiko(e: unknown): KumikoError;
@@ -127,6 +127,16 @@ export class BatchRollback extends Error {
127
127
  this.name = "BatchRollback";
128
128
  }
129
129
  }
130
+ // Thrown inside a nested write's savepoint so the driver rolls the savepoint back;
131
+ // the caller unwraps it into the original failure result.
132
+ export class NestedWriteRollback extends Error {
133
+ failure;
134
+ constructor(failure) {
135
+ super("nested write rollback");
136
+ this.failure = failure;
137
+ this.name = "NestedWriteRollback";
138
+ }
139
+ }
130
140
  export function resolveType(type) {
131
141
  return typeof type === "string" ? type : type.name;
132
142
  }
@@ -1,7 +1,7 @@
1
1
  import { runWithWriteOrigin } from "../api/request-context.js";
2
2
  import { TENANT_MEMBERSHIPS_QUERY } from "../engine/extension-names.js";
3
3
  import { InternalError, reraiseAsKumikoError } from "../errors/index.js";
4
- import { createEscapeHatchReportWindow, getFallbackMeter, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
4
+ import { createEscapeHatchReportWindow, getFallbackMeter, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
5
5
  import { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage } from "../redis/cache-sync-topics.js";
6
6
  import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership.js";
7
7
  import { runBatch, unwrapSingle } from "./dispatch-batch.js";
@@ -102,14 +102,14 @@ export function createDispatcher(registry, context, options = {}) {
102
102
  async write(typeOrRef, payload, user, requestId) {
103
103
  const type = resolveType(typeOrRef);
104
104
  // Idempotency handled inside runBatch (caches BatchResult under requestId).
105
- const batchResult = await runBatch(ctx, [{ type, payload }], user, requestId);
105
+ const batchResult = await withEscapeHatchAuditScope(() => runBatch(ctx, [{ type, payload }], user, requestId));
106
106
  return unwrapSingle(batchResult);
107
107
  },
108
- batch: (commands, user, requestId) => runBatch(ctx, commands, user, requestId),
108
+ batch: (commands, user, requestId) => withEscapeHatchAuditScope(() => runBatch(ctx, commands, user, requestId)),
109
109
  query: (typeOrRef, payload, user) => {
110
110
  const type = resolveType(typeOrRef);
111
111
  const origin = rootWriteOrigin(registry, type, user);
112
- return runWithWriteOrigin(origin, () => executeQuery(ctx, type, payload, user, origin));
112
+ return withEscapeHatchAuditScope(() => runWithWriteOrigin(origin, () => executeQuery(ctx, type, payload, user, origin)));
113
113
  },
114
114
  stream: (typeOrRef, payload, user, options) => {
115
115
  const type = resolveType(typeOrRef);
@@ -117,7 +117,7 @@ export function createDispatcher(registry, context, options = {}) {
117
117
  },
118
118
  async command(typeOrRef, payload, user) {
119
119
  const type = resolveType(typeOrRef);
120
- const batchResult = await runBatch(ctx, [{ type, payload }], user);
120
+ const batchResult = await withEscapeHatchAuditScope(() => runBatch(ctx, [{ type, payload }], user));
121
121
  const result = unwrapSingle(batchResult);
122
122
  if (!result.isSuccess) {
123
123
  throw reraiseAsKumikoError(result.error);
@@ -129,12 +129,12 @@ export function createDispatcher(registry, context, options = {}) {
129
129
  };
130
130
  dispatcherInternals.set(dispatcher, {
131
131
  writeWithOrigin: async (type, payload, user, inheritedOrigin) => {
132
- const batchResult = await runBatch(ctx, [{ type, payload }], user, undefined, inheritedOrigin);
132
+ const batchResult = await withEscapeHatchAuditScope(() => runBatch(ctx, [{ type, payload }], user, undefined, inheritedOrigin));
133
133
  return unwrapSingle(batchResult);
134
134
  },
135
135
  queryWithOrigin: (type, payload, user, inheritedOrigin) => {
136
136
  const origin = effectiveWriteOrigin(rootWriteOrigin(registry, type, user), inheritedOrigin);
137
- return runWithWriteOrigin(origin, () => executeQuery(ctx, type, payload, user, origin));
137
+ return withEscapeHatchAuditScope(() => runWithWriteOrigin(origin, () => executeQuery(ctx, type, payload, user, origin)));
138
138
  },
139
139
  });
140
140
  return dispatcher;
@@ -1,5 +1,5 @@
1
1
  export type { EscapeHatchReportWindow } from "../observability/escape-hatch-report.js";
2
- export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "../observability/escape-hatch-report.js";
2
+ export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, flushEscapeHatchAudits, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, withEscapeHatchAuditScope, } from "../observability/escape-hatch-report.js";
3
3
  export type { AppendDomainEventCoreDeps } from "./append-event-core.js";
4
4
  export { appendDomainEventCore } from "./append-event-core.js";
5
5
  export type { ResolveAuthClaimsArgs } from "./auth-claims-resolver.js";
@@ -1,4 +1,4 @@
1
- export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "../observability/escape-hatch-report.js";
1
+ export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, flushEscapeHatchAudits, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, withEscapeHatchAuditScope, } from "../observability/escape-hatch-report.js";
2
2
  export { appendDomainEventCore } from "./append-event-core.js";
3
3
  export { resolveAuthClaims } from "./auth-claims-resolver.js";
4
4
  export { createCascadeDeleteHook } from "./cascade-handler.js";
@@ -1,4 +1,5 @@
1
1
  import { hasAccess } from "../engine/access.js";
2
+ import { tenantOverrideDenied } from "../engine/cross-tenant.js";
2
3
  import { checkWriteFieldRoles } from "../engine/field-access.js";
3
4
  import { runValidation } from "../engine/validation.js";
4
5
  import { AccessDeniedError, FrameworkReasons, isKumikoError, ValidationError, validationErrorFromZod, writeFailure, } from "../errors/index.js";
@@ -81,6 +82,9 @@ async function rejectBeforeParse(ctx, handler, type, user, mode) {
81
82
  return undefined;
82
83
  }
83
84
  async function rejectAfterParse(ctx, handler, type, user, payload, mode) {
85
+ const overrideDenied = tenantOverrideDenied(user, payload);
86
+ if (overrideDenied)
87
+ return writeFailure(overrideDenied);
84
88
  if (mode === "charge") {
85
89
  const rateLimited = await rejectPayloadRateLimited(ctx, handler, type, user, payload);
86
90
  if (rateLimited)
@@ -9,7 +9,7 @@ export type { BooleanFieldDef, DateFieldDef, EmbeddedDerivedCellDef, EntityDefin
9
9
  export type { AccessRule, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, RoleAccessPersonalData, RoleAccessRule, } from "../engine/types/handlers.js";
10
10
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.js";
11
11
  export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav.js";
12
- export type { ActionFormFooterAction, ActionFormRedirect, ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartKind, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardChartTone, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelGate, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardTimeRangeDefinition, DashboardValueFormat, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, EntityListExpandableRow, EntityListFacetConfig, EntityListFacetExtraOption, EntityListScreenDefinition, FieldCondition, FieldIconKey, FieldRenderer, FormWidth, ListBadgeTone, ListColumnDisplay, ListColumnSpec, ListFacetSpec, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSubtitlePart, RelatedListGroupBy, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionDisplay, RowActionDrawer, RowActionMode, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, } from "../engine/types/screen.js";
12
+ export type { ActionFormFooterAction, ActionFormRedirect, ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartKind, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardChartTone, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelGate, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardTimeRangeDefinition, DashboardValueFormat, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityListExpandableRow, EntityListFacetConfig, EntityListFacetExtraOption, EntityListScreenDefinition, EntityWriteExcludedFields, FieldCondition, FieldIconKey, FieldRenderer, FormWidth, ListBadgeTone, ListColumnDisplay, ListColumnSpec, ListFacetSpec, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSubtitlePart, RelatedListGroupBy, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionDisplay, RowActionDrawer, RowActionMode, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, } from "../engine/types/screen.js";
13
13
  export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "../engine/types/screen.js";
14
14
  export type { TargetRef } from "../engine/types/target-ref.js";
15
15
  export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.354.1",
3
+ "version": "0.356.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -210,8 +210,8 @@
210
210
  "./package.json": "./package.json"
211
211
  },
212
212
  "dependencies": {
213
- "@cosmicdrift/kumiko-http": "0.354.1",
214
- "@cosmicdrift/kumiko-types": "0.354.1",
213
+ "@cosmicdrift/kumiko-http": "0.356.0",
214
+ "@cosmicdrift/kumiko-types": "0.356.0",
215
215
  "bullmq": "^5.76.7",
216
216
  "bun-types": "^1.3.13",
217
217
  "hono": "^4.13.1",
@@ -227,7 +227,7 @@
227
227
  "zod": "^4.4.3"
228
228
  },
229
229
  "devDependencies": {
230
- "@cosmicdrift/kumiko-dispatcher-live": "0.354.1",
230
+ "@cosmicdrift/kumiko-dispatcher-live": "0.356.0",
231
231
  "bun-types": "^1.3.13",
232
232
  "pino-pretty": "^13.1.3"
233
233
  },
package/src/changes.json CHANGED
@@ -1,4 +1,46 @@
1
1
  [
2
+ {
3
+ "version": "0.356.0",
4
+ "type": "improvement",
5
+ "title": "Executor create accepts streamTenantId; ctx.forTenant(tenantId) for gated cross-tenant writes",
6
+ "detail": "createEventStoreExecutor().create takes { streamTenantId } (stream, event tenant, row tenant, PII key and cache invalidation follow it, the acting user stays the operator). The override is accepted on a system-mode db and also on a tenant-mode db bound to exactly that stream tenant. New handler-context method ctx.forTenant(tenantId) returns { db, streamTenantId }: a tenant-mode db bound to the target tenant plus the stream override to pass to the executor, so a handler needs no unsafeRaw escape hatch for cross-tenant writes. A foreign tenant is gated to SystemAdmin and system identities (AccessDeniedError with reason tenant_override_requires_system_admin otherwise); the caller's own tenant returns streamTenantId undefined. It is not offered to r.systemScope() handlers. The bundled cross-tenant handlers (tenant removeMember/updateMemberRoles, tier setTenantTier, custom-fields system fields, template-resolver upserts/set, managed-pages set, compliance-profiles setProfile) no longer rewrite event.user.tenantId; the event actor is the operator. tenant cancelPendingInvitation takes an optional streamTenantId."
7
+ },
8
+ {
9
+ "version": "0.356.0",
10
+ "type": "breaking",
11
+ "title": "Job ctx.queryAs is gated like ctx.writeAs",
12
+ "migration": "Jobs calling ctx.queryAs with createSystemUser(...) or any identity other than the job's own caller now fail with AccessDeniedError (system_identity_switch_denied / identity_switch_denied). Declare r.systemScope() on the job's feature or escapeHatch: { reason } on the job, the same grant ctx.writeAs already needs. The escape-hatch guard exempts jobs by name, so this surfaces only at runtime; grep your jobs for ctx.queryAs. Known consumer sites: offlot-app channel-text generate-texts.job, campaign-calendar sync, campaign-channel-foundation seed-starter-channels."
13
+ },
14
+ {
15
+ "version": "0.355.0",
16
+ "type": "improvement",
17
+ "title": "Entity events are versionable: eventVersion + eventMigrations on r.entity",
18
+ "detail": "Entities can declare eventVersion and row-shaped eventMigrations; lifecycle events are stamped with the version and upcast on rebuild and aggregate load. A version bump invalidates entity snapshots."
19
+ },
20
+ {
21
+ "version": "0.355.0",
22
+ "type": "improvement",
23
+ "title": "excludeFields per verb reaches the renderer and the agent manifest",
24
+ "detail": "WriteHandlerDef carries excludedFields; entityEdit screens get writeExcludedFields per verb. The edit form shows them read-only and keeps them out of the payload, create hides them, and the agent manifest lists them per handler."
25
+ },
26
+ {
27
+ "version": "0.355.0",
28
+ "type": "breaking",
29
+ "title": "Identity and tenant switches are explicitly gated; escape-hatch audit is durable",
30
+ "migration": "Job ctx.writeAs now needs r.systemScope() on the feature or escapeHatch: { reason } on the job; use ctx.write when the job acts as its own system user. A payload tenantIdOverride is refused by the dispatcher for non-SystemAdmin callers (handlers no longer call crossTenantOverrideDenied, which is removed; use mayOverrideTenant for other cross-tenant fields). Entity convention handlers with a cross-tenant escapeHatch now keep the operator as actor in the row stream (executor option streamTenantId, system-mode db only). createEscapeHatchReporter requires log; pass the runtime logger and share one report window per runtime. EscapeHatchUseEvent and the audit sink payload gain an optional caller. Call flushEscapeHatchAudits() before closing the database on shutdown."
31
+ },
32
+ {
33
+ "version": "0.355.0",
34
+ "type": "fix",
35
+ "title": "A failed nested ctx.write is rolled back to a savepoint instead of leaking its event and projection into the outer commit",
36
+ "detail": "A nested `ctx.write` / `ctx.writeAs` (and workflow `call-feature`) now runs in a savepoint of the outer transaction: when the inner write fails after its event and projection were written, for example because an in-transaction hook throws, the inner change is rolled back even if the outer handler swallows the failure result and commits, and a SQL error in the inner write no longer poisons the outer transaction. The inner write's afterCommit hooks only run when it succeeded."
37
+ },
38
+ {
39
+ "version": "0.355.0",
40
+ "type": "fix",
41
+ "title": "Unknown record ids show a localized empty state; an undecryptable encrypted field reads null instead of failing the whole list",
42
+ "detail": "Edit and detail screens show a localized empty state with a way back to the list for an unknown or deleted record id instead of a hard-coded English banner, and an entity list or detail no longer fails with a 500 when one row's encrypted field cannot be decrypted: that field reads null, the rest of the row and list are served, and the failure is logged at error level with entity, row id and field"
43
+ },
2
44
  {
3
45
  "version": "0.354.1",
4
46
  "type": "fix",