@cosmicdrift/kumiko-framework 0.363.0 → 0.364.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 (189) hide show
  1. package/AGENTS.md +307 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +443 -0
  4. package/agents/examples/anonymous-access.md +300 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +323 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +314 -0
  20. package/agents/examples/custom-handlers.md +397 -0
  21. package/agents/examples/delivery-notifications.md +432 -0
  22. package/agents/examples/designer-demo.md +86 -0
  23. package/agents/examples/e2e-generator.md +211 -0
  24. package/agents/examples/embedded-entity-form.md +598 -0
  25. package/agents/examples/embedded.md +649 -0
  26. package/agents/examples/encrypted-tenant-config.md +405 -0
  27. package/agents/examples/error-contract.md +539 -0
  28. package/agents/examples/event-sourcing.md +1287 -0
  29. package/agents/examples/feature-toggles.md +263 -0
  30. package/agents/examples/field-access.md +271 -0
  31. package/agents/examples/files-post-processing.md +433 -0
  32. package/agents/examples/i18n.md +166 -0
  33. package/agents/examples/idempotency.md +179 -0
  34. package/agents/examples/inbound-mail-triage.md +315 -0
  35. package/agents/examples/lane-routing.md +246 -0
  36. package/agents/examples/ledger-banking.md +214 -0
  37. package/agents/examples/ledger-invoicing.md +235 -0
  38. package/agents/examples/legal-pages.md +393 -0
  39. package/agents/examples/lifecycle-hooks.md +368 -0
  40. package/agents/examples/managed-config.md +428 -0
  41. package/agents/examples/managed-pages.md +353 -0
  42. package/agents/examples/notes-history-basic.md +345 -0
  43. package/agents/examples/ownership.md +396 -0
  44. package/agents/examples/pdf-audit-export.md +270 -0
  45. package/agents/examples/pipeline-basics.md +792 -0
  46. package/agents/examples/public-share-token.md +351 -0
  47. package/agents/examples/rate-limiting.md +211 -0
  48. package/agents/examples/realtime-sse.md +187 -0
  49. package/agents/examples/record-detail-layout.md +297 -0
  50. package/agents/examples/reference-data.md +151 -0
  51. package/agents/examples/relations.md +280 -0
  52. package/agents/examples/screens-nav.md +353 -0
  53. package/agents/examples/search.md +233 -0
  54. package/agents/examples/secrets-demo.md +417 -0
  55. package/agents/examples/security-baseline.md +187 -0
  56. package/agents/examples/seed-migration.md +99 -0
  57. package/agents/examples/session-revocation.md +259 -0
  58. package/agents/examples/state-machine.md +920 -0
  59. package/agents/examples/tags-basic.md +401 -0
  60. package/agents/examples/tenant-isolation.md +173 -0
  61. package/agents/examples/tenant-settings.md +275 -0
  62. package/agents/examples/tier-admin.md +347 -0
  63. package/agents/examples/timezones.md +366 -0
  64. package/agents/examples/user-data-rights.md +410 -0
  65. package/agents/examples/user-profile.md +226 -0
  66. package/agents/examples/webhook-step.md +597 -0
  67. package/agents/examples/wizard-form.md +379 -0
  68. package/agents/examples/workflow-engine.md +1731 -0
  69. package/agents/examples/writeform-section.md +300 -0
  70. package/agents/examples.md +74 -0
  71. package/agents/features.md +768 -0
  72. package/agents/widgets.md +181 -0
  73. package/dist/api/auth-middleware.d.ts +2 -0
  74. package/dist/api/auth-middleware.js +7 -0
  75. package/dist/api/auth-routes.js +5 -3
  76. package/dist/api/extra-route.d.ts +3 -0
  77. package/dist/api/index.d.ts +2 -2
  78. package/dist/api/index.js +1 -1
  79. package/dist/api/routes.js +46 -17
  80. package/dist/api/server.d.ts +2 -1
  81. package/dist/api/server.js +18 -6
  82. package/dist/api/session-cookies.d.ts +1 -0
  83. package/dist/api/session-cookies.js +20 -5
  84. package/dist/api/sse-route.js +16 -1
  85. package/dist/bun-db/index.d.ts +1 -1
  86. package/dist/bun-db/index.js +1 -1
  87. package/dist/bun-db/query.d.ts +8 -3
  88. package/dist/bun-db/query.js +27 -7
  89. package/dist/db/collect-table-metas.js +11 -7
  90. package/dist/db/eagerload.js +2 -2
  91. package/dist/db/event-store-executor-context.d.ts +5 -6
  92. package/dist/db/event-store-executor-context.js +24 -22
  93. package/dist/db/event-store-executor-read.js +31 -16
  94. package/dist/db/event-store-executor-write.js +18 -20
  95. package/dist/db/index.d.ts +1 -1
  96. package/dist/db/index.js +1 -1
  97. package/dist/db/lock-guard.d.ts +4 -0
  98. package/dist/db/lock-guard.js +14 -0
  99. package/dist/db/migrate-generator.js +1 -1
  100. package/dist/db/migrate-runner.js +9 -8
  101. package/dist/db/queries/event-consumer.d.ts +6 -6
  102. package/dist/db/queries/event-store.d.ts +1 -1
  103. package/dist/db/queries/projection-rebuild.d.ts +1 -1
  104. package/dist/db/tenant-db-runner.d.ts +4 -0
  105. package/dist/db/tenant-db-runner.js +17 -0
  106. package/dist/db/tenant-db.d.ts +2 -0
  107. package/dist/db/tenant-db.js +30 -4
  108. package/dist/derivatives/derivatives-context.js +1 -1
  109. package/dist/engine/boot-validator/access-declarations.js +2 -2
  110. package/dist/engine/boot-validator/api-ext.js +3 -3
  111. package/dist/engine/boot-validator/boot-check.js +1 -1
  112. package/dist/engine/boot-validator/config-deps.js +13 -13
  113. package/dist/engine/boot-validator/detail-screens.js +1 -1
  114. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  115. package/dist/engine/boot-validator/entity-handler.js +84 -35
  116. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  117. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  118. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  119. package/dist/engine/boot-validator/nav.js +1 -1
  120. package/dist/engine/boot-validator/pii-retention.js +8 -8
  121. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  122. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  123. package/dist/engine/boot-validator/screens.d.ts +2 -1
  124. package/dist/engine/boot-validator/screens.js +153 -95
  125. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  126. package/dist/engine/define-handler.d.ts +2 -2
  127. package/dist/engine/define-handler.js +8 -1
  128. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  129. package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
  130. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  131. package/dist/engine/feature-ast/render.js +10 -2
  132. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  133. package/dist/engine/feature-entity-handlers.js +12 -3
  134. package/dist/engine/handler-field-references.d.ts +21 -2
  135. package/dist/engine/handler-field-references.js +55 -17
  136. package/dist/engine/index.d.ts +2 -2
  137. package/dist/engine/schema-builder.js +2 -1
  138. package/dist/engine/soft-delete-cleanup.js +10 -3
  139. package/dist/engine/steps/retry.js +2 -1
  140. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  141. package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
  142. package/dist/engine/template-variables.d.ts +3 -0
  143. package/dist/engine/template-variables.js +3 -0
  144. package/dist/engine/types/index.d.ts +1 -1
  145. package/dist/env/_zod-introspect.d.ts +1 -0
  146. package/dist/errors/classes.js +1 -0
  147. package/dist/errors/kumiko-error.d.ts +2 -0
  148. package/dist/errors/kumiko-error.js +2 -0
  149. package/dist/errors/write-error-info.js +2 -0
  150. package/dist/event-store/tenant-db-append.js +2 -2
  151. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  152. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  153. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  154. package/dist/files/bind-pending-file-refs.js +3 -3
  155. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  156. package/dist/files/bind-referenced-files.write.js +22 -13
  157. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  158. package/dist/files/create-file-ref-from-bytes.js +67 -4
  159. package/dist/files/create-file-ref.d.ts +22 -0
  160. package/dist/files/create-file-ref.js +53 -25
  161. package/dist/files/file-handle.d.ts +1 -1
  162. package/dist/files/file-handle.js +8 -0
  163. package/dist/files/file-routes.js +1 -1
  164. package/dist/files/index.d.ts +2 -2
  165. package/dist/files/index.js +1 -1
  166. package/dist/files/load-save-file-refs.d.ts +10 -0
  167. package/dist/files/load-save-file-refs.js +47 -0
  168. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  169. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  170. package/dist/jobs/index.d.ts +1 -1
  171. package/dist/jobs/job-runner.d.ts +4 -1
  172. package/dist/jobs/job-runner.js +8 -4
  173. package/dist/observability/escape-hatch-report.d.ts +1 -1
  174. package/dist/observability/escape-hatch-report.js +17 -5
  175. package/dist/pipeline/cascade-handler.js +5 -2
  176. package/dist/pipeline/dispatch-batch.js +79 -31
  177. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  178. package/dist/pipeline/dispatch-shared.js +99 -22
  179. package/dist/pipeline/dispatch-write.js +34 -22
  180. package/dist/pipeline/index.d.ts +1 -0
  181. package/dist/pipeline/index.js +1 -0
  182. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  183. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  184. package/dist/rate-limit/bucket.d.ts +3 -3
  185. package/dist/rate-limit/bucket.js +14 -4
  186. package/dist/redis/cache-sync-bus.js +9 -3
  187. package/dist/search/meilisearch-adapter.js +36 -9
  188. package/package.json +7 -5
  189. package/src/changes.json +207 -8
@@ -1,34 +1,60 @@
1
1
  import { deleteMany, selectMany } from "../db/query.js";
2
2
  import { deleteStoredFileAndDerivatives } from "../derivatives/delete-stored-file.js";
3
3
  export const EXPIRED_FILE_REF_PURGE_BATCH = 500;
4
+ const PAGE_ORDER = [
5
+ { col: "deletedAt", direction: "asc" },
6
+ { col: "id", direction: "asc" },
7
+ ];
8
+ // Keyset page over (deletedAt, id): rows at the cursor's instant with a larger id first, then
9
+ // strictly later instants. `where` already bounds deletedAt by the cutoff; the later-instants
10
+ // query repeats that bound next to the cursor.
11
+ async function selectNextPage(args) {
12
+ const { db, table, where, expiredBefore, cursor, pageSize } = args;
13
+ const options = { orderBy: PAGE_ORDER, limit: pageSize };
14
+ if (cursor === undefined)
15
+ return selectMany(db, table, where, options);
16
+ const sameInstant = await selectMany(db, table, { ...where, deletedAt: cursor.deletedAt, id: { gt: cursor.id } }, options);
17
+ if (sameInstant.length >= pageSize)
18
+ return sameInstant;
19
+ const later = await selectMany(db, table, { ...where, deletedAt: { gt: cursor.deletedAt, lt: expiredBefore } }, { ...options, limit: pageSize - sameInstant.length });
20
+ return [...sameInstant, ...later];
21
+ }
4
22
  // Deletes the bytes (original + derivatives) of expired soft-deleted fileRef rows, then the
5
23
  // rows. Bytes first and fail-closed: once the row is gone nobody knows the storageKey, so a
6
- // row whose bytes could not be removed stays and the next run retries it.
24
+ // row whose bytes could not be removed stays and the next run retries it. A run walks the
25
+ // expired rows page by page along (deletedAt, id), so rows that keep failing cannot hide the
26
+ // rows behind them; the run ends on the first empty page.
7
27
  export async function purgeExpiredFileRefsWithBytes(args) {
8
- const { db, table, where, files, log } = args;
28
+ const { db, table, where, expiredBefore, files, log } = args;
29
+ const pageSize = args.pageSize ?? EXPIRED_FILE_REF_PURGE_BATCH;
9
30
  if (!files) {
10
31
  log?.warn("[soft-delete-cleanup] no file storage available, expired fileRef rows kept");
11
32
  return;
12
33
  }
13
- const rows = await selectMany(db, table, where, {
14
- orderBy: [{ col: "deletedAt", direction: "asc" }],
15
- limit: EXPIRED_FILE_REF_PURGE_BATCH,
16
- });
17
- const purgedIds = [];
18
34
  const store = { list: files.list, delete: (key) => files.ref(key).delete() };
19
- for (const row of rows) {
20
- try {
21
- const failedKeys = await deleteStoredFileAndDerivatives(row.storageKey, store, "soft-delete-cleanup:fileRef");
22
- if (failedKeys.length === 0) {
23
- purgedIds.push(row.id);
24
- continue;
35
+ let cursor;
36
+ for (;;) {
37
+ const rows = await selectNextPage({ db, table, where, expiredBefore, cursor, pageSize });
38
+ const lastRow = rows[rows.length - 1];
39
+ // skip: an empty page means every expired row has been visited
40
+ if (lastRow === undefined)
41
+ return;
42
+ cursor = lastRow;
43
+ const purgedIds = [];
44
+ for (const row of rows) {
45
+ try {
46
+ const failedKeys = await deleteStoredFileAndDerivatives(row.storageKey, store, "soft-delete-cleanup:fileRef");
47
+ if (failedKeys.length === 0) {
48
+ purgedIds.push(row.id);
49
+ continue;
50
+ }
51
+ log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not fully deleted, row kept`);
52
+ }
53
+ catch (err) {
54
+ log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not deleted, row kept: ${err instanceof Error ? err.message : String(err)}`);
25
55
  }
26
- log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not fully deleted, row kept`);
27
- }
28
- catch (err) {
29
- log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not deleted, row kept: ${err instanceof Error ? err.message : String(err)}`);
30
56
  }
57
+ if (purgedIds.length > 0)
58
+ await deleteMany(db, table, { ...where, id: { in: purgedIds } });
31
59
  }
32
- if (purgedIds.length > 0)
33
- await deleteMany(db, table, { ...where, id: { in: purgedIds } });
34
60
  }
@@ -1,2 +1,2 @@
1
- export type { JobLogEntry, JobMeta, JobOutcomeMeta, JobQueuedMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
1
+ export type { JobDroppedMeta, JobLogEntry, JobMeta, JobOutcomeMeta, JobQueuedMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
2
2
  export { createJobRunner, serializeJobSubject } from "./job-runner.js";
@@ -29,6 +29,9 @@ export type JobQueuedMeta = {
29
29
  readonly tenantId: string;
30
30
  readonly subject: string | null;
31
31
  };
32
+ export type JobDroppedMeta = {
33
+ readonly tenantId: string;
34
+ };
32
35
  export type JobOutcomeMeta = {
33
36
  readonly tenantId: string;
34
37
  readonly finalAttempt: boolean;
@@ -66,6 +69,6 @@ export type JobRunnerOptions = {
66
69
  onJobComplete?: (jobName: string, jobId: string, duration: number, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
67
70
  onJobFailed?: (jobName: string, jobId: string, error: string, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
68
71
  onJobQueued?: (jobName: string, jobId: string, meta: JobQueuedMeta) => void;
69
- onJobDropped?: (jobName: string, jobId: string) => void;
72
+ onJobDropped?: (jobName: string, jobId: string, meta: JobDroppedMeta) => void;
70
73
  };
71
74
  export declare function createJobRunner(options: JobRunnerOptions): JobRunner;
@@ -611,12 +611,16 @@ export function createJobRunner(options) {
611
611
  });
612
612
  }
613
613
  }
614
- async function announceDropped(jobName, jobDef, bullJobId) {
614
+ async function announceDropped(jobName, jobDef, bullJobId, data) {
615
615
  // skip: job did not opt in or no logger wired
616
616
  if (!jobDef.tenantVisibleRun || !options.onJobDropped)
617
617
  return;
618
+ const tenantId = tenantIdOfJobData(data);
619
+ // skip: tenant-less run — announceQueued never recorded a row for it
620
+ if (tenantId === SYSTEM_TENANT_ID)
621
+ return;
618
622
  try {
619
- await options.onJobDropped(jobName, bullJobId);
623
+ await options.onJobDropped(jobName, bullJobId, { tenantId });
620
624
  }
621
625
  catch (err) {
622
626
  errorLogger.warn("tenant-visible queued state not cleared", {
@@ -732,7 +736,7 @@ export function createJobRunner(options) {
732
736
  ...buildRetryBullOpts(jobDef, priorAttempts),
733
737
  });
734
738
  if (bullJob.id !== undefined && bullJob.id !== requeued.id) {
735
- await announceDropped(jobName, jobDef, bullJob.id);
739
+ await announceDropped(jobName, jobDef, bullJob.id, bullJob.data);
736
740
  }
737
741
  await announceQueued(jobName, jobDef, requeued.id, requeuedData);
738
742
  // skip: lock taken, work re-enqueued with delay, current invocation done
@@ -1298,7 +1302,7 @@ export function createJobRunner(options) {
1298
1302
  for (const j of waiting) {
1299
1303
  if (j.name === jobName && j.id) {
1300
1304
  await j.remove();
1301
- await announceDropped(jobName, jobDef, j.id);
1305
+ await announceDropped(jobName, jobDef, j.id, j.data);
1302
1306
  }
1303
1307
  }
1304
1308
  break;
@@ -11,7 +11,7 @@ export declare function createEscapeHatchReportWindow(opts?: {
11
11
  }): EscapeHatchReportWindow;
12
12
  export type EscapeHatchProcessDedup = {
13
13
  readonly kinds: ReadonlySet<EscapeHatchKind>;
14
- readonly seen: Set<string>;
14
+ readonly lastAuditedAt: Map<string, number>;
15
15
  };
16
16
  export declare function createEscapeHatchProcessDedup(kinds: readonly EscapeHatchKind[]): EscapeHatchProcessDedup;
17
17
  export declare function withEscapeHatchAuditScope<T>(run: () => Promise<T>): Promise<T>;
@@ -27,8 +27,11 @@ export function createEscapeHatchReportWindow(opts) {
27
27
  },
28
28
  };
29
29
  }
30
+ // A long-lived worker must leave an audit event inside every retention window
31
+ // (90 days), or its crons vanish from the log once the first event is pruned.
32
+ const PROCESS_REAUDIT_INTERVAL_MS = 24 * 60 * 60 * 1000;
30
33
  export function createEscapeHatchProcessDedup(kinds) {
31
- return { kinds: new Set(kinds), seen: new Set() };
34
+ return { kinds: new Set(kinds), lastAuditedAt: new Map() };
32
35
  }
33
36
  // Default window for createEscapeHatchReporter callers that don't pass their own.
34
37
  const fallbackReportWindow = createEscapeHatchReportWindow();
@@ -105,11 +108,20 @@ export function createEscapeHatchReporter(opts) {
105
108
  emitEscapeHatchUse(opts.meter, opts.handler, kind);
106
109
  const { processDedup } = opts;
107
110
  if (processDedup?.kinds.has(kind)) {
108
- const processKey = JSON.stringify([opts.handler, kind, opts.tenantId]);
109
- // skip: this (handler, kind, tenant) was already audited by this process
110
- if (processDedup.seen.has(processKey))
111
+ const processKey = JSON.stringify([
112
+ opts.handler,
113
+ kind,
114
+ opts.tenantId,
115
+ target?.id ?? null,
116
+ target?.tenantId ?? null,
117
+ ]);
118
+ const nowMs = now();
119
+ const lastAuditedAt = processDedup.lastAuditedAt.get(processKey);
120
+ // skip: audited by this process within the re-audit interval
121
+ if (lastAuditedAt !== undefined && nowMs - lastAuditedAt < PROCESS_REAUDIT_INTERVAL_MS) {
111
122
  return;
112
- processDedup.seen.add(processKey);
123
+ }
124
+ processDedup.lastAuditedAt.set(processKey, nowMs);
113
125
  reportEscapeHatchUse(eventFor(kind, reason, target), deps);
114
126
  // skip: audited above, the window dedup below only serves the other kinds
115
127
  return;
@@ -52,10 +52,13 @@ export function createCascadeDeleteHook(registry, tables) {
52
52
  // block the parent delete here instead of changing with it. The error names
53
53
  // the parent only: the caller may delete the parent without reading the child.
54
54
  if (strategy === OnDeleteStrategies.cascade || strategy === OnDeleteStrategies.setNull) {
55
- const childLock = registry.getEntity(relation.target)?.lockedWhen;
56
- if (childLock !== undefined) {
55
+ const childEntity = registry.getEntity(relation.target);
56
+ const childLock = childEntity?.lockedWhen;
57
+ if (childLock !== undefined && payload.lockGuardBypassed !== true) {
58
+ // A soft-deleted child is invisible to the caller and must not block.
57
59
  const children = await selectMany(db, targetTable, {
58
60
  [relation.foreignKey]: payload.id,
61
+ ...(childEntity?.softDelete === true && { isDeleted: false }),
59
62
  });
60
63
  if (children.some((child) => isRecordLocked(childLock, child))) {
61
64
  throw new RecordLockedError({ entityName, entityId: payload.id });
@@ -1,10 +1,10 @@
1
1
  import { requestContext, runWithWriteOrigin } from "../api/request-context.js";
2
2
  import { transaction } from "../db/query.js";
3
- import { InternalError, toWriteErrorInfo, writeFailure } from "../errors/index.js";
3
+ import { InternalError, toWriteErrorInfo, UnprocessableError, writeFailure, } from "../errors/index.js";
4
4
  import { createFallbackLogger } from "../logging/utils.js";
5
5
  import { parseJsonSafe } from "../utils/safe-json.js";
6
6
  import { resolveDbSource } from "./dispatch-shared.js";
7
- import { executeNestedWrite } from "./dispatch-write.js";
7
+ import { executeNestedWrite, executeWrite } from "./dispatch-write.js";
8
8
  import { BatchRollback, isLifecycleResult, wrapToKumiko, } from "./dispatcher-utils.js";
9
9
  import { reserveBeforeTransaction, runAsCommand, } from "./pre-transaction-reservations.js";
10
10
  import { effectiveWriteOrigin, isPersonalDataGated, rootWriteOrigin } from "./write-origin.js";
@@ -22,15 +22,44 @@ function toReservedScope(reservations, index, cmd) {
22
22
  const reservation = reservations.reservedCommands.get(index);
23
23
  return reservation && { type: cmd.type, reservation };
24
24
  }
25
+ function isPerCommandHandler(ctx, type) {
26
+ return ctx.registry.getWriteHandler(type)?.transaction === "perCommand";
27
+ }
25
28
  async function runReservedCommand(scope, cmd, index, tx) {
26
29
  const { ctx, user, inheritedOrigin, reservations, origins, afterCommitHooks } = scope;
27
30
  const origin = effectiveWriteOrigin(rootWriteOrigin(ctx.registry, cmd.type, user), inheritedOrigin);
28
31
  origins.push(origin);
29
32
  const hookStart = afterCommitHooks.length;
30
- const res = await runAsCommand(toReservedScope(reservations, index, cmd), () => runWithWriteOrigin(origin, () => executeNestedWrite(ctx, cmd.type, cmd.payload, user, origin, tx, afterCommitHooks)));
33
+ // Nested-write children would run inline with the in-transaction scope, so a perCommand command skips the expansion.
34
+ const execute = isPerCommandHandler(ctx, cmd.type) ? executeWrite : executeNestedWrite;
35
+ const res = await runAsCommand(toReservedScope(reservations, index, cmd), () => runWithWriteOrigin(origin, () => execute(ctx, cmd.type, cmd.payload, user, origin, tx, afterCommitHooks)));
31
36
  rewrapHooksWithOrigin(afterCommitHooks, hookStart, origin);
32
37
  return res;
33
38
  }
39
+ // Sequential fallback without an outer transaction. A failure result and a throw both end the
40
+ // batch, so both give the reserved capacity back.
41
+ async function runCommandsWithoutTransaction(scope, commands, results) {
42
+ try {
43
+ for (let i = 0; i < commands.length; i++) {
44
+ const cmd = commands[i];
45
+ if (!cmd)
46
+ continue;
47
+ const res = await runReservedCommand(scope, cmd, i, undefined);
48
+ results.push(res);
49
+ if (!res.isSuccess) {
50
+ // No tx means no rollback — but we still drop afterCommit hooks,
51
+ // matching the semantic "failure = side-effects don't fire".
52
+ await scope.reservations.releaseAll();
53
+ return { index: i, error: res.error };
54
+ }
55
+ }
56
+ }
57
+ catch (e) {
58
+ await scope.reservations.releaseAll();
59
+ throw e;
60
+ }
61
+ return undefined;
62
+ }
34
63
  // Core batch logic extracted so write() and command() can reuse it
35
64
  // (a single write = batch of one, running in its own transaction).
36
65
  export async function runBatch(ctx, commands, user, requestId, inheritedOrigin) {
@@ -43,6 +72,30 @@ export async function runBatch(ctx, commands, user, requestId, inheritedOrigin)
43
72
  const { signal: _signal, ...withoutSignal } = current;
44
73
  return requestContext.run(withoutSignal, () => runBatchBody(ctx, commands, user, requestId, inheritedOrigin));
45
74
  }
75
+ function perCommandBatchRejection(ctx, commands) {
76
+ const index = commands.findIndex((cmd) => isPerCommandHandler(ctx, cmd.type));
77
+ const command = commands[index];
78
+ if (!command || commands.length < 2)
79
+ return undefined;
80
+ return {
81
+ isSuccess: false,
82
+ error: toWriteErrorInfo(new UnprocessableError("per_command_handler_in_batch", {
83
+ message: `handler "${command.type}" runs without a transaction (transaction: "perCommand") and cannot be part of a multi-command batch`,
84
+ })),
85
+ failedIndex: index,
86
+ results: [],
87
+ };
88
+ }
89
+ async function checkBatchIdempotency(ctx, user, requestId) {
90
+ if (!requestId || !ctx.idempotency)
91
+ return {};
92
+ const checked = await ctx.idempotency.check(user.tenantId, user.id, requestId);
93
+ if (checked.status !== "cached")
94
+ return { token: checked.token };
95
+ const parsed = parseJsonSafe(checked.result, null);
96
+ // corrupted cache entry — treat as miss, let the request re-run
97
+ return parsed ? { cached: parsed } : {};
98
+ }
46
99
  async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
47
100
  const { idempotency, lifecycle, appContext: context } = ctx;
48
101
  if (commands.length === 0) {
@@ -53,19 +106,10 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
53
106
  // idempotencyToken is only set when we actually acquired the lock — the
54
107
  // corrupted-cache fallthrough below leaves it unset, so finalize() skips
55
108
  // store() rather than writing over an entry it never owned.
56
- let idempotencyToken;
57
- if (requestId && idempotency) {
58
- const checked = await idempotency.check(user.tenantId, user.id, requestId);
59
- if (checked.status === "cached") {
60
- const parsed = parseJsonSafe(checked.result, null);
61
- if (parsed)
62
- return parsed;
63
- // corrupted cache entry — treat as miss, let the request re-run
64
- }
65
- else {
66
- idempotencyToken = checked.token;
67
- }
68
- }
109
+ const idempotencyCheck = await checkBatchIdempotency(ctx, user, requestId);
110
+ if (idempotencyCheck.cached)
111
+ return idempotencyCheck.cached;
112
+ const idempotencyToken = idempotencyCheck.token;
69
113
  // Hooks already ran on the plaintext results; what is returned and cached
70
114
  // for retries must not carry writeOnly values.
71
115
  const maskForClient = (result) => maskBatchResultForClient(ctx.registry, commands, result);
@@ -148,6 +192,10 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
148
192
  return;
149
193
  await runWithWriteOrigin(strictest, flushBatchHooksInner);
150
194
  };
195
+ const perCommandRejection = perCommandBatchRejection(ctx, commands);
196
+ if (perCommandRejection)
197
+ return releaseOrFinalize(perCommandRejection, false);
198
+ const runsWithoutTransaction = commands.some((cmd) => isPerCommandHandler(ctx, cmd.type));
151
199
  // Reserve before the transaction opens so no extra connection is held while the handler tx is
152
200
  // open; the release below runs only after that tx has ended without committing.
153
201
  const reservations = await reserveBeforeTransaction(ctx, commands, user, inheritedOrigin);
@@ -162,25 +210,24 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
162
210
  // batch() opens its own outer transaction — needs the top-level
163
211
  // connection's `.begin()` (TransactionSql exposes only `.savepoint()`).
164
212
  const db = resolveDbSource(ctx, undefined);
165
- if (!db) {
166
- // Without a DB connection there is no transaction to open. Fall back to
213
+ if (!db || runsWithoutTransaction) {
214
+ // Without a DB connection (or for a perCommand handler, whose writes open their own transactions)
215
+ // there is no transaction to open. Fall back to
167
216
  // sequential execution — useful for unit tests that don't touch the DB.
168
217
  // Each command runs independently; a failure stops the batch.
169
- for (let i = 0; i < commands.length; i++) {
170
- const cmd = commands[i];
171
- if (!cmd)
172
- continue;
173
- const res = await runReservedCommand({ ctx, user, inheritedOrigin, reservations, origins, afterCommitHooks }, cmd, i, undefined);
174
- results.push(res);
175
- if (!res.isSuccess) {
176
- // No tx means no rollback — but we still drop afterCommit hooks,
177
- // matching the semantic "failure = side-effects don't fire".
178
- await reservations.releaseAll();
179
- return finalize({ isSuccess: false, error: res.error, failedIndex: i, results });
180
- }
218
+ const failed = await runCommandsWithoutTransaction({ ctx, user, inheritedOrigin, reservations, origins, afterCommitHooks }, commands, results);
219
+ if (failed) {
220
+ return finalize({
221
+ isSuccess: false,
222
+ error: failed.error,
223
+ failedIndex: failed.index,
224
+ results,
225
+ });
181
226
  }
182
227
  await flushAfterCommit();
183
- await flushBatchHooks();
228
+ // The inner commands of a perCommand handler already fired their own batch hooks.
229
+ if (!runsWithoutTransaction)
230
+ await flushBatchHooks();
184
231
  return finalize({ isSuccess: true, results });
185
232
  }
186
233
  let transactionCallbackCompleted = false;
@@ -201,6 +248,7 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
201
248
  }
202
249
  catch (e) {
203
250
  // Rollback or failed COMMIT: the transaction is over, so the reserved capacity goes back.
251
+ // The release is outcome-safe: it only gives back a reservation row the handler tx did not delete.
204
252
  await reservations.releaseAll();
205
253
  if (e instanceof BatchRollback) {
206
254
  // Thrown inside the callback, so the tx rolled back. A 4xx is
@@ -6,7 +6,7 @@ import type { defineTransitions } from "../engine/state-machine.js";
6
6
  import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension.js";
7
7
  import type { TenantId } from "../engine/types/identifiers.js";
8
8
  import type { AppContext, HandlerContext, JobRunnerRef, PayloadRateLimitOption, RateLimitDeclaration, Registry, SessionUser, WriteResult } from "../engine/types/index.js";
9
- import { type WriteErrorInfo } from "../errors/index.js";
9
+ import { InternalError, type WriteErrorInfo } from "../errors/index.js";
10
10
  import { type EscapeHatchReportWindow, type getFallbackMeter, getFallbackTracer } from "../observability/index.js";
11
11
  import { type AfterCommitHook } from "./dispatcher-utils.js";
12
12
  import type { IdempotencyGuard } from "./idempotency.js";
@@ -53,7 +53,12 @@ export declare const DEFAULT_MEMBER_READ_TIMEOUT_MS = 10000;
53
53
  export declare function resolveDbSource(ctx: DispatchContext, tx: DbTx | undefined): DbConnection | DbTx | undefined;
54
54
  export declare function isMemberResolutionPrincipal(user: SessionUser): boolean;
55
55
  export declare function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext;
56
- export declare function buildHandlerContext(ctx: DispatchContext, type: string, user: SessionUser, origin: WriteOrigin, tx?: DbTx, afterCommitHooks?: AfterCommitHook[], includeDeleted?: boolean, isPostCommit?: boolean): Promise<HandlerContext>;
56
+ export declare function perCommandNestedDenied(targetType: string): InternalError;
57
+ export declare function denyTransactionBoundSurfaces(handlerContext: HandlerContext): HandlerContext;
58
+ export declare function applyBeforeTransactionReadOnly(handlerContext: HandlerContext): HandlerContext;
59
+ export type HandlerTransactionScope = "inTransaction" | "withoutTransaction" | "postCommit";
60
+ export declare function buildHandlerContext(ctx: DispatchContext, type: string, user: SessionUser, origin: WriteOrigin, tx?: DbTx, afterCommitHooks?: AfterCommitHook[], includeDeleted?: boolean, transactionScope?: HandlerTransactionScope): Promise<HandlerContext>;
61
+ export declare function createHandlerBoundDigest(digest: (value: string) => string, handlerName: string): (value: string) => string;
57
62
  export declare function runHandlerInstrumented<T>(ctx: DispatchContext, type: string, operation: "query" | "write" | "stream", user: SessionUser, inner: () => Promise<T>): Promise<T>;
58
63
  export declare function runStreamInstrumented<T>(ctx: DispatchContext, type: string, user: SessionUser, inner: () => AsyncGenerator<T>): AsyncGenerator<T>;
59
64
  export declare function checkFeatureEnabled(ctx: DispatchContext, qualifiedHandler: string, tenantId: TenantId): Promise<import("../errors/index.js").FeatureDisabledError | undefined>;
@@ -6,7 +6,7 @@ import { createDerivativesContext } from "../derivatives/derivatives-context.js"
6
6
  import { mayOverrideTenant } from "../engine/cross-tenant.js";
7
7
  import { createSystemUser } from "../engine/system-user.js";
8
8
  import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types/index.js";
9
- import { AccessDeniedError, FeatureDisabledError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, RateLimitError, RateLimitUnavailableError, VersionConflictError, } from "../errors/index.js";
9
+ import { AccessDeniedError, FeatureDisabledError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, RateLimitError, RateLimitUnavailableError, VersionConflictError, writeFailure, } from "../errors/index.js";
10
10
  import { archiveStream as archiveStreamHelper, isStreamArchived, restoreStream as restoreStreamHelper, } from "../event-store/archive.js";
11
11
  import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError, VersionConflictError as EventStoreVersionConflictError, } from "../event-store/errors.js";
12
12
  import { getStreamVersion, loadAggregate, loadAggregateAsOf, } from "../event-store/event-store.js";
@@ -112,10 +112,10 @@ async function denyMemberResolutionWrite() {
112
112
  }
113
113
  // JobRunnerRef's declared type only carries handleEvent, but callers
114
114
  // routinely bracket-access a fuller runner — a Proxy denies ANY property read.
115
- function denyingJobRunnerProxy() {
115
+ function denyingJobRunnerProxy(denied = memberResolutionReadOnlyDenied) {
116
116
  return new Proxy({}, {
117
117
  get() {
118
- throw memberResolutionReadOnlyDenied();
118
+ throw denied();
119
119
  },
120
120
  });
121
121
  }
@@ -205,7 +205,62 @@ export function applyMemberResolutionReadOnly(handlerContext) {
205
205
  }),
206
206
  };
207
207
  }
208
- export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, isPostCommit) {
208
+ function beforeTransactionDenied(surface) {
209
+ return new InternalError({
210
+ message: `ctx.${surface} is not available before the transaction; move it into the handler`,
211
+ });
212
+ }
213
+ function withoutTransactionDenied(surface) {
214
+ return new InternalError({
215
+ message: `ctx.${surface} is not available without an open transaction; run the write through ctx.write so it gets its own transaction`,
216
+ });
217
+ }
218
+ export function perCommandNestedDenied(targetType) {
219
+ return new InternalError({
220
+ message: `handler "${targetType}" runs without a transaction (transaction: "perCommand") and cannot run as a nested write inside an open transaction`,
221
+ });
222
+ }
223
+ function denySurface(buildError, surface) {
224
+ return () => {
225
+ throw buildError(surface);
226
+ };
227
+ }
228
+ function denyEventStoreSurfaces(handlerContext, buildError) {
229
+ return {
230
+ ...handlerContext,
231
+ appendEvent: denySurface(buildError, "appendEvent"),
232
+ unsafeAppendEvent: denySurface(buildError, "unsafeAppendEvent"),
233
+ tryAppendEvent: denySurface(buildError, "tryAppendEvent"),
234
+ fetchForWriting: denySurface(buildError, "fetchForWriting"),
235
+ archiveStream: denySurface(buildError, "archiveStream"),
236
+ restoreStream: denySurface(buildError, "restoreStream"),
237
+ snapshotAggregate: denySurface(buildError, "snapshotAggregate"),
238
+ };
239
+ }
240
+ // Reservations write on purpose through the outside-transaction handles, so they keep those but
241
+ // lose every surface bound to the not-yet-open transaction.
242
+ export function denyTransactionBoundSurfaces(handlerContext) {
243
+ return {
244
+ ...denyEventStoreSurfaces(handlerContext, beforeTransactionDenied),
245
+ write: denySurface(beforeTransactionDenied, "write"),
246
+ writeAs: denySurface(beforeTransactionDenied, "writeAs"),
247
+ scheduleAfterCommit: denySurface(beforeTransactionDenied, "scheduleAfterCommit"),
248
+ ...(handlerContext.notify && { notify: denySurface(beforeTransactionDenied, "notify") }),
249
+ ...(handlerContext.jobRunner && {
250
+ jobRunner: denyingJobRunnerProxy(() => beforeTransactionDenied("jobRunner")),
251
+ }),
252
+ };
253
+ }
254
+ // The beforeTransaction phase has no write need at all, so it also loses the outside-transaction handles.
255
+ export function applyBeforeTransactionReadOnly(handlerContext) {
256
+ return {
257
+ ...denyTransactionBoundSurfaces(handlerContext),
258
+ dbOutsideTransaction: undefined,
259
+ outsideTransaction: undefined,
260
+ forTenant: undefined,
261
+ };
262
+ }
263
+ export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, transactionScope = tx ? "inTransaction" : "withoutTransaction") {
209
264
  const { registry, appContext: context, effectiveFeatures, jobRunner, lifecycle } = ctx;
210
265
  const isSystem = registry.isHandlerSystemScoped(type);
211
266
  // The outer dispatcher receives a DbConnection from the server/stack;
@@ -214,7 +269,10 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
214
269
  const dbSource = resolveDbSource(ctx, tx);
215
270
  const reqCtx = requestContext.get();
216
271
  // global() writes are write-handler-only; SYSTEM identity switch and unsafeRaw accept write, query or stream escapeHatch.
217
- const writeEscapeHatch = registry.getWriteHandler(type)?.escapeHatch;
272
+ const writeHandler = registry.getWriteHandler(type);
273
+ const writeEscapeHatch = writeHandler?.escapeHatch;
274
+ const isPerCommand = writeHandler?.transaction === "perCommand";
275
+ const denyDbWrites = transactionScope === "withoutTransaction";
218
276
  const handlerEscapeHatch = writeEscapeHatch ??
219
277
  registry.getQueryHandler(type)?.escapeHatch ??
220
278
  registry.getStreamHandler(type)?.escapeHatch;
@@ -231,7 +289,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
231
289
  });
232
290
  const identitySwitchGrantReason = identitySwitchEscapeHatch?.reason ??
233
291
  (isSystem ? `r.systemScope() feature "${featureName ?? "unknown"}"` : undefined);
234
- const buildTenantScopedDb = (source, signal, foreignTenantId) => createTenantDb(source, foreignTenantId ?? user.tenantId, isSystem && foreignTenantId === undefined ? "system" : "tenant", context.tracer, context.meter, signal, {
292
+ const buildTenantScopedDb = (source, signal, foreignTenantId, denyWrites = false) => createTenantDb(source, foreignTenantId ?? user.tenantId, isSystem && foreignTenantId === undefined ? "system" : "tenant", context.tracer, context.meter, signal, {
235
293
  ...(foreignTenantId === undefined && {
236
294
  globalWrites: escapeHatchFor(writeEscapeHatch, "globalWrites"),
237
295
  unsafeRaw: escapeHatchFor(handlerEscapeHatch, "unsafeRaw"),
@@ -240,11 +298,14 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
240
298
  memberReadOnly: isMemberResolutionPrincipal(user),
241
299
  personalDataGate: buildPersonalDataGate(registry, origin),
242
300
  projectionRegistry: registry,
301
+ ...(denyWrites && { writesDeniedReason: "without an open transaction" }),
243
302
  });
244
303
  // Propagate the request's AbortSignal so every TenantDb query throws when
245
304
  // the client has disconnected — handlers with many sequential queries skip
246
305
  // the rest of the chain instead of burning DB-CPU for results no one reads.
247
- const db = dbSource ? buildTenantScopedDb(dbSource, reqCtx?.signal) : undefined;
306
+ const db = dbSource
307
+ ? buildTenantScopedDb(dbSource, reqCtx?.signal, undefined, denyDbWrites)
308
+ : undefined;
248
309
  // Unbound pool, tenant-scoped like `db` but never tx-bound — writes
249
310
  // through it survive a rollback of the handler's own transaction. No
250
311
  // AbortSignal here: a client disconnect must not abort a durability write
@@ -286,7 +347,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
286
347
  // queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
287
348
  const bridgeSink = afterCommitHooks ?? [];
288
349
  // Query bridges and post-commit contexts get a throwaway sink: hooks pushed there never run.
289
- const hasLiveCommitSink = afterCommitHooks !== undefined && isPostCommit !== true;
350
+ const hasLiveCommitSink = afterCommitHooks !== undefined && transactionScope !== "postCommit";
290
351
  const scheduleAfterCommit = (hook) => {
291
352
  bridgeSink.push(hook);
292
353
  };
@@ -354,7 +415,11 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
354
415
  const forTenant = !isSystem && dbSource && registry.getWriteHandler(type) !== undefined
355
416
  ? (targetTenantId) => {
356
417
  if (targetTenantId === user.tenantId && exposedDb) {
357
- return { db: exposedDb, streamTenantId: undefined };
418
+ // Only perCommand handlers lose forTenant writes; reservations keep their pre-transaction contract.
419
+ const ownTenantDb = denyDbWrites && !isPerCommand
420
+ ? buildTenantScopedDb(dbSource, reqCtx?.signal)
421
+ : exposedDb;
422
+ return { db: ownTenantDb, streamTenantId: undefined };
358
423
  }
359
424
  if (!mayOverrideTenant(user)) {
360
425
  throw new AccessDeniedError({
@@ -362,7 +427,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
362
427
  });
363
428
  }
364
429
  return {
365
- db: buildTenantScopedDb(dbSource, reqCtx?.signal, targetTenantId),
430
+ db: buildTenantScopedDb(dbSource, reqCtx?.signal, targetTenantId, denyDbWrites && isPerCommand),
366
431
  streamTenantId: targetTenantId,
367
432
  };
368
433
  }
@@ -400,6 +465,9 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
400
465
  // failure result. The savepoint makes the failure undo exactly the inner write; the
401
466
  // inner afterCommit hooks only reach the outer sink if it succeeded.
402
467
  const executeNestedWriteInSavepoint = async (asUser, targetType, payload) => {
468
+ if (registry.getWriteHandler(targetType)?.transaction === "perCommand") {
469
+ return writeFailure(perCommandNestedDenied(targetType));
470
+ }
403
471
  if (!tx)
404
472
  return executeWrite(ctx, targetType, payload, asUser, origin, tx, bridgeSink);
405
473
  const innerAfterCommitHooks = [];
@@ -420,13 +488,13 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
420
488
  throw e;
421
489
  }
422
490
  };
423
- // Post-commit hooks have no transaction left, so a bare executeWrite would
424
- // auto-commit event append, projections and inTransaction hooks separately
425
- // and its afterCommit hooks would land in an already-flushed sink. A
426
- // batch-of-one gives the write its own transaction and its own flush.
427
- const writeAsUser = async (asUser, targetType, payload) => isPostCommit
428
- ? unwrapSingle(await runBatch(ctx, [{ type: targetType, payload }], asUser, undefined, origin))
429
- : executeNestedWriteInSavepoint(asUser, targetType, payload);
491
+ // Without an open transaction (post-commit hooks, stream/query handlers, perCommand handlers) a bare
492
+ // executeWrite would auto-commit event append, projections and inTransaction hooks separately and its
493
+ // afterCommit hooks would land in a sink nobody flushes. A batch-of-one gives the write its own
494
+ // transaction and its own flush.
495
+ const writeAsUser = async (asUser, targetType, payload) => transactionScope === "inTransaction"
496
+ ? executeNestedWriteInSavepoint(asUser, targetType, payload)
497
+ : unwrapSingle(await runBatch(ctx, [{ type: targetType, payload }], asUser, undefined, origin));
430
498
  const identitySwitchAudit = { reason: identitySwitchGrantReason, report: reportEscapeHatch };
431
499
  const identitySwitch = createGatedIdentitySwitch(`handler "${type}"`, user, hasIdentitySwitchGrant, {
432
500
  // Inherits the caller's origin, so switching to SYSTEM cannot shed an anonymous root.
@@ -761,11 +829,12 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
761
829
  const locale = reqCtx?.locale ?? safeUserLocale ?? context.defaultLocale ?? DEFAULT_LOCALE;
762
830
  // The payload-bucket digest is dispatcher-internal; handlers must not be
763
831
  // able to compute bucket keys.
764
- const { _rateLimitPayloadDigest: _omitPayloadDigest,
832
+ const { _rateLimitPayloadDigest: payloadDigest,
765
833
  // Bound to this call's tx: an outer context's factory must not leak into a tx-less one.
766
834
  _notifyInTransactionFactory: _omitOuterNotifyInTransactionFactory, ...contextForHandler } = context;
767
835
  const handlerContext = {
768
836
  ...contextForHandler,
837
+ ...(payloadDigest && { rateLimitDigest: createHandlerBoundDigest(payloadDigest, type) }),
769
838
  ...(notifyInTransactionFactory && { _notifyInTransactionFactory: notifyInTransactionFactory }),
770
839
  registry,
771
840
  db: exposedDb,
@@ -816,10 +885,18 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
816
885
  ...(includeDeleted && { includeDeleted: true }),
817
886
  ...bridge,
818
887
  }; // @cast-boundary engine-bridge
888
+ const scopedContext = transactionScope === "withoutTransaction"
889
+ ? denyEventStoreSurfaces(handlerContext, withoutTransactionDenied)
890
+ : handlerContext;
819
891
  // A resolved member principal is read-only by construction.
820
892
  return isMemberResolutionPrincipal(user)
821
- ? applyMemberResolutionReadOnly(handlerContext)
822
- : handlerContext;
893
+ ? applyMemberResolutionReadOnly(scopedContext)
894
+ : scopedContext;
895
+ }
896
+ // The handler-name prefix keeps a handler from reproducing the digests the
897
+ // dispatcher uses for payload buckets (those hash the bare normalized value).
898
+ export function createHandlerBoundDigest(digest, handlerName) {
899
+ return (value) => digest(`handler:${handlerName}:${value}`);
823
900
  }
824
901
  // Resolves the agent risk the irreversible-operation gate should attribute to
825
902
  // `type` when it becomes the request's entry handler — same defaulting rule
@@ -1107,7 +1184,7 @@ export async function enforcePayloadRateLimits(ctx, limits, handlerName, user, d
1107
1184
  message: `Handler "${handlerName}" declares additionalRateLimits but its payload is not an object.`,
1108
1185
  });
1109
1186
  }
1110
- for (const { per, limit, windowSeconds, cost } of limits) {
1187
+ for (const { per, limit, windowSeconds, cost, normalize } of limits) {
1111
1188
  const value = data[per.payloadField];
1112
1189
  // skip: optional field not sent — nothing to bucket on
1113
1190
  if (value === undefined)
@@ -1117,7 +1194,7 @@ export async function enforcePayloadRateLimits(ctx, limits, handlerName, user, d
1117
1194
  message: `Handler "${handlerName}" additionalRateLimits payloadField "${per.payloadField}" is not a string.`,
1118
1195
  });
1119
1196
  }
1120
- const key = buildPayloadBucketKey(handlerName, per.payloadField, digest(normalizePayloadBucketValue(value)));
1197
+ const key = buildPayloadBucketKey(user.tenantId, handlerName, per.payloadField, digest(normalizePayloadBucketValue(value, normalize)));
1121
1198
  await context.rateLimit.enforce(key, { limit, windowSeconds, cost });
1122
1199
  }
1123
1200
  }