@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
@@ -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
@@ -9,10 +9,11 @@ import { AccessDeniedError, FeatureDisabledError, InternalError, memberResolutio
9
9
  import { archiveStream as archiveStreamHelper, isStreamArchived, restoreStream as restoreStreamHelper, } from "../event-store/archive.js";
10
10
  import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError, VersionConflictError as EventStoreVersionConflictError, } from "../event-store/errors.js";
11
11
  import { getStreamVersion, loadAggregate, loadAggregateAsOf, } from "../event-store/event-store.js";
12
- import { loadAggregateWithSnapshot, saveSnapshot, } from "../event-store/snapshot.js";
12
+ import { effectiveSnapshotGeneration, loadAggregateWithSnapshot, saveSnapshot, } from "../event-store/snapshot.js";
13
13
  import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster.js";
14
14
  import { createFileContext } from "../files/file-handle.js";
15
15
  import { DEFAULT_LOCALE, isValidLocaleTag } from "../i18n/request-locale.js";
16
+ import { createFallbackLogger } from "../logging/utils.js";
16
17
  import { createEscapeHatchReporter, createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
17
18
  import { buildBucketKey, buildPayloadBucketKey, normalizePayloadBucketValue, } from "../rate-limit/index.js";
18
19
  import { createTzContext, isValidIanaTimeZone } from "../time/index.js";
@@ -23,7 +24,7 @@ import { resolveAuthClaims as runAuthClaimsResolver } from "./auth-claims-resolv
23
24
  import { runBatch, unwrapSingle } from "./dispatch-batch.js";
24
25
  import { executeQuery } from "./dispatch-query.js";
25
26
  import { executeWrite } from "./dispatch-write.js";
26
- import { dispatcherSpanAttributes, isFailedWriteResult, } from "./dispatcher-utils.js";
27
+ import { dispatcherSpanAttributes, isFailedWriteResult, NestedWriteRollback, } from "./dispatcher-utils.js";
27
28
  import { createMemberReaderFn } from "./member-reader.js";
28
29
  import { createGatedIdentitySwitch, createGatedMemberReader, createGatedProjectionReader, isSystemIdentity, systemIdentitySwitchDenied, } from "./system-identity-switch.js";
29
30
  import { buildPersonalDataGate, rootWriteOrigin } from "./write-origin.js";
@@ -220,7 +221,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
220
221
  tenantId: user.tenantId,
221
222
  actor: user.id,
222
223
  sink: context._escapeHatchAuditSink,
223
- log: context.log,
224
+ log: context.log ?? createFallbackLogger("dispatcher"),
224
225
  window: ctx.escapeHatchReportWindow,
225
226
  });
226
227
  const identitySwitchGrantReason = identitySwitchEscapeHatch?.reason ??
@@ -339,13 +340,38 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
339
340
  // Cross-feature bridge: ctx.query/write share the current tx + afterCommitHooks sink.
340
341
  // queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
341
342
  const bridgeSink = afterCommitHooks ?? [];
343
+ // A failed inner write has already appended its event and projections when a later
344
+ // step (e.g. an inTransaction hook) fails, and the outer handler may swallow the
345
+ // failure result. The savepoint makes the failure undo exactly the inner write; the
346
+ // inner afterCommit hooks only reach the outer sink if it succeeded.
347
+ const executeNestedWriteInSavepoint = async (asUser, targetType, payload) => {
348
+ if (!tx)
349
+ return executeWrite(ctx, targetType, payload, asUser, origin, tx, bridgeSink);
350
+ const innerAfterCommitHooks = [];
351
+ try {
352
+ const result = await runInSavepoint(tx, async (savepointTx) => {
353
+ const innerResult = await executeWrite(ctx, targetType, payload, asUser, origin, savepointTx, // @cast-boundary driver savepoint handle is a transaction handle
354
+ innerAfterCommitHooks);
355
+ if (isFailedWriteResult(innerResult))
356
+ throw new NestedWriteRollback(innerResult);
357
+ return innerResult;
358
+ });
359
+ bridgeSink.push(...innerAfterCommitHooks);
360
+ return result;
361
+ }
362
+ catch (e) {
363
+ if (e instanceof NestedWriteRollback)
364
+ return e.failure;
365
+ throw e;
366
+ }
367
+ };
342
368
  // Post-commit hooks have no transaction left, so a bare executeWrite would
343
369
  // auto-commit event append, projections and inTransaction hooks separately
344
370
  // and its afterCommit hooks would land in an already-flushed sink. A
345
371
  // batch-of-one gives the write its own transaction and its own flush.
346
372
  const writeAsUser = async (asUser, targetType, payload) => isPostCommit
347
373
  ? unwrapSingle(await runBatch(ctx, [{ type: targetType, payload }], asUser, undefined, origin))
348
- : executeWrite(ctx, targetType, payload, asUser, origin, tx, bridgeSink);
374
+ : executeNestedWriteInSavepoint(asUser, targetType, payload);
349
375
  const scheduleAfterCommit = (hook) => {
350
376
  bridgeSink.push(hook);
351
377
  };
@@ -560,7 +586,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
560
586
  aggregateType: snapshotArgs.aggregateType,
561
587
  version: snapshotArgs.version,
562
588
  state: snapshotArgs.state,
563
- snapshotVersion: snapshotArgs.snapshotVersion,
589
+ snapshotVersion: effectiveSnapshotGeneration(snapshotArgs.snapshotVersion ?? 1, registry.getEntity(snapshotArgs.aggregateType)?.eventVersion),
564
590
  });
565
591
  },
566
592
  loadAggregateWithSnapshot: async (aggregateId, reducer, initial, loadOptions) => {
@@ -579,6 +605,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
579
605
  const upcastCtx = { db: dbSource, tenantId: user.tenantId };
580
606
  return loadAggregateWithSnapshot(dbSource, aggregateId, user.tenantId, reducer, initial, {
581
607
  ...loadOptions,
608
+ entityEventVersionOf: (aggregateType) => registry.getEntity(aggregateType)?.eventVersion,
582
609
  upcastEvent: (event) => upcastStoredEvent(event, upcasters, upcastCtx), // @wrapper-known semantic-alias
583
610
  });
584
611
  },
@@ -1057,7 +1084,7 @@ function buildAuthClaimsContext(ctx, user) {
1057
1084
  tenantId: user.tenantId,
1058
1085
  actor: user.id,
1059
1086
  sink: context._escapeHatchAuditSink,
1060
- log: context.log,
1087
+ log: context.log ?? createFallbackLogger("dispatcher"),
1061
1088
  window: ctx.escapeHatchReportWindow,
1062
1089
  }),
1063
1090
  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, 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.355.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.355.0",
214
+ "@cosmicdrift/kumiko-types": "0.355.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.355.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,34 @@
1
1
  [
2
+ {
3
+ "version": "0.355.0",
4
+ "type": "improvement",
5
+ "title": "Entity events are versionable: eventVersion + eventMigrations on r.entity",
6
+ "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."
7
+ },
8
+ {
9
+ "version": "0.355.0",
10
+ "type": "improvement",
11
+ "title": "excludeFields per verb reaches the renderer and the agent manifest",
12
+ "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."
13
+ },
14
+ {
15
+ "version": "0.355.0",
16
+ "type": "breaking",
17
+ "title": "Identity and tenant switches are explicitly gated; escape-hatch audit is durable",
18
+ "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."
19
+ },
20
+ {
21
+ "version": "0.355.0",
22
+ "type": "fix",
23
+ "title": "A failed nested ctx.write is rolled back to a savepoint instead of leaking its event and projection into the outer commit",
24
+ "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."
25
+ },
26
+ {
27
+ "version": "0.355.0",
28
+ "type": "fix",
29
+ "title": "Unknown record ids show a localized empty state; an undecryptable encrypted field reads null instead of failing the whole list",
30
+ "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"
31
+ },
2
32
  {
3
33
  "version": "0.354.1",
4
34
  "type": "fix",