@cosmicdrift/kumiko-samples 0.365.1 → 0.367.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 (208) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/admin-shell/feature.ts +9 -9
  4. package/packages/bundled-features/src/agent-tools/changes.json +6 -0
  5. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +87 -5
  6. package/packages/bundled-features/src/audit/feature.ts +1 -1
  7. package/packages/bundled-features/src/auth-email-password/changes.json +13 -0
  8. package/packages/bundled-features/src/auth-email-password/constants.ts +0 -2
  9. package/packages/bundled-features/src/auth-email-password/feature.ts +3 -3
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +1 -0
  11. package/packages/bundled-features/src/auth-mfa/feature.ts +4 -4
  12. package/packages/bundled-features/src/auth-mfa-user-data/index.ts +1 -1
  13. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-consent.ts +1 -1
  14. package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
  15. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +89 -28
  16. package/packages/bundled-features/src/cap-counter/changes.json +7 -0
  17. package/packages/bundled-features/src/cap-counter/constants.ts +3 -0
  18. package/packages/bundled-features/src/cap-counter/feature.ts +2 -2
  19. package/packages/bundled-features/src/cap-counter/tables.ts +5 -2
  20. package/packages/bundled-features/src/cap-overview/feature.ts +3 -3
  21. package/packages/bundled-features/src/channel-discord/feature.ts +2 -2
  22. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  23. package/packages/bundled-features/src/channel-email/email-channel.ts +12 -2
  24. package/packages/bundled-features/src/channel-email/feature.ts +2 -2
  25. package/packages/bundled-features/src/channel-in-app/feature.ts +1 -1
  26. package/packages/bundled-features/src/channel-push/feature.ts +1 -1
  27. package/packages/bundled-features/src/channel-slack/feature.ts +2 -2
  28. package/packages/bundled-features/src/channel-teams/feature.ts +2 -2
  29. package/packages/bundled-features/src/channel-telegram/feature.ts +2 -2
  30. package/packages/bundled-features/src/compliance-profiles/feature.ts +1 -1
  31. package/packages/bundled-features/src/compliance-profiles-ops/index.ts +3 -3
  32. package/packages/bundled-features/src/config/feature.ts +1 -1
  33. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  34. package/packages/bundled-features/src/custom-fields/feature.ts +2 -2
  35. package/packages/bundled-features/src/custom-fields/wire-for-entity.ts +17 -13
  36. package/packages/bundled-features/src/data-retention/feature.ts +1 -1
  37. package/packages/bundled-features/src/data-retention/presets.ts +4 -4
  38. package/packages/bundled-features/src/delivery/changes.json +18 -0
  39. package/packages/bundled-features/src/delivery/channel-context.ts +1 -0
  40. package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +6 -2
  41. package/packages/bundled-features/src/delivery/db/queries/preferences.ts +4 -4
  42. package/packages/bundled-features/src/delivery/delivery-service.ts +71 -4
  43. package/packages/bundled-features/src/delivery/feature.ts +10 -6
  44. package/packages/bundled-features/src/delivery/handlers/resubscribe-address.write.ts +12 -8
  45. package/packages/bundled-features/src/delivery/handlers/resubscribe-user.write.ts +3 -1
  46. package/packages/bundled-features/src/delivery/handlers/set-preference.write.ts +3 -1
  47. package/packages/bundled-features/src/delivery/handlers/unsubscribe-address.write.ts +13 -6
  48. package/packages/bundled-features/src/delivery/handlers/unsubscribe-user.write.ts +3 -1
  49. package/packages/bundled-features/src/delivery/index.ts +6 -0
  50. package/packages/bundled-features/src/delivery/notification-type-aliases.ts +13 -0
  51. package/packages/bundled-features/src/delivery/testing.ts +2 -0
  52. package/packages/bundled-features/src/delivery/types.ts +4 -0
  53. package/packages/bundled-features/src/delivery/unsubscribe-link.ts +73 -0
  54. package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +17 -0
  55. package/packages/bundled-features/src/delivery/unsubscribe.ts +94 -31
  56. package/packages/bundled-features/src/derivatives-sharp/feature.ts +1 -1
  57. package/packages/bundled-features/src/document-ingest-foundation/feature.ts +2 -2
  58. package/packages/bundled-features/src/file-derivatives/feature.ts +3 -3
  59. package/packages/bundled-features/src/file-foundation/feature.ts +1 -1
  60. package/packages/bundled-features/src/file-provider-inmemory/feature.ts +2 -2
  61. package/packages/bundled-features/src/file-provider-s3/feature.ts +3 -3
  62. package/packages/bundled-features/src/file-provider-s3-env/feature.ts +2 -2
  63. package/packages/bundled-features/src/files/README.md +1 -1
  64. package/packages/bundled-features/src/files-tenant-data/index.ts +1 -1
  65. package/packages/bundled-features/src/folders/feature.ts +2 -2
  66. package/packages/bundled-features/src/folders-user-data/index.ts +1 -1
  67. package/packages/bundled-features/src/form-draft/feature.ts +2 -2
  68. package/packages/bundled-features/src/form-draft-user-data/index.ts +1 -1
  69. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +13 -0
  70. package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +9 -1
  71. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
  72. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +19 -7
  73. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +21 -2
  74. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -2
  75. package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +1 -1
  76. package/packages/bundled-features/src/ledger/feature.ts +5 -5
  77. package/packages/bundled-features/src/legal-pages/feature.ts +1 -1
  78. package/packages/bundled-features/src/mail-foundation/feature.ts +51 -10
  79. package/packages/bundled-features/src/mail-foundation/index.ts +3 -0
  80. package/packages/bundled-features/src/mail-transport-inmemory/feature.ts +2 -2
  81. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +3 -3
  82. package/packages/bundled-features/src/managed-pages/css-gate.ts +1 -1
  83. package/packages/bundled-features/src/managed-pages/feature.ts +2 -2
  84. package/packages/bundled-features/src/metrics/feature.ts +2 -2
  85. package/packages/bundled-features/src/notes-history/feature.ts +2 -2
  86. package/packages/bundled-features/src/notes-history-user-data/index.ts +1 -1
  87. package/packages/bundled-features/src/personal-access-tokens/feature.ts +15 -11
  88. package/packages/bundled-features/src/readiness/feature.ts +2 -2
  89. package/packages/bundled-features/src/renderer-foundation/README.md +1 -1
  90. package/packages/bundled-features/src/renderer-foundation/feature.ts +1 -1
  91. package/packages/bundled-features/src/renderer-pdf/feature.ts +1 -1
  92. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  93. package/packages/bundled-features/src/seo/feature.ts +1 -1
  94. package/packages/bundled-features/src/sessions/feature.ts +17 -13
  95. package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -1
  96. package/packages/bundled-features/src/subscription-mollie/feature.ts +1 -1
  97. package/packages/bundled-features/src/subscription-stripe/feature.ts +3 -3
  98. package/packages/bundled-features/src/tags/feature.ts +2 -2
  99. package/packages/bundled-features/src/template-resolver/changes.json +25 -0
  100. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -9
  101. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +19 -0
  102. package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -0
  103. package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
  104. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +9 -1
  105. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +8 -2
  106. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +8 -1
  107. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +6 -1
  108. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +7 -2
  109. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +21 -1
  110. package/packages/bundled-features/src/template-resolver/index.ts +4 -0
  111. package/packages/bundled-features/src/template-resolver/qualified-names.ts +1 -0
  112. package/packages/bundled-features/src/template-resolver/seeding.ts +54 -7
  113. package/packages/bundled-features/src/template-resolver/template-preview.ts +2 -0
  114. package/packages/bundled-features/src/template-resolver/template-system-only.ts +21 -0
  115. package/packages/bundled-features/src/template-resolver/template-validation.ts +48 -24
  116. package/packages/bundled-features/src/template-resolver/web/i18n.ts +2 -0
  117. package/packages/bundled-features/src/template-resolver-user-data/index.ts +1 -1
  118. package/packages/bundled-features/src/tenant/feature.ts +4 -4
  119. package/packages/bundled-features/src/tenant/seeding.ts +1 -1
  120. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +2 -3
  121. package/packages/bundled-features/src/tenant-settings/feature.ts +1 -1
  122. package/packages/bundled-features/src/tier-engine/feature.ts +36 -27
  123. package/packages/bundled-features/src/user/feature.ts +1 -1
  124. package/packages/bundled-features/src/user-data-rights/README.md +1 -1
  125. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  126. package/packages/bundled-features/src/user-data-rights/feature.ts +4 -4
  127. package/packages/bundled-features/src/user-data-rights-defaults/feature.ts +2 -2
  128. package/packages/bundled-features/src/user-profile/feature.ts +4 -4
  129. package/packages/bundled-features/src/waitlist/feature.ts +2 -2
  130. package/samples/apps/cap-billing-demo/README.md +1 -1
  131. package/samples/apps/cap-billing-demo/src/feature.ts +4 -4
  132. package/samples/apps/cap-billing-demo/src/run-config.ts +1 -1
  133. package/samples/apps/config-demo/src/features/demo/feature.ts +2 -2
  134. package/samples/apps/styleguide/src/features/ai-settings/feature.ts +43 -28
  135. package/samples/apps/styleguide/src/features/demo/feature.ts +18 -18
  136. package/samples/apps/styleguide/src/features/widgets/ai-text-demo.ts +10 -12
  137. package/samples/apps/styleguide/src/features/widgets/feature.ts +90 -108
  138. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
  139. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +12 -0
  140. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +7 -0
  141. package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +2 -0
  142. package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +36 -36
  143. package/samples/apps/ui-walkthrough/src/features/vehicles/feature.ts +6 -6
  144. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +2 -2
  145. package/samples/apps/use-all-bundled/feature-manifest.json +4 -4
  146. package/samples/apps/use-all-bundled/package.json +1 -0
  147. package/samples/apps/use-all-bundled/scripts/gen-sample-index.ts +7 -7
  148. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +3 -3
  149. package/samples/apps/use-all-bundled/src/app/screens-feature.ts +1 -1
  150. package/samples/apps/user-data-rights-demo/src/feature.ts +5 -5
  151. package/samples/recipes/access-control/src/feature.ts +21 -25
  152. package/samples/recipes/anonymous-access/src/feature.ts +36 -43
  153. package/samples/recipes/anonymous-access-multitenant/src/feature.ts +14 -16
  154. package/samples/recipes/auth-foundation-providers/src/feature.ts +1 -1
  155. package/samples/recipes/basic-entity/README.md +1 -1
  156. package/samples/recipes/boot-check/README.md +1 -1
  157. package/samples/recipes/boot-check/src/feature.ts +2 -2
  158. package/samples/recipes/chat-channels/src/feature.ts +1 -1
  159. package/samples/recipes/cross-feature-events/src/feature.ts +7 -7
  160. package/samples/recipes/currencies-global/src/feature.ts +3 -3
  161. package/samples/recipes/currencies-per-tenant/src/feature.ts +2 -2
  162. package/samples/recipes/custom-fields-basic/README.md +1 -1
  163. package/samples/recipes/custom-fields-basic/src/feature.ts +3 -2
  164. package/samples/recipes/custom-handlers/README.md +3 -3
  165. package/samples/recipes/custom-handlers/src/feature.ts +37 -41
  166. package/samples/recipes/dashboard-stats/src/feature.ts +6 -6
  167. package/samples/recipes/delivery-notifications/README.md +1 -1
  168. package/samples/recipes/delivery-notifications/src/feature.ts +8 -8
  169. package/samples/recipes/designer-demo/src/feature.ts +2 -1
  170. package/samples/recipes/embedded/src/feature.ts +1 -1
  171. package/samples/recipes/embedded-entity-form/src/feature.ts +13 -13
  172. package/samples/recipes/encrypted-tenant-config/src/feature.ts +1 -1
  173. package/samples/recipes/error-contract/src/feature.ts +25 -25
  174. package/samples/recipes/event-sourcing/src/feature.ts +70 -76
  175. package/samples/recipes/feature-toggles/src/feature.ts +28 -20
  176. package/samples/recipes/files-post-processing/src/feature.ts +22 -26
  177. package/samples/recipes/idempotency/src/feature.ts +16 -18
  178. package/samples/recipes/inbound-mail-triage/src/feature.ts +1 -1
  179. package/samples/recipes/lane-routing/src/feature.ts +10 -12
  180. package/samples/recipes/legal-pages/src/feature.ts +1 -1
  181. package/samples/recipes/lifecycle-hooks/README.md +4 -4
  182. package/samples/recipes/lifecycle-hooks/src/feature.ts +55 -35
  183. package/samples/recipes/managed-config/src/feature.ts +8 -8
  184. package/samples/recipes/managed-pages/src/feature.ts +1 -1
  185. package/samples/recipes/notes-history-basic/README.md +1 -1
  186. package/samples/recipes/notes-history-basic/src/feature.ts +2 -2
  187. package/samples/recipes/pdf-audit-export/src/feature.ts +7 -7
  188. package/samples/recipes/pipeline-basics/README.md +1 -1
  189. package/samples/recipes/pipeline-basics/src/feature.ts +7 -7
  190. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  191. package/samples/recipes/rate-limiting/src/feature.ts +7 -9
  192. package/samples/recipes/record-detail-layout/src/feature.ts +18 -18
  193. package/samples/recipes/reference-data/src/feature.ts +1 -1
  194. package/samples/recipes/relations/src/feature.ts +2 -2
  195. package/samples/recipes/screens-nav/src/feature.ts +1 -1
  196. package/samples/recipes/search/src/feature.ts +17 -19
  197. package/samples/recipes/security-baseline/README.md +1 -1
  198. package/samples/recipes/state-machine/src/feature.ts +1 -1
  199. package/samples/recipes/tags-basic/README.md +1 -1
  200. package/samples/recipes/tags-basic/src/feature.ts +2 -2
  201. package/samples/recipes/tenant-settings/README.md +1 -1
  202. package/samples/recipes/tenant-settings/src/feature.ts +1 -1
  203. package/samples/recipes/timezones/src/feature.ts +12 -12
  204. package/samples/recipes/user-data-rights/README.md +2 -2
  205. package/samples/recipes/user-data-rights/src/feature.ts +1 -1
  206. package/samples/recipes/user-profile/src/feature.ts +1 -1
  207. package/samples/recipes/webhook-step/src/feature.ts +1 -1
  208. package/samples/recipes/writeform-section/src/feature.ts +12 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.365.1",
3
+ "version": "0.367.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.365.1",
3
+ "version": "0.367.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -65,15 +65,15 @@ export function createAdminShellFeature(options: CreateAdminShellOptions = {}):
65
65
  category: "operations",
66
66
  recommended: false,
67
67
  });
68
- r.requires("user");
69
- r.requires("tenant");
70
- r.requires("audit");
71
- r.requires("jobs");
72
- r.requires("metrics");
73
- r.requires("metrics-system");
74
- if (includeTierAdmin) r.requires("tier-engine");
75
- if (includeCapOverview) r.requires("cap-overview");
76
- if (includeDocumentDesigner) r.requires("document-designer");
68
+ r.requires({ features: ["user"] });
69
+ r.requires({ features: ["tenant"] });
70
+ r.requires({ features: ["audit"] });
71
+ r.requires({ features: ["jobs"] });
72
+ r.requires({ features: ["metrics"] });
73
+ r.requires({ features: ["metrics-system"] });
74
+ if (includeTierAdmin) r.requires({ features: ["tier-engine"] });
75
+ if (includeCapOverview) r.requires({ features: ["cap-overview"] });
76
+ if (includeDocumentDesigner) r.requires({ features: ["document-designer"] });
77
77
 
78
78
  r.translations({ keys: ADMIN_SHELL_I18N });
79
79
 
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.366.0",
4
+ "type": "improvement",
5
+ "title": "Agent tool failures are classified instead of passing raw messages through",
6
+ "detail": "dispatchToolCall now adds a code to failed tool results. Client errors keep their message, rate limits also set retryable and retryAfterSeconds. Server errors and unknown throws are logged with their cause and returned to the model as \"Tool failed unexpectedly\", so internal details such as database messages no longer reach the model. ToolCallRequest accepts an optional log."
7
+ },
2
8
  {
3
9
  "version": "0.353.0",
4
10
  "type": "fix",
@@ -1,5 +1,7 @@
1
1
  import type { SessionUser, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { isKumikoError } from "@cosmicdrift/kumiko-framework/errors";
2
3
  import { runAsDirectCallEntry } from "@cosmicdrift/kumiko-framework/internal/request-context";
4
+ import { createFallbackLogger, type Logger } from "@cosmicdrift/kumiko-framework/logging";
3
5
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
4
6
  import { FILTER_OPS, isRecord } from "./filter-ops.js";
5
7
  import type { ToolDispatchDescriptor } from "./types.js";
@@ -23,7 +25,13 @@ export type ToolDispatcher = {
23
25
 
24
26
  export type ToolCallResult =
25
27
  | { readonly ok: true; readonly data: unknown }
26
- | { readonly ok: false; readonly error: string };
28
+ | {
29
+ readonly ok: false;
30
+ readonly error: string;
31
+ readonly code?: string;
32
+ readonly retryable?: boolean;
33
+ readonly retryAfterSeconds?: number;
34
+ };
27
35
 
28
36
  export type ToolCallRequest = {
29
37
  readonly dispatcher: ToolDispatcher;
@@ -37,8 +45,62 @@ export type ToolCallRequest = {
37
45
  /** Idempotency scope: `${runId}:${toolCallId}` becomes the write requestId. */
38
46
  readonly runId: string;
39
47
  readonly toolCallId: string;
48
+ /** Receives the cause of unexpected (5xx) failures, which the tool result never carries. */
49
+ readonly log?: Pick<Logger, "error">;
50
+ };
51
+
52
+ type ToolFailure = {
53
+ readonly code: string;
54
+ readonly httpStatus: number;
55
+ readonly message: string;
56
+ readonly retryAfterSeconds: number | undefined;
40
57
  };
41
58
 
59
+ type FailureClassifier = (failure: ToolFailure, cause: unknown) => ToolCallResult;
60
+
61
+ const HTTP_TOO_MANY_REQUESTS = 429;
62
+ const HTTP_SERVER_ERROR = 500;
63
+ const UNEXPECTED_FAILURE_MESSAGE = "Tool failed unexpectedly";
64
+ const fallbackLog = createFallbackLogger("agent-tools");
65
+
66
+ function toolFailureOf(err: unknown): ToolFailure {
67
+ if (isKumikoError(err)) {
68
+ return {
69
+ code: err.code,
70
+ httpStatus: err.httpStatus,
71
+ message: err.message,
72
+ retryAfterSeconds: err.retryAfterSeconds,
73
+ };
74
+ }
75
+ return {
76
+ code: "internal_error",
77
+ httpStatus: HTTP_SERVER_ERROR,
78
+ message: err instanceof Error ? err.message : String(err),
79
+ retryAfterSeconds: undefined,
80
+ };
81
+ }
82
+
83
+ function createFailureClassifier(toolName: string, log: Pick<Logger, "error">): FailureClassifier {
84
+ return (failure, cause) => {
85
+ if (failure.httpStatus === HTTP_TOO_MANY_REQUESTS) {
86
+ return {
87
+ ok: false,
88
+ error: failure.message,
89
+ code: failure.code,
90
+ retryable: true,
91
+ ...(failure.retryAfterSeconds !== undefined && {
92
+ retryAfterSeconds: failure.retryAfterSeconds,
93
+ }),
94
+ };
95
+ }
96
+ if (failure.httpStatus < HTTP_SERVER_ERROR) {
97
+ return { ok: false, error: failure.message, code: failure.code };
98
+ }
99
+ log.error("agent tool call failed unexpectedly", { toolName, code: failure.code, cause });
100
+ return { ok: false, error: UNEXPECTED_FAILURE_MESSAGE, code: failure.code };
101
+ };
102
+ }
103
+
42
104
  const SEARCH_RESULT_LIMIT = 10;
43
105
  const DEFAULT_LIST_LIMIT = 10;
44
106
  const MAX_LIST_LIMIT = 200;
@@ -216,6 +278,7 @@ async function dispatchServerWrite(
216
278
  input: Readonly<Record<string, unknown>>,
217
279
  runId: string,
218
280
  toolCallId: string,
281
+ classifyFailure: FailureClassifier,
219
282
  ): Promise<ToolCallResult> {
220
283
  let payload: Readonly<Record<string, unknown>> = input;
221
284
 
@@ -240,7 +303,15 @@ async function dispatchServerWrite(
240
303
  const result = await dispatcher.write(descriptor.qn, payload, user, requestId);
241
304
 
242
305
  if (!result.isSuccess) {
243
- return { ok: false, error: result.error.message };
306
+ return classifyFailure(
307
+ {
308
+ code: result.error.code,
309
+ httpStatus: result.error.httpStatus,
310
+ message: result.error.message,
311
+ retryAfterSeconds: result.error.retryAfterSeconds,
312
+ },
313
+ result.error,
314
+ );
244
315
  }
245
316
 
246
317
  const { id, version } = extractIdAndVersion(result.data);
@@ -343,10 +414,13 @@ function dispatchClient(
343
414
  /** Executes one LLM-issued tool call. Never throws — a missing tool, a missing argument, a
344
415
  * disallowed filter field, or a rejected handler call (cap check, validation, ...) all come
345
416
  * back as `{ ok: false }` so the agent loop can feed the error back to the model instead of
346
- * crashing. `request.user` must be the caller's real identity — every server-kind tool runs
417
+ * crashing. Client errors (< 500) keep their message and carry a `code`; 429 adds
418
+ * `retryable`. Server-side failures (>= 500, unknown throws) are logged and returned as a
419
+ * generic message so internals never reach the model. `request.user` must be the caller's real identity — every server-kind tool runs
347
420
  * through the same permission-checked dispatcher pipeline as a normal HTTP request. */
348
421
  export async function dispatchToolCall(request: ToolCallRequest): Promise<ToolCallResult> {
349
422
  const { dispatcher, user, toolName, input, dispatchTable, runId, toolCallId } = request;
423
+ const classifyFailure = createFailureClassifier(toolName, request.log ?? fallbackLog);
350
424
  const descriptor = dispatchTable.get(toolName);
351
425
  if (!descriptor) {
352
426
  return { ok: false, error: `Unknown tool "${toolName}"` };
@@ -366,10 +440,18 @@ export async function dispatchToolCall(request: ToolCallRequest): Promise<ToolCa
366
440
  case "server":
367
441
  return descriptor.op === "query"
368
442
  ? await dispatchServerQuery(descriptor, dispatcher, user, input)
369
- : await dispatchServerWrite(descriptor, dispatcher, user, input, runId, toolCallId);
443
+ : await dispatchServerWrite(
444
+ descriptor,
445
+ dispatcher,
446
+ user,
447
+ input,
448
+ runId,
449
+ toolCallId,
450
+ classifyFailure,
451
+ );
370
452
  }
371
453
  });
372
454
  } catch (err) {
373
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
455
+ return classifyFailure(toolFailureOf(err), err);
374
456
  }
375
457
  }
@@ -44,7 +44,7 @@ export function createAuditFeature(): FeatureDefinition {
44
44
  recommended: false,
45
45
  });
46
46
  r.translations({ keys: AUDIT_I18N });
47
- r.requires("tenant", "user", "config");
47
+ r.requires({ features: ["tenant", "user", "config"] });
48
48
 
49
49
  // Registered so ops tools/MSPs can discover the type — escape-hatch-audit-sink.ts appends it via the low-level event-store API.
50
50
  r.defineEvent("escape-hatch-used", escapeHatchUsedSchema, { piiFields: "none" });
@@ -1,4 +1,17 @@
1
1
  [
2
+ {
3
+ "version": "0.366.0",
4
+ "type": "fix",
5
+ "title": "Removed unused AuthHandlers.inviteCancel constant",
6
+ "detail": "AuthHandlers.inviteCancel pointed at auth-email-password:write:invite-cancel, a handler that never existed. The constant is removed.",
7
+ "migration": "If you referenced AuthHandlers.inviteCancel, use tenant:write:cancel-invitation."
8
+ },
9
+ {
10
+ "version": "0.366.0",
11
+ "type": "fix",
12
+ "title": "invite-create is no longer exposed to agents",
13
+ "detail": "The admin invite-create handler is now opted out of the agent tool catalog with agent: { expose: false }, like the other invite handlers, because it mails an accept link to an arbitrary address."
14
+ },
2
15
  {
3
16
  "version": "0.365.0",
4
17
  "type": "fix",
@@ -38,7 +38,6 @@ export const AuthHandlers = {
38
38
  // inviteAccept: logged-in user → POST token (membership-add)
39
39
  // inviteAcceptWithLogin: anon user with existing email → POST token + email + password
40
40
  // inviteSignupComplete: anon user with new email → POST token + password
41
- // inviteCancel: admin cancels a pending invite
42
41
  inviteCreate: "auth-email-password:write:invite-create",
43
42
  // System-only variant (access.system) that may carry global roles — see
44
43
  // invite-create.write.ts. Dispatched in-process by runBootstrap.
@@ -47,7 +46,6 @@ export const AuthHandlers = {
47
46
  inviteAcceptWithLogin: "auth-email-password:write:invite-accept-with-login",
48
47
  inviteSignupComplete: "auth-email-password:write:invite-signup-complete",
49
48
  switchTenantMfaGate: "auth-email-password:write:switch-tenant-mfa-gate",
50
- inviteCancel: "auth-email-password:write:invite-cancel",
51
49
  } as const;
52
50
 
53
51
  // Qualified query names. Anonymous-readable status so the (unauthenticated)
@@ -196,12 +196,12 @@ export function createAuthEmailPasswordFeature(
196
196
  { key: "accountUnlock", label: "Account-Unlock-Flow", type: "boolean", default: false },
197
197
  ],
198
198
  });
199
- r.requires("user");
200
- r.requires("tenant");
199
+ r.requires({ features: ["user"] });
200
+ r.requires({ features: ["tenant"] });
201
201
  // All five magic-link flows (reset/verify/signup/invite/unlock) dispatch
202
202
  // via ctx.notify → delivery must be mounted. Fail closed at boot instead
203
203
  // of silently dropping the mail.
204
- if (sendsMagicLinkMail(opts)) r.requires("delivery");
204
+ if (sendsMagicLinkMail(opts)) r.requires({ features: ["delivery"] });
205
205
  r.envSchema(authEmailPasswordEnvSchema);
206
206
  r.translations({ keys: AUTH_EMAIL_PASSWORD_MAIL_I18N });
207
207
 
@@ -292,6 +292,7 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
292
292
  name: "invite-create",
293
293
  schema: InviteCreateSchema,
294
294
  access: { roles: access.admin },
295
+ agent: { expose: false },
295
296
  description:
296
297
  "Invites an email address into the caller's tenant with a chosen membership role, creating or reusing the invitation and mailing the invitee a fresh accept link; re-inviting the same address invalidates the previous link.",
297
298
  handler: async (event, ctx) => {
@@ -128,12 +128,12 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
128
128
  // shared variable; an app mounting auth-mfa without `secrets` still gets the
129
129
  // KEK slot (and its _CIPHERTEXT twin) declared.
130
130
  r.envSchema(secretsEnvSchema);
131
- r.requires("user");
132
- r.requires("config");
131
+ r.requires({ features: ["user"] });
132
+ r.requires({ features: ["config"] });
133
133
  // verify.write.ts dispatches "tenant:query:memberships" — without this
134
134
  // the app boots clean and the first MFA login dies at runtime on an
135
135
  // unknown handler instead of failing at mount time.
136
- r.requires("tenant");
136
+ r.requires({ features: ["tenant"] });
137
137
  r.config("required", mfaRequiredConfigKey(opts.requiredPolicy));
138
138
 
139
139
  // Declarative secretMint screen — mint (no input) -> reveal QR/secret/
@@ -162,7 +162,7 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
162
162
  ),
163
163
  });
164
164
 
165
- r.entity("user-mfa", userMfaEntity);
165
+ r.entity({ name: "user-mfa", ...userMfaEntity });
166
166
 
167
167
  // Late-bound by run-prod-app once the sessions feature (if mounted) has
168
168
  // produced a concrete `sessionRevokeAllOthers` callback — mirrors
@@ -12,7 +12,7 @@ export const authMfaUserDataFeature = defineFeature("auth-mfa-user-data", (r) =>
12
12
  r.describe(
13
13
  "GDPR (Art. 20 export / Art. 17 erasure) coverage for the `auth-mfa` feature's `user-mfa` entity. Mounts the EXT_USER_DATA export + delete hooks so 2FA enrollment status is included in the user-data export bundle and a user's TOTP secret + recovery codes are hard-deleted (via executor.forget, rebuild-safe) on a data-subject erasure request. Kept separate from `auth-mfa` so consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `auth-mfa`.",
14
14
  );
15
- r.requires("user-data-rights");
15
+ r.requires({ features: ["user-data-rights"] });
16
16
  r.optionalRequires("auth-mfa");
17
17
  r.useExtension(EXT_USER_DATA, "user-mfa", {
18
18
  export: userMfaExportHook,
@@ -46,7 +46,7 @@ export function registerConsumerProtection(
46
46
  options: ResolvedBillingFoundationOptions,
47
47
  consumerProtection: ConsumerProtectionOptions,
48
48
  ): void {
49
- r.requires("template-resolver", "delivery", "user", "tenant");
49
+ r.requires({ features: ["template-resolver", "delivery", "user", "tenant"] });
50
50
  registerConsumerProtectionMails(r);
51
51
  registerContractTermination(r, options, consumerProtection);
52
52
  r.defineEvent(CHECKOUT_CONSENT_RECORDED_EVENT_SHORT, checkoutConsentRecordedPayloadSchema, {
@@ -143,7 +143,7 @@ export function createBillingFoundationFeature<TTier extends string = string>(
143
143
  category: "billing",
144
144
  recommended: false,
145
145
  });
146
- r.requires("tenant-lifecycle", "compliance-profiles");
146
+ r.requires({ features: ["tenant-lifecycle", "compliance-profiles"] });
147
147
  // 5 fine-grained domain-events. All 5 share the same payload shape (a
148
148
  // subscription-state snapshot); the event type tags what happened.
149
149
  // Future consumers (billing-history, accounting) listen directly on
@@ -1,4 +1,9 @@
1
- import { deleteMany, runInOwnTransaction, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
1
+ import {
2
+ deleteMany,
3
+ isUniqueViolation,
4
+ runInOwnTransaction,
5
+ type TenantDb,
6
+ } from "@cosmicdrift/kumiko-framework/db";
2
7
  import type { SessionUser, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
3
8
  import { reraiseAsKumikoError } from "@cosmicdrift/kumiko-framework/errors";
4
9
  import {
@@ -25,6 +30,7 @@ import {
25
30
  import {
26
31
  CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
27
32
  CAP_RESERVATION_TTL_MINUTES,
33
+ CAP_SETTLED_TOMBSTONE_TTL_MINUTES,
28
34
  ROLLING_INCREMENTED_EVENT_QN,
29
35
  ROLLING_RELEASED_EVENT_QN,
30
36
  } from "./constants.js";
@@ -38,6 +44,7 @@ type CapReservationRow = {
38
44
  readonly amount: number;
39
45
  readonly expiresAt: Temporal.Instant;
40
46
  readonly createdAt: Temporal.Instant;
47
+ readonly settledAt: Temporal.Instant | null;
41
48
  };
42
49
 
43
50
  const NOTHING_TO_RELEASE: WriteResult = { isSuccess: true, data: {} };
@@ -61,6 +68,7 @@ async function insertReservationRow(
61
68
  amount: draft.amount,
62
69
  expiresAt: now.add({ minutes: CAP_RESERVATION_TTL_MINUTES }),
63
70
  createdAt: now,
71
+ settledAt: null,
64
72
  });
65
73
  }
66
74
 
@@ -185,7 +193,11 @@ async function confirmCapReservation(
185
193
  confirm: ReservationConfirmContext,
186
194
  id: string,
187
195
  ): Promise<void> {
188
- await deleteMany(confirm.tx, capReservationsTable, { id, tenantId: confirm.tenantId });
196
+ await deleteMany(confirm.tx, capReservationsTable, {
197
+ id,
198
+ tenantId: confirm.tenantId,
199
+ settledAt: null,
200
+ });
189
201
  }
190
202
 
191
203
  async function giveBackCalendar(
@@ -229,7 +241,10 @@ async function giveBackReservation(
229
241
  id: string,
230
242
  ): Promise<WriteResult> {
231
243
  const ownRow = { id, tenantId: user.tenantId };
232
- const row = await txDb.fetchOne<CapReservationRow>(capReservationsTable, ownRow);
244
+ const row = await txDb.fetchOne<CapReservationRow>(capReservationsTable, {
245
+ ...ownRow,
246
+ settledAt: null,
247
+ });
233
248
  if (!row) return NOTHING_TO_RELEASE;
234
249
  const claimed = await claimReservationRow(txDb, user, row);
235
250
  if (!claimed) return NOTHING_TO_RELEASE;
@@ -242,7 +257,8 @@ async function giveBackReservation(
242
257
  return given;
243
258
  }
244
259
 
245
- // Waits for a confirm still holding the row; false means that confirm (or another release) won.
260
+ // Waits for a confirm still holding the row; false means that confirm, a settle or another release
261
+ // won (Postgres re-checks settled_at after the lock wait).
246
262
  async function claimReservationRow(
247
263
  txDb: TenantDb,
248
264
  user: SessionUser,
@@ -251,7 +267,7 @@ async function claimReservationRow(
251
267
  const claimed = await txDb.updateMany(
252
268
  capReservationsTable,
253
269
  { expiresAt: row.expiresAt },
254
- { id: row.id, tenantId: user.tenantId },
270
+ { id: row.id, tenantId: user.tenantId, settledAt: null },
255
271
  );
256
272
  return claimed.length > 0;
257
273
  }
@@ -291,8 +307,61 @@ function bookCalendarDelta(
291
307
  );
292
308
  }
293
309
 
294
- // Replaces the reserved amount by the actual one in a single transaction. When the row is already
295
- // gone (swept or released), the reserved amount was given back, so the full actual amount is booked.
310
+ async function settleInTransaction(
311
+ txDb: TenantDb,
312
+ user: SessionUser,
313
+ id: string,
314
+ options: SettleCalendarCapOptions,
315
+ ): Promise<WriteResult> {
316
+ const ownRow = { id, tenantId: user.tenantId };
317
+ const open = await txDb.fetchOne<CapReservationRow>(capReservationsTable, {
318
+ ...ownRow,
319
+ settledAt: null,
320
+ });
321
+ if (open && (await claimReservationRow(txDb, user, open))) {
322
+ const booked = await bookCalendarDelta(txDb, user, options, options.actualAmount - open.amount);
323
+ if (!booked.isSuccess) return booked;
324
+ const now = Temporal.Now.instant();
325
+ await txDb.updateMany(
326
+ capReservationsTable,
327
+ { settledAt: now, expiresAt: now.add({ minutes: CAP_SETTLED_TOMBSTONE_TTL_MINUTES }) },
328
+ ownRow,
329
+ );
330
+ return booked;
331
+ }
332
+ // The claim lost to a concurrent settle or release: a tombstone means the amount is booked.
333
+ if (await txDb.fetchOne<CapReservationRow>(capReservationsTable, ownRow)) {
334
+ return NOTHING_TO_RELEASE;
335
+ }
336
+ // Row swept or released: its amount was given back, so the full actual amount is booked and a
337
+ // tombstone makes a repeated settle a no-op. A parallel settle inserting first fails this insert.
338
+ const now = Temporal.Now.instant();
339
+ await txDb.insertOne(capReservationsTable, {
340
+ ...ownRow,
341
+ capName: options.capName,
342
+ kind: "calendar",
343
+ periodStartIso: options.periodStartIso,
344
+ amount: options.actualAmount,
345
+ expiresAt: now.add({ minutes: CAP_SETTLED_TOMBSTONE_TTL_MINUTES }),
346
+ createdAt: now,
347
+ settledAt: now,
348
+ });
349
+ return bookCalendarDelta(txDb, user, options, options.actualAmount);
350
+ }
351
+
352
+ // The failed tombstone insert aborts the whole attempt, so nothing was booked; the repeat sees the
353
+ // winner's tombstone.
354
+ async function retryOnceOnTombstoneRace(attempt: () => Promise<WriteResult>): Promise<WriteResult> {
355
+ try {
356
+ return await attempt();
357
+ } catch (error) {
358
+ if (!isUniqueViolation(error)) throw error;
359
+ return attempt();
360
+ }
361
+ }
362
+
363
+ // Replaces the reserved amount by the actual one in a single transaction and keeps the row as a
364
+ // settled tombstone, so a repeated settle (retry, lost ack) books nothing twice.
296
365
  export async function settleCalendarCapReservation(
297
366
  ctx: CapCounterContext,
298
367
  id: string,
@@ -306,41 +375,33 @@ export async function settleCalendarCapReservation(
306
375
  }
307
376
  const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
308
377
  const settled = await retryCounterWriteOnVersionConflict(() =>
309
- runInOwnTransaction(writeDb, async (txDb) => {
310
- const row = await txDb.fetchOne<CapReservationRow>(capReservationsTable, {
311
- id,
312
- tenantId: user.tenantId,
313
- });
314
- const claimed = row ? await claimReservationRow(txDb, user, row) : false;
315
- if (!row || !claimed) {
316
- return bookCalendarDelta(txDb, user, options, options.actualAmount);
317
- }
318
- const booked = await bookCalendarDelta(
319
- txDb,
320
- user,
321
- options,
322
- options.actualAmount - row.amount,
323
- );
324
- if (!booked.isSuccess) return booked;
325
- await txDb.deleteMany(capReservationsTable, { id, tenantId: user.tenantId });
326
- return booked;
327
- }),
378
+ retryOnceOnTombstoneRace(() =>
379
+ runInOwnTransaction(writeDb, (txDb) => settleInTransaction(txDb, user, id, options)),
380
+ ),
328
381
  );
329
382
  if (!settled.isSuccess) throw reraiseAsKumikoError(settled.error);
330
383
  }
331
384
 
332
385
  // A failed release leaves its row behind; giving it back once the TTL passed bounds how long that
333
- // capacity stays counted.
386
+ // capacity stays counted. Expired settled tombstones are only deleted: their amount stays booked.
334
387
  export async function releaseExpiredCapReservations(
335
388
  ctx: CapCounterContext,
336
389
  capName: string,
337
390
  outsideTransaction = true,
338
391
  ): Promise<void> {
339
392
  const { user, writeDb } = resolveCapCounterScope(ctx, outsideTransaction);
340
- const expired = await writeDb.selectMany<Pick<CapReservationRow, "id">>(capReservationsTable, {
393
+ const expiredWhere = {
341
394
  tenantId: user.tenantId,
342
395
  capName,
343
396
  expiresAt: { lt: Temporal.Now.instant() },
397
+ };
398
+ await writeDb.deleteMany(capReservationsTable, {
399
+ ...expiredWhere,
400
+ settledAt: { ne: null },
401
+ });
402
+ const expired = await writeDb.selectMany<Pick<CapReservationRow, "id">>(capReservationsTable, {
403
+ ...expiredWhere,
404
+ settledAt: null,
344
405
  });
345
406
  for (const { id } of expired) {
346
407
  try {
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.366.0",
4
+ "type": "fix",
5
+ "title": "cap-counter calendar settle is idempotent",
6
+ "detail": "Settling a calendar cap reservation now keeps the row as a tombstone (new column store_cap_reservations.settled_at, retained for 24 hours) instead of deleting it. A repeated settle with the same reservation id is a no-op, and confirm, give-back and expiry sweep ignore settled rows. The sweep deletes expired tombstones.",
7
+ "migration": "Apps with cap-counter generate a migration for the new column store_cap_reservations.settled_at (kumiko migrate generate)."
8
+ },
2
9
  {
3
10
  "version": "0.365.0",
4
11
  "type": "breaking",
@@ -28,6 +28,9 @@ export const ROLLING_RELEASED_EVENT_QN = "cap-counter:event:rolling-released" as
28
28
  // reservation for the same cap, which bounds the over-count a failed release can leave behind.
29
29
  export const CAP_RESERVATION_TTL_MINUTES = 60;
30
30
 
31
+ // A settled reservation stays as a tombstone this long so a retried or lost-ack settle stays a no-op.
32
+ export const CAP_SETTLED_TOMBSTONE_TTL_MINUTES = 24 * 60;
33
+
31
34
  // Qualified write handler names (QN format: scope:type:name).
32
35
  export const CapCounterHandlers = {
33
36
  increment: "cap-counter:write:increment",
@@ -1,7 +1,7 @@
1
1
  // cap-counter — Counter-Storage + Increment-API + Soft-Warn-State.
2
2
  //
3
3
  // **Was diese Feature liefert:**
4
- // 1. r.entity("cap-counter") — Counter-Rows pro (tenant, capName,
4
+ // 1. r.entity({ name: "cap-counter" }) — Counter-Rows pro (tenant, capName,
5
5
  // period) für Calendar-Period-Caps (Mails/Monat, Egress/Monat).
6
6
  // 2. increment-Handler — atomic counter increment via deterministic
7
7
  // aggregate-id (try-create / executor-update). Race-frei via
@@ -77,7 +77,7 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
77
77
  category: "operations",
78
78
  recommended: false,
79
79
  });
80
- r.entity("cap-counter", capCounterEntity);
80
+ r.entity({ name: "cap-counter", ...capCounterEntity });
81
81
  r.storeTable(capReservationsTableMeta, {
82
82
  reason: "coordination.cap_reservation_ledger",
83
83
  });
@@ -14,8 +14,9 @@ const CAP_RESERVATIONS_TENANT_CAP_EXPIRES_INDEX =
14
14
 
15
15
  // One row per capacity that was booked before a handler transaction and is not settled yet. The
16
16
  // handler transaction deletes it on its own commit (confirm); a release or the TTL sweep deletes it
17
- // together with the counter decrement. Unmanaged: the rows are a coordination aid, not state with
18
- // an event history.
17
+ // together with the counter decrement. A settle keeps the row as a tombstone (settled_at set) until
18
+ // its shortened TTL passes, so a repeated settle or a late release finds it and does nothing.
19
+ // Unmanaged: the rows are a coordination aid, not state with an event history.
19
20
  export const capReservationsTable = pgTable(
20
21
  "store_cap_reservations",
21
22
  {
@@ -28,6 +29,7 @@ export const capReservationsTable = pgTable(
28
29
  amount: integer("amount").notNull(),
29
30
  expiresAt: instant("expires_at").notNull(),
30
31
  createdAt: instant("created_at").notNull(),
32
+ settledAt: instant("settled_at"),
31
33
  },
32
34
  (t) => [index(CAP_RESERVATIONS_TENANT_CAP_EXPIRES_INDEX).on(t.tenantId, t.capName, t.expiresAt)],
33
35
  );
@@ -49,5 +51,6 @@ export const capReservationsTableMeta: EntityTableMeta = defineUnmanagedTable({
49
51
  { name: "amount", pgType: "integer", notNull: true },
50
52
  { name: "expires_at", pgType: "timestamptz", notNull: true },
51
53
  { name: "created_at", pgType: "timestamptz", notNull: true },
54
+ { name: "settled_at", pgType: "timestamptz", notNull: false },
52
55
  ],
53
56
  });
@@ -70,9 +70,9 @@ export function createCapOverviewFeature(opts: CreateCapOverviewOptions): Featur
70
70
  recommended: false,
71
71
  });
72
72
  r.systemScope();
73
- r.requires("tenant");
74
- r.requires("tier-engine");
75
- r.requires("billing-foundation");
73
+ r.requires({ features: ["tenant"] });
74
+ r.requires({ features: ["tier-engine"] });
75
+ r.requires({ features: ["billing-foundation"] });
76
76
 
77
77
  r.queryHandler(createTenantCapsListQuery(opts.caps, listCaps));
78
78
  r.queryHandler(createCapsUsageQuery(opts.caps, usageRoles));
@@ -24,8 +24,8 @@ export function createChannelDiscordFeature(
24
24
  category: "notifications",
25
25
  recommended: false,
26
26
  });
27
- r.requires("delivery");
28
- r.requires("secrets");
27
+ r.requires({ features: ["delivery"] });
28
+ r.requires({ features: ["secrets"] });
29
29
 
30
30
  const webhookTarget = resolveChatWebhookTarget(
31
31
  {
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.367.0",
4
+ "type": "improvement",
5
+ "title": "Email channel can send through the tenant's own mail provider",
6
+ "detail": "EmailChannelOptions gets tenantTransport, a resolver called per send with the delivery channel context. mail-foundation exports tenantMailTransport for it: a tenant with a provider configured sends through its own transport, a broken provider fails the send, a tenant without a provider uses the app transport. The transport is built per send and never cached, and the tenant comes from the server-side delivery context only. ChannelContext now carries the tenant's config accessor as config. Without tenantTransport nothing changes."
7
+ },
2
8
  {
3
9
  "version": "0.365.0",
4
10
  "type": "fix",