agent-relay-server 0.92.0 → 0.93.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 (157) hide show
  1. package/docs/openapi.json +29 -1
  2. package/package.json +2 -2
  3. package/public/assets/MessageBubble-DX16Hbb5.js +3 -0
  4. package/public/assets/MessageBubble-DX16Hbb5.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-CpIBMvnu.js → PlanGraphPanel-BuWerKhs.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CpIBMvnu.js.map → PlanGraphPanel-BuWerKhs.js.map} +1 -1
  7. package/public/assets/activity-tuTEn3vn.js +2 -0
  8. package/public/assets/{activity-B6WPWNsX.js.map → activity-tuTEn3vn.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CLJNeVxc.js → agent-profiles-CAwmjdDx.js} +2 -2
  10. package/public/assets/{agent-profiles-CLJNeVxc.js.map → agent-profiles-CAwmjdDx.js.map} +1 -1
  11. package/public/assets/agents-B-BSBrM5.js +2 -0
  12. package/public/assets/{agents-BebF-yQr.js.map → agents-B-BSBrM5.js.map} +1 -1
  13. package/public/assets/{analytics-C1F28oqr.js → analytics-aI_oGHtR.js} +3 -3
  14. package/public/assets/{analytics-C1F28oqr.js.map → analytics-aI_oGHtR.js.map} +1 -1
  15. package/public/assets/{automation-CyPXC1A8.js → automation-BAQM1dBh.js} +2 -2
  16. package/public/assets/{automation-CyPXC1A8.js.map → automation-BAQM1dBh.js.map} +1 -1
  17. package/public/assets/{badge-CK6ejiOh.js → badge-fjSgjnDc.js} +2 -2
  18. package/public/assets/{badge-CK6ejiOh.js.map → badge-fjSgjnDc.js.map} +1 -1
  19. package/public/assets/branch-state-badge-DaafW7sN.js +2 -0
  20. package/public/assets/{branch-state-badge-Q4W68jxj.js.map → branch-state-badge-DaafW7sN.js.map} +1 -1
  21. package/public/assets/{button-BAwKuRIq.js → button-DidMOp8n.js} +2 -2
  22. package/public/assets/{button-BAwKuRIq.js.map → button-DidMOp8n.js.map} +1 -1
  23. package/public/assets/card-CpLnx1JJ.js +2 -0
  24. package/public/assets/{card-DDxxo-Fo.js.map → card-CpLnx1JJ.js.map} +1 -1
  25. package/public/assets/{channels-BQbNtrtF.js → channels-DHjTCcE8.js} +2 -2
  26. package/public/assets/{channels-BQbNtrtF.js.map → channels-DHjTCcE8.js.map} +1 -1
  27. package/public/assets/chat-uWKl3MpS.js +2 -0
  28. package/public/assets/{chat-DfqC3kFF.js.map → chat-uWKl3MpS.js.map} +1 -1
  29. package/public/assets/{connectors-CmARSKoV.js → connectors-CI97PYOl.js} +2 -2
  30. package/public/assets/{connectors-CmARSKoV.js.map → connectors-CI97PYOl.js.map} +1 -1
  31. package/public/assets/copy-button-CKAwTG9g.js +2 -0
  32. package/public/assets/{copy-button-D3B_s7J7.js.map → copy-button-CKAwTG9g.js.map} +1 -1
  33. package/public/assets/{dist-Dmiv1tSO.js → dist-BEpGArlE.js} +2 -2
  34. package/public/assets/{dist-Dmiv1tSO.js.map → dist-BEpGArlE.js.map} +1 -1
  35. package/public/assets/dist-BZ-CpDXy.js +2 -0
  36. package/public/assets/{dist-CIbvPDQV.js.map → dist-BZ-CpDXy.js.map} +1 -1
  37. package/public/assets/{dist-DLsSqk6h.js → dist-BaDWyAAG.js} +2 -2
  38. package/public/assets/{dist-DLsSqk6h.js.map → dist-BaDWyAAG.js.map} +1 -1
  39. package/public/assets/{dist-CAFUEpK2.js → dist-BivRSJg7.js} +2 -2
  40. package/public/assets/{dist-CAFUEpK2.js.map → dist-BivRSJg7.js.map} +1 -1
  41. package/public/assets/{dist-CL2npVDe.js → dist-C9ntDbUp.js} +2 -2
  42. package/public/assets/{dist-CL2npVDe.js.map → dist-C9ntDbUp.js.map} +1 -1
  43. package/public/assets/dist-CEb4q1gZ.js +2 -0
  44. package/public/assets/{dist-DyqEeyBI.js.map → dist-CEb4q1gZ.js.map} +1 -1
  45. package/public/assets/dist-DBDYIgS5.js +2 -0
  46. package/public/assets/{dist-CoRABSO3.js.map → dist-DBDYIgS5.js.map} +1 -1
  47. package/public/assets/{dist-ERYvDMdt.js → dist-DNxK5Ibj.js} +2 -2
  48. package/public/assets/{dist-ERYvDMdt.js.map → dist-DNxK5Ibj.js.map} +1 -1
  49. package/public/assets/{dist-YjP8Gd1C.js → dist-DayffsX1.js} +2 -2
  50. package/public/assets/{dist-YjP8Gd1C.js.map → dist-DayffsX1.js.map} +1 -1
  51. package/public/assets/{dist-YQVQrtnc.js → dist-L3vQoV3T.js} +2 -2
  52. package/public/assets/{dist-YQVQrtnc.js.map → dist-L3vQoV3T.js.map} +1 -1
  53. package/public/assets/{dist-CnRv0y2x.js → dist-uFOP-AfS.js} +2 -2
  54. package/public/assets/{dist-CnRv0y2x.js.map → dist-uFOP-AfS.js.map} +1 -1
  55. package/public/assets/{es2015-Yto-4EBw.js → es2015-CJr6bLcU.js} +2 -2
  56. package/public/assets/{es2015-Yto-4EBw.js.map → es2015-CJr6bLcU.js.map} +1 -1
  57. package/public/assets/{formatted-body-impl-CrE-hFvy.js → formatted-body-impl-DuqZunxL.js} +2 -2
  58. package/public/assets/{formatted-body-impl-CrE-hFvy.js.map → formatted-body-impl-DuqZunxL.js.map} +1 -1
  59. package/public/assets/index-DLjkMB-b.css +2 -0
  60. package/public/assets/index-dMB8klzy.js +23 -0
  61. package/public/assets/index-dMB8klzy.js.map +1 -0
  62. package/public/assets/{input-CLW2gDUM.js → input-BbH2Jq-f.js} +2 -2
  63. package/public/assets/{input-CLW2gDUM.js.map → input-BbH2Jq-f.js.map} +1 -1
  64. package/public/assets/{insights-BribQHXe.js → insights-C0lZ7n8D.js} +2 -2
  65. package/public/assets/{insights-BribQHXe.js.map → insights-C0lZ7n8D.js.map} +1 -1
  66. package/public/assets/integrations-DXh00Ss-.js +2 -0
  67. package/public/assets/{integrations-D9Cx-7ma.js.map → integrations-DXh00Ss-.js.map} +1 -1
  68. package/public/assets/{lib-DdCM7GfN.js → lib-B0r9alsu.js} +2 -2
  69. package/public/assets/{lib-DdCM7GfN.js.map → lib-B0r9alsu.js.map} +1 -1
  70. package/public/assets/{lucide-react-CsPXvXMC.js → lucide-react-BCarrGVa.js} +2 -2
  71. package/public/assets/{lucide-react-CsPXvXMC.js.map → lucide-react-BCarrGVa.js.map} +1 -1
  72. package/public/assets/{maintenance-Cjol3fS_.js → maintenance-Dh9nzjYV.js} +2 -2
  73. package/public/assets/{maintenance-Cjol3fS_.js.map → maintenance-Dh9nzjYV.js.map} +1 -1
  74. package/public/assets/{managed-agents-1lZwQNHl.js → managed-agents-Bsveljn4.js} +4 -4
  75. package/public/assets/{managed-agents-1lZwQNHl.js.map → managed-agents-Bsveljn4.js.map} +1 -1
  76. package/public/assets/{markdown-preview-impl-C2WeUNFR.js → markdown-preview-impl-CYpMFis9.js} +2 -2
  77. package/public/assets/{markdown-preview-impl-C2WeUNFR.js.map → markdown-preview-impl-CYpMFis9.js.map} +1 -1
  78. package/public/assets/{memory-B7mcELEW.js → memory-DMFQMXrp.js} +2 -2
  79. package/public/assets/{memory-B7mcELEW.js.map → memory-DMFQMXrp.js.map} +1 -1
  80. package/public/assets/messages-Cnzc7j50.js +2 -0
  81. package/public/assets/{messages-B8jF9p2l.js.map → messages-Cnzc7j50.js.map} +1 -1
  82. package/public/assets/{orchestrators-CQjvBXDx.js → orchestrators-DSoDPNVS.js} +2 -2
  83. package/public/assets/{orchestrators-CQjvBXDx.js.map → orchestrators-DSoDPNVS.js.map} +1 -1
  84. package/public/assets/{overview-CgDUJOG-.js → overview-OsRxo5XQ.js} +2 -2
  85. package/public/assets/{overview-CgDUJOG-.js.map → overview-OsRxo5XQ.js.map} +1 -1
  86. package/public/assets/{pairs-BdPKQyzE.js → pairs-D4UiUhyW.js} +2 -2
  87. package/public/assets/{pairs-BdPKQyzE.js.map → pairs-D4UiUhyW.js.map} +1 -1
  88. package/public/assets/{projects-hU5KGW_T.js → projects-CYzU-Cf3.js} +2 -2
  89. package/public/assets/{projects-hU5KGW_T.js.map → projects-CYzU-Cf3.js.map} +1 -1
  90. package/public/assets/{react-dom-CEl58oxP.js → react-dom-CYpnckci.js} +2 -2
  91. package/public/assets/{react-dom-CEl58oxP.js.map → react-dom-CYpnckci.js.map} +1 -1
  92. package/public/assets/{security-BlZQ0ECI.js → security-CC2F6xdd.js} +2 -2
  93. package/public/assets/{security-BlZQ0ECI.js.map → security-CC2F6xdd.js.map} +1 -1
  94. package/public/assets/{select-CjpI0JtN.js → select-CD38f1WQ.js} +2 -2
  95. package/public/assets/{select-CjpI0JtN.js.map → select-CD38f1WQ.js.map} +1 -1
  96. package/public/assets/settings-T3B40Amb.js +6 -0
  97. package/public/assets/settings-T3B40Amb.js.map +1 -0
  98. package/public/assets/{store-DsqUEZOf.js → store-CJeafbP1.js} +3 -3
  99. package/public/assets/{store-DsqUEZOf.js.map → store-CJeafbP1.js.map} +1 -1
  100. package/public/assets/{tasks-BcHWptn4.js → tasks-BkrhWMw7.js} +2 -2
  101. package/public/assets/{tasks-BcHWptn4.js.map → tasks-BkrhWMw7.js.map} +1 -1
  102. package/public/assets/{teams-BGL-VpqZ.js → teams-DyzaREAg.js} +2 -2
  103. package/public/assets/{teams-BGL-VpqZ.js.map → teams-DyzaREAg.js.map} +1 -1
  104. package/public/assets/{terminal-viewer-impl-D8bnx7Z7.js → terminal-viewer-impl-B5JeHof3.js} +3 -3
  105. package/public/assets/{terminal-viewer-impl-D8bnx7Z7.js.map → terminal-viewer-impl-B5JeHof3.js.map} +1 -1
  106. package/public/assets/{use-keyboard-viewport-DABeIMEJ.js → use-keyboard-viewport-BYCJkZCN.js} +2 -2
  107. package/public/assets/{use-keyboard-viewport-DABeIMEJ.js.map → use-keyboard-viewport-BYCJkZCN.js.map} +1 -1
  108. package/public/assets/user-avatar-C7i11_ju.js +2 -0
  109. package/public/assets/{user-avatar-DZ1g4X7l.js.map → user-avatar-C7i11_ju.js.map} +1 -1
  110. package/public/assets/{work-queue-CIy6fe6y.js → work-queue-DHp4p1Zk.js} +2 -2
  111. package/public/assets/{work-queue-CIy6fe6y.js.map → work-queue-DHp4p1Zk.js.map} +1 -1
  112. package/public/assets/workspaces-BzwZvbsq.js +3 -0
  113. package/public/assets/{workspaces-C740m1ys.js.map → workspaces-BzwZvbsq.js.map} +1 -1
  114. package/public/index.html +21 -21
  115. package/src/agent-lifecycle-events.ts +0 -10
  116. package/src/branch-landed.ts +17 -29
  117. package/src/config-store.ts +13 -26
  118. package/src/context-advisory.ts +20 -10
  119. package/src/db/migrations.ts +17 -0
  120. package/src/db/scheduled-timers.ts +33 -12
  121. package/src/db/schema.ts +4 -0
  122. package/src/mcp/index.ts +1 -1
  123. package/src/mcp/tools-messaging.ts +13 -3
  124. package/src/mcp-notification-subscription-tools.ts +55 -2
  125. package/src/notification-router.ts +163 -9
  126. package/src/notification-rules.ts +7 -1
  127. package/src/notification-settings-store.ts +47 -0
  128. package/src/notification-settings.ts +155 -0
  129. package/src/notification-types.ts +2 -0
  130. package/src/quota-advisory.ts +0 -3
  131. package/src/routes/index.ts +2 -0
  132. package/src/routes/notifications.ts +36 -0
  133. package/src/routes/settings.ts +6 -0
  134. package/src/security.ts +1 -0
  135. package/src/services/scheduler.ts +29 -1
  136. package/src/settings/registry.ts +30 -44
  137. package/src/sse.ts +1 -0
  138. package/public/assets/MessageBubble-CUddRP1E.js +0 -3
  139. package/public/assets/MessageBubble-CUddRP1E.js.map +0 -1
  140. package/public/assets/activity-B6WPWNsX.js +0 -2
  141. package/public/assets/agents-BebF-yQr.js +0 -2
  142. package/public/assets/branch-state-badge-Q4W68jxj.js +0 -2
  143. package/public/assets/card-DDxxo-Fo.js +0 -2
  144. package/public/assets/chat-DfqC3kFF.js +0 -2
  145. package/public/assets/copy-button-D3B_s7J7.js +0 -2
  146. package/public/assets/dist-CIbvPDQV.js +0 -2
  147. package/public/assets/dist-CoRABSO3.js +0 -2
  148. package/public/assets/dist-DyqEeyBI.js +0 -2
  149. package/public/assets/index-CdDX5NR1.js +0 -23
  150. package/public/assets/index-CdDX5NR1.js.map +0 -1
  151. package/public/assets/index-umexz3cs.css +0 -2
  152. package/public/assets/integrations-D9Cx-7ma.js +0 -2
  153. package/public/assets/messages-B8jF9p2l.js +0 -2
  154. package/public/assets/settings-CMgqpUY-.js +0 -4
  155. package/public/assets/settings-CMgqpUY-.js.map +0 -1
  156. package/public/assets/user-avatar-DZ1g4X7l.js +0 -2
  157. package/public/assets/workspaces-C740m1ys.js +0 -3
@@ -1,7 +1,11 @@
1
1
  import { notifySystemMessage } from "./notify";
2
- import type { Message, MessageKind } from "./types";
2
+ import { createActivityEvent, getAgent, getMessage } from "./db";
3
+ import { subscriptionDecisionFor } from "./db/notification-subscriptions";
4
+ import { notificationEnabledFor } from "./notification-settings-store";
5
+ import type { Message, MessageKind, NotificationRoutingExplanation, NotificationRoutingPreview, NotificationRoutingRecipientInput } from "./types";
3
6
  import { DEFAULT_TTL_MS } from "./notification-types";
4
7
  import type { NotificationScope, NotificationType } from "./notification-types";
8
+ import { resolveAudience } from "./notification-rules";
5
9
 
6
10
  export {
7
11
  DEFAULT_TTL_MS,
@@ -44,7 +48,7 @@ interface RouteNotificationInput {
44
48
  * durable targets (`policy:`, `team:`, `label:`, `broadcast`, …) — the delivery layer resolves
45
49
  * durable forms at poll time. #442 will derive this set from `scope` via the rule engine.
46
50
  */
47
- recipients: Array<string | undefined | null>;
51
+ recipients?: Array<string | NotificationRoutingRecipientInput | undefined | null>;
48
52
  message: NotificationMessage;
49
53
  /**
50
54
  * #451 — time-to-live in ms from `emittedAt`, after which the notification is stale and must not
@@ -82,8 +86,8 @@ export function routeNotification(input: RouteNotificationInput): Message[] {
82
86
  // router correct for any caller that routes a delayed/replayed event.
83
87
  if (ttl !== null && Date.now() - emittedAt > ttl) return [];
84
88
 
85
- const recipients = [...new Set(input.recipients.filter((r): r is string => Boolean(r && r.trim())))];
86
- if (recipients.length === 0) return [];
89
+ const preview = previewNotificationRouting(input);
90
+ if (preview.recipients.length === 0) return [];
87
91
 
88
92
  // Stamp the TTL on the message so the delivery/poll path (pollMessages) enforces it for offline
89
93
  // or late-joining recipients too. An explicit `null` (never-expire) is authoritative: it suppresses
@@ -91,16 +95,166 @@ export function routeNotification(input: RouteNotificationInput): Message[] {
91
95
  // never-expire intent instead of inheriting the moderate baseline.
92
96
  const maxAgeSeconds = ttl === null ? null : Math.max(1, Math.ceil(ttl / 1000));
93
97
 
94
- return recipients.map((to) =>
95
- notifySystemMessage(to, {
98
+ return preview.recipients.map((routing) => {
99
+ const message = notifySystemMessage(routing.recipient, {
96
100
  subject: input.message.subject,
97
101
  body: input.message.body,
98
- payload: input.message.payload,
102
+ payload: { ...(input.message.payload ?? {}), routing: compactRoutingExplanation(routing) },
99
103
  idempotencyKey: input.message.idempotencyKey,
100
104
  kind: input.message.kind,
101
105
  from: input.message.from,
102
106
  replyExpected: input.message.replyExpected,
103
107
  maxAgeSeconds,
104
- }),
105
- );
108
+ });
109
+ auditRoutingTrace(message, preview, maxAgeSeconds);
110
+ return message;
111
+ });
112
+ }
113
+
114
+ export function previewNotificationRouting(input: Pick<RouteNotificationInput, "type" | "scope" | "recipients">): NotificationRoutingPreview {
115
+ const candidates = normalizeRoutingRecipients(input);
116
+ const recipients: NotificationRoutingExplanation[] = [];
117
+ const suppressed: NotificationRoutingExplanation[] = [];
118
+ for (const candidate of candidates) {
119
+ const settingsEnabled = notificationRecipientEnabled(input.type, candidate.agentId, input.scope);
120
+ const subscriptionState = subscriptionStateFor(input.type, candidate.agentId, input.scope);
121
+ const matchedRule = candidate.matchedRule || inferMatchedRule(candidate.agentId, input.scope);
122
+ const explanation: NotificationRoutingExplanation = {
123
+ type: input.type,
124
+ recipient: candidate.agentId,
125
+ matchedRule,
126
+ reason: candidate.reason || routingReason(matchedRule, candidate.agentId, input.scope, subscriptionState, settingsEnabled),
127
+ scope: input.scope as Record<string, unknown>,
128
+ subscriptionState,
129
+ settingsEnabled,
130
+ };
131
+ if (settingsEnabled) recipients.push(explanation);
132
+ else suppressed.push({ ...explanation, matchedRule: "settings:notifications" });
133
+ }
134
+ return { type: input.type, scope: input.scope as Record<string, unknown>, recipients, suppressed };
135
+ }
136
+
137
+ export function whyNotified(messageId: number, agentId: string): (NotificationRoutingExplanation & { messageId: number; agentId: string }) | null {
138
+ const message = getMessage(messageId);
139
+ if (!message) return null;
140
+ const routing = routingExplanationFromPayload(message.payload?.routing);
141
+ if (!routing) return null;
142
+ if (routing.recipient !== agentId && message.to !== agentId && message.resolvedToAgent !== agentId) return null;
143
+ return { ...routing, messageId, agentId };
144
+ }
145
+
146
+ function notificationRecipientEnabled(type: NotificationType, recipient: string, scope: NotificationScope): boolean {
147
+ const agent = getAgent(recipient);
148
+ return notificationEnabledFor(type, {
149
+ agentId: agent?.id ?? recipient,
150
+ teamId: agent?.teamId ?? scope.teamId,
151
+ });
152
+ }
153
+
154
+ function normalizeRoutingRecipients(input: Pick<RouteNotificationInput, "type" | "scope" | "recipients">): NotificationRoutingRecipientInput[] {
155
+ const raw = input.recipients === undefined
156
+ ? resolveAudience(input.type, input.scope).map((member) => ({ agentId: member.agentId, matchedRule: member.matchedRule }))
157
+ : input.recipients;
158
+ const byAgent = new Map<string, NotificationRoutingRecipientInput>();
159
+ for (const item of raw) {
160
+ const candidate = normalizeRoutingRecipient(item);
161
+ if (!candidate || byAgent.has(candidate.agentId)) continue;
162
+ byAgent.set(candidate.agentId, candidate);
163
+ }
164
+ return [...byAgent.values()];
165
+ }
166
+
167
+ function normalizeRoutingRecipient(value: string | NotificationRoutingRecipientInput | null | undefined): NotificationRoutingRecipientInput | null {
168
+ if (typeof value === "string") {
169
+ const agentId = value.trim();
170
+ return agentId ? { agentId } : null;
171
+ }
172
+ if (!value) return null;
173
+ const agentId = typeof value.agentId === "string" ? value.agentId.trim() : "";
174
+ if (!agentId) return null;
175
+ return {
176
+ agentId,
177
+ matchedRule: typeof value.matchedRule === "string" && value.matchedRule.trim() ? value.matchedRule.trim() : undefined,
178
+ reason: typeof value.reason === "string" && value.reason.trim() ? value.reason.trim() : undefined,
179
+ };
180
+ }
181
+
182
+ function inferMatchedRule(recipient: string, scope: NotificationScope): string {
183
+ if (scope.author && recipient === scope.author) return "author";
184
+ if (scope.parent && recipient === scope.parent) return "spawn-parent";
185
+ if (scope.agentId && recipient === scope.agentId) return "subject-agent";
186
+ const agent = getAgent(recipient);
187
+ if (scope.teamId && agent?.teamId === scope.teamId) return "same-team";
188
+ return "explicit-recipient";
189
+ }
190
+
191
+ function subscriptionStateFor(type: NotificationType, recipient: string, scope: NotificationScope): NotificationRoutingExplanation["subscriptionState"] {
192
+ const agent = getAgent(recipient);
193
+ if (!agent) return "unknown";
194
+ return subscriptionDecisionFor(agent, { type, scope, now: Date.now() });
195
+ }
196
+
197
+ function routingReason(
198
+ matchedRule: string,
199
+ recipient: string,
200
+ scope: NotificationScope,
201
+ subscriptionState: NotificationRoutingExplanation["subscriptionState"],
202
+ settingsEnabled: boolean,
203
+ ): string {
204
+ if (!settingsEnabled) return "notifications are disabled for this recipient by settings";
205
+ if (matchedRule === "author") return "you are the author";
206
+ if (matchedRule === "spawn-parent") return "you are the spawn parent";
207
+ if (matchedRule === "subject-agent") return "you are the notification subject";
208
+ if (matchedRule === "same-team") return scope.teamId ? `same team ${scope.teamId}` : "same team";
209
+ if (matchedRule === "on-repo-root") return scope.repoRoot ? `online in repo root ${scope.repoRoot}` : "online in the repo root";
210
+ if (matchedRule === "subscription:store" || subscriptionState === "deliver") return "explicit subscription matched";
211
+ if (matchedRule === "settings:notifications") return "notifications are disabled for this recipient by settings";
212
+ return `explicit recipient ${recipient}`;
213
+ }
214
+
215
+ function compactRoutingExplanation(routing: NotificationRoutingExplanation): NotificationRoutingExplanation {
216
+ return routing;
217
+ }
218
+
219
+ function routingExplanationFromPayload(value: unknown): NotificationRoutingExplanation | null {
220
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
221
+ const record = value as Record<string, unknown>;
222
+ if (typeof record.type !== "string" || typeof record.recipient !== "string" || typeof record.matchedRule !== "string") return null;
223
+ return {
224
+ type: record.type as NotificationType,
225
+ recipient: record.recipient,
226
+ matchedRule: record.matchedRule,
227
+ reason: typeof record.reason === "string" ? record.reason : "",
228
+ scope: record.scope && typeof record.scope === "object" && !Array.isArray(record.scope) ? record.scope as Record<string, unknown> : {},
229
+ subscriptionState: record.subscriptionState === "deliver" || record.subscriptionState === "suppress" || record.subscriptionState === "abstain"
230
+ ? record.subscriptionState
231
+ : "unknown",
232
+ settingsEnabled: record.settingsEnabled !== false,
233
+ };
234
+ }
235
+
236
+ function auditRoutingTrace(message: Message, preview: NotificationRoutingPreview, maxAgeSeconds: number | null): void {
237
+ try {
238
+ createActivityEvent({
239
+ kind: "state",
240
+ title: "Notification routed",
241
+ body: preview.recipients.find((item) => item.recipient === message.to)?.reason,
242
+ icon: "ti-route",
243
+ view: "messages",
244
+ messageId: message.id,
245
+ agentId: message.to,
246
+ metadata: {
247
+ source: "notification-router",
248
+ notificationType: preview.type,
249
+ scope: preview.scope as Record<string, unknown>,
250
+ routing: {
251
+ delivered: preview.recipients,
252
+ suppressed: preview.suppressed,
253
+ maxAgeSeconds,
254
+ },
255
+ },
256
+ });
257
+ } catch {
258
+ // Routing audit must never block notification delivery.
259
+ }
106
260
  }
@@ -1,6 +1,7 @@
1
1
  import type { AgentCard } from "./types";
2
2
  import { listAgents } from "./db";
3
3
  import { isAgentOnline } from "./agent-ref";
4
+ import { notificationEnabledFor } from "./notification-settings-store";
4
5
  import type { NotificationType, NotificationScope } from "./notification-types";
5
6
 
6
7
  /**
@@ -154,6 +155,11 @@ export function registerDefaultAudienceRules(type: NotificationType, rules: Noti
154
155
  // #443 subscription seam: opt-in / opt-out / mute become override rules injected here. They run AFTER
155
156
  // a type's default rules so they win the last-wins fold. Empty until #443 wires a subscription store.
156
157
  let subscriptionRules: NotificationRule[] = [];
158
+ const settingsRule: NotificationRule = {
159
+ name: "settings:notifications",
160
+ evaluate: (agent, { type, scope }) =>
161
+ notificationEnabledFor(type, { agentId: agent.id, teamId: agent.teamId ?? scope.teamId }) ? "abstain" : "suppress",
162
+ };
157
163
 
158
164
  /** #443 hook: install the subscription override rules (opt-in/opt-out/mute). Pass [] to clear. */
159
165
  export function setSubscriptionRules(rules: NotificationRule[]): void {
@@ -164,7 +170,7 @@ export function setSubscriptionRules(rules: NotificationRule[]): void {
164
170
  function audienceRulesFor(type: NotificationType): NotificationRule[] {
165
171
  const defaults = audienceRegistry.get(type);
166
172
  if (!defaults) return [];
167
- return subscriptionRules.length === 0 ? defaults : [...defaults, ...subscriptionRules];
173
+ return [...defaults, ...subscriptionRules, settingsRule];
168
174
  }
169
175
 
170
176
  /**
@@ -0,0 +1,47 @@
1
+ import { getConfig, getNotificationsConfig, listConfig } from "./config-store";
2
+ import {
3
+ NOTIFICATIONS_DEFAULT_KEY,
4
+ NOTIFICATIONS_NAMESPACE,
5
+ mergeNotificationSettings,
6
+ notificationAgentKey,
7
+ notificationTeamKey,
8
+ notificationTypeEnabled,
9
+ parseNotificationSettingsKey,
10
+ validateNotificationSettingsLayer,
11
+ type NotificationSettingsTarget,
12
+ type NotificationSettingLayer,
13
+ } from "./notification-settings";
14
+ import { registerNotificationSettingDescriptor } from "./settings/registry";
15
+ import type { NotificationsConfig } from "./types";
16
+ import type { NotificationType } from "./notification-types";
17
+
18
+ export function ensureNotificationSettingDescriptors(extraKeys: string[] = []): void {
19
+ const keys = new Set<string>([NOTIFICATIONS_DEFAULT_KEY]);
20
+ for (const entry of listConfig(NOTIFICATIONS_NAMESPACE)) keys.add(entry.key);
21
+ for (const key of extraKeys) keys.add(key);
22
+
23
+ for (const key of keys) {
24
+ if (!parseNotificationSettingsKey(key)) continue;
25
+ registerNotificationSettingDescriptor(key);
26
+ }
27
+ }
28
+
29
+ export function getResolvedNotificationsConfig(target: NotificationSettingsTarget = {}): NotificationsConfig {
30
+ let config = getNotificationsConfig();
31
+ if (target.teamId) {
32
+ config = mergeNotificationSettings(config, notificationSettingsLayer(notificationTeamKey(target.teamId)));
33
+ }
34
+ if (target.agentId) {
35
+ config = mergeNotificationSettings(config, notificationSettingsLayer(notificationAgentKey(target.agentId)));
36
+ }
37
+ return config;
38
+ }
39
+
40
+ export function notificationEnabledFor(type: NotificationType, target: NotificationSettingsTarget = {}): boolean {
41
+ return notificationTypeEnabled(getResolvedNotificationsConfig(target), type);
42
+ }
43
+
44
+ function notificationSettingsLayer(key: string): NotificationSettingLayer | null {
45
+ const entry = getConfig<NotificationSettingLayer>(NOTIFICATIONS_NAMESPACE, key);
46
+ return entry ? validateNotificationSettingsLayer(entry.value) : null;
47
+ }
@@ -0,0 +1,155 @@
1
+ import { isRecord } from "agent-relay-sdk";
2
+ import type { JSONSchema } from "agent-relay-sdk";
3
+ import { ValidationError } from "./db/connection.ts";
4
+ import { DEFAULT_CONTEXT_THRESHOLD_CONFIG, validateContextThresholdConfig } from "./context-threshold-config";
5
+ import { DEFAULT_QUOTA_THRESHOLD_CONFIG, validateQuotaThresholdConfig } from "./quota-threshold-config";
6
+ import type { NotificationsConfig } from "./types";
7
+ import type { NotificationType } from "./notification-types";
8
+
9
+ export const NOTIFICATIONS_NAMESPACE = "notifications";
10
+ export const NOTIFICATIONS_DEFAULT_KEY = "default";
11
+ const TEAM_KEY_PREFIX = "team:";
12
+ const AGENT_KEY_PREFIX = "agent:";
13
+
14
+ type NotificationToggleKey = "branchLanded" | "agentReady" | "agentExited" | "agentSpawnFailed";
15
+ export type NotificationSettingLayer = Partial<NotificationsConfig>;
16
+
17
+ export interface NotificationSettingsTarget {
18
+ agentId?: string;
19
+ teamId?: string;
20
+ }
21
+
22
+ export const NOTIFICATIONS_CONFIG_DEFAULTS: NotificationsConfig = {
23
+ enabled: true,
24
+ branchLanded: true,
25
+ agentReady: true,
26
+ agentExited: true,
27
+ agentSpawnFailed: true,
28
+ contextThreshold: DEFAULT_CONTEXT_THRESHOLD_CONFIG,
29
+ quotaThreshold: DEFAULT_QUOTA_THRESHOLD_CONFIG,
30
+ };
31
+
32
+ export const CONTEXT_THRESHOLD_SETTINGS_SCHEMA: JSONSchema = {
33
+ type: "object",
34
+ additionalProperties: false,
35
+ properties: {
36
+ enabled: { type: "boolean" },
37
+ advisoryUtilization: { type: "number", minimum: 0, maximum: 1 },
38
+ selfCompactUtilization: { type: "number", minimum: 0, maximum: 1 },
39
+ warnUtilization: { type: "number", minimum: 0, maximum: 1 },
40
+ hardBackstopUtilization: { type: "number", minimum: 0, maximum: 1 },
41
+ bandSize: { type: "number", exclusiveMinimum: 0, maximum: 1 },
42
+ },
43
+ };
44
+
45
+ export const QUOTA_THRESHOLD_SETTINGS_SCHEMA: JSONSchema = {
46
+ type: "object",
47
+ additionalProperties: false,
48
+ properties: {
49
+ enabled: { type: "boolean" },
50
+ warnRemaining: { type: "number", minimum: 0, maximum: 1 },
51
+ criticalRemaining: { type: "number", minimum: 0, maximum: 1 },
52
+ resetRemaining: { type: "number", minimum: 0, maximum: 1 },
53
+ bandSize: { type: "number", exclusiveMinimum: 0, maximum: 1 },
54
+ },
55
+ };
56
+
57
+ export const NOTIFICATIONS_SETTINGS_SCHEMA: JSONSchema = {
58
+ type: "object",
59
+ additionalProperties: false,
60
+ properties: {
61
+ enabled: { type: "boolean" },
62
+ branchLanded: { type: "boolean" },
63
+ agentReady: { type: "boolean" },
64
+ agentExited: { type: "boolean" },
65
+ agentSpawnFailed: { type: "boolean" },
66
+ contextThreshold: CONTEXT_THRESHOLD_SETTINGS_SCHEMA,
67
+ quotaThreshold: QUOTA_THRESHOLD_SETTINGS_SCHEMA,
68
+ },
69
+ };
70
+
71
+ export function notificationTeamKey(teamId: string): string {
72
+ return `${TEAM_KEY_PREFIX}${cleanLayerId(teamId, "teamId")}`;
73
+ }
74
+
75
+ export function notificationAgentKey(agentId: string): string {
76
+ return `${AGENT_KEY_PREFIX}${cleanLayerId(agentId, "agentId")}`;
77
+ }
78
+
79
+ export function parseNotificationSettingsKey(key: string): { kind: "default" | "team" | "agent"; id?: string } | null {
80
+ if (key === NOTIFICATIONS_DEFAULT_KEY) return { kind: "default" };
81
+ if (key.startsWith(TEAM_KEY_PREFIX)) {
82
+ const id = key.slice(TEAM_KEY_PREFIX.length).trim();
83
+ return id ? { kind: "team", id } : null;
84
+ }
85
+ if (key.startsWith(AGENT_KEY_PREFIX)) {
86
+ const id = key.slice(AGENT_KEY_PREFIX.length).trim();
87
+ return id ? { kind: "agent", id } : null;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ export function validateNotificationsConfig(value: unknown): NotificationsConfig {
93
+ return mergeNotificationSettings(NOTIFICATIONS_CONFIG_DEFAULTS, validateNotificationSettingsLayer(value));
94
+ }
95
+
96
+ export function validateNotificationSettingsLayer(value: unknown): NotificationSettingLayer {
97
+ if (!isRecord(value)) throw new ValidationError("notifications config value must be an object");
98
+ const bool = (key: "enabled" | NotificationToggleKey): boolean | undefined =>
99
+ value[key] === undefined ? undefined : cleanBoolean(value[key], key);
100
+ return {
101
+ ...(bool("enabled") !== undefined ? { enabled: bool("enabled")! } : {}),
102
+ ...(bool("branchLanded") !== undefined ? { branchLanded: bool("branchLanded")! } : {}),
103
+ ...(bool("agentReady") !== undefined ? { agentReady: bool("agentReady")! } : {}),
104
+ ...(bool("agentExited") !== undefined ? { agentExited: bool("agentExited")! } : {}),
105
+ ...(bool("agentSpawnFailed") !== undefined ? { agentSpawnFailed: bool("agentSpawnFailed")! } : {}),
106
+ ...(value.contextThreshold === undefined ? {} : { contextThreshold: validateContextThresholdConfig(value.contextThreshold) }),
107
+ ...(value.quotaThreshold === undefined ? {} : { quotaThreshold: validateQuotaThresholdConfig(value.quotaThreshold) }),
108
+ };
109
+ }
110
+
111
+ export function mergeNotificationSettings(base: NotificationsConfig, layer: NotificationSettingLayer | null | undefined): NotificationsConfig {
112
+ if (!layer) return base;
113
+ return {
114
+ ...base,
115
+ ...withoutNestedThresholds(layer),
116
+ contextThreshold: layer.contextThreshold ? { ...base.contextThreshold, ...layer.contextThreshold } : base.contextThreshold,
117
+ quotaThreshold: layer.quotaThreshold ? { ...base.quotaThreshold, ...layer.quotaThreshold } : base.quotaThreshold,
118
+ };
119
+ }
120
+
121
+ export function notificationTypeEnabled(config: NotificationsConfig, type: NotificationType): boolean {
122
+ if (!config.enabled) return false;
123
+ if (type === "agent.context_threshold") return config.contextThreshold.enabled;
124
+ if (type === "agent.quota_threshold") return config.quotaThreshold.enabled;
125
+ const key = notificationToggleForType(type);
126
+ return key ? config[key] : true;
127
+ }
128
+
129
+ function notificationToggleForType(type: NotificationType): NotificationToggleKey | null {
130
+ if (type === "branch.landed" || type === "landed-failure") return "branchLanded";
131
+ if (type === "agent.ready") return "agentReady";
132
+ if (type === "agent.exited") return "agentExited";
133
+ if (type === "agent.spawn_failed") return "agentSpawnFailed";
134
+ return null;
135
+ }
136
+
137
+ function withoutNestedThresholds(layer: NotificationSettingLayer): Omit<NotificationSettingLayer, "contextThreshold" | "quotaThreshold"> {
138
+ const { contextThreshold: _contextThreshold, quotaThreshold: _quotaThreshold, ...rest } = layer;
139
+ return rest;
140
+ }
141
+
142
+ function cleanBoolean(value: unknown, field: string): boolean {
143
+ if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
144
+ return value;
145
+ }
146
+
147
+ function cleanLayerId(value: string, field: string): string {
148
+ const clean = value.trim();
149
+ if (!clean) throw new ValidationError(`${field} required`);
150
+ if (clean.length > 200) throw new ValidationError(`${field} must be at most 200 characters`);
151
+ if (clean.includes("/") || clean.includes("\n") || clean.includes("\r")) {
152
+ throw new ValidationError(`${field} must not contain slashes or newlines`);
153
+ }
154
+ return clean;
155
+ }
@@ -84,5 +84,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
84
84
  "workspace.review_changes_requested": null,
85
85
  };
86
86
 
87
+ export const NOTIFICATION_TYPES = Object.keys(DEFAULT_TTL_MS) as NotificationType[];
88
+
87
89
  /** Convenience TTL for emitter-supplied overrides on bystander/informational notifications. */
88
90
  export const INFORMATIONAL_TTL_MS = 30 * MINUTES;
@@ -165,9 +165,6 @@ function notifyQuotaAdvisory(input: {
165
165
  data: payload,
166
166
  });
167
167
 
168
- const notifications = effectiveQuotaNotificationConfig();
169
- if (!notifications.enabled || !notifications.quotaThreshold.enabled) return;
170
-
171
168
  routeNotification({
172
169
  type: "agent.quota_threshold",
173
170
  scope: {},
@@ -40,6 +40,7 @@ import { deleteIssueRefRoute, getIssueRefRoute, listIssueRefsRoute, patchIssueRe
40
40
  import { getProjectEntriesRoute, getProjectEntryRoute, getProjectResolveRoute, getProjectRoute, getProjectsRoute } from "./projects";
41
41
  import { getRecipeByName, getRecipeInstanceArtifacts, getRecipeInstanceById, getRecipeInstances, getRecipes, postRecipeInstanceArtifacts, postRecipeStart, postRecipeStop } from "./recipes";
42
42
  import { getSettingRoute, getSettingsNamespaceRoute, getSettingsRoute, putSettingRoute, putSettingsDefaultRoute } from "./settings";
43
+ import { postNotificationPreviewRoute } from "./notifications";
43
44
  import { getStewardConfigRoute, getWorkspaceConfigRoute, putStewardConfigRoute, putWorkspaceConfigRoute } from "./steward";
44
45
  import { getTaskById, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim } from "./tasks";
45
46
  import { getMyTeamRoute, getTeamOutcomesRoute, getTeamsRoute, postMyTeamDissolveRoute, putMyTeamBriefRoute } from "./teams";
@@ -246,6 +247,7 @@ const routes: Route[] = [
246
247
  route("GET", "/api/settings/:namespace/:key", getSettingRoute),
247
248
  route("PUT", "/api/settings/:namespace", putSettingsDefaultRoute),
248
249
  route("PUT", "/api/settings/:namespace/:key", putSettingRoute),
250
+ route("POST", "/api/notifications/preview", postNotificationPreviewRoute),
249
251
  route("GET", "/api/config/:namespace", getConfigNamespace),
250
252
  route("GET", "/api/config/:namespace/:key/history", getConfigKeyHistory),
251
253
  route("GET", "/api/config/:namespace/:key", getConfigKey),
@@ -0,0 +1,36 @@
1
+ // Domain: notification routing preview/explainability (#446).
2
+ import { isRecord } from "agent-relay-sdk";
3
+ import { ValidationError } from "../db";
4
+ import { NOTIFICATION_TYPES, type NotificationScope, type NotificationType } from "../notification-types";
5
+ import { previewNotificationRouting } from "../notification-router";
6
+ import { error, json, parseBody, type Handler } from "./_shared";
7
+
8
+ export const postNotificationPreviewRoute: Handler = async (req) => {
9
+ const parsed = await parseBody<unknown>(req);
10
+ if (!parsed.ok) return error(parsed.error, parsed.status);
11
+ try {
12
+ if (!isRecord(parsed.body)) return error("JSON object body required");
13
+ return json({
14
+ preview: previewNotificationRouting({
15
+ type: cleanNotificationType(parsed.body.type),
16
+ scope: cleanScope(parsed.body.scope),
17
+ }),
18
+ });
19
+ } catch (e) {
20
+ if (e instanceof ValidationError) return error(e.message, 400);
21
+ throw e;
22
+ }
23
+ };
24
+
25
+ function cleanNotificationType(value: unknown): NotificationType {
26
+ if (typeof value !== "string" || !NOTIFICATION_TYPES.includes(value as NotificationType)) {
27
+ throw new ValidationError(`type must be one of: ${NOTIFICATION_TYPES.join(", ")}`);
28
+ }
29
+ return value as NotificationType;
30
+ }
31
+
32
+ function cleanScope(value: unknown): NotificationScope {
33
+ if (value === undefined || value === null) return {};
34
+ if (!isRecord(value)) throw new ValidationError("scope must be an object");
35
+ return value as NotificationScope;
36
+ }
@@ -3,6 +3,8 @@ import { ValidationError } from "../db";
3
3
  import { getConfig, setConfig } from "../config-store";
4
4
  import { getUserSetting, setUserSetting } from "../user-settings-store";
5
5
  import { ensureProviderConfigDescriptors, migrateLocalProviderConfigs } from "../provider-config-store";
6
+ import { ensureNotificationSettingDescriptors } from "../notification-settings-store";
7
+ import { NOTIFICATIONS_NAMESPACE } from "../notification-settings";
6
8
  import { getSettingDescriptor, listSettingDescriptors, validateSettingValue, PROVIDER_CONFIG_NAMESPACE } from "../settings/registry";
7
9
  import { isRequestAuthorizedFor } from "../security";
8
10
  import { cleanString } from "../validation";
@@ -21,6 +23,7 @@ type SettingEnvelope = SettingEntry;
21
23
  export const getSettingsRoute: Handler = (req) => {
22
24
  migrateLocalProviderConfigs();
23
25
  ensureProviderConfigDescriptors();
26
+ ensureNotificationSettingDescriptors();
24
27
  return json({ settings: listVisibleSettings(req) });
25
28
  };
26
29
 
@@ -28,6 +31,7 @@ export const getSettingsNamespaceRoute: Handler = (req, params) => {
28
31
  const namespace = normalizeConfigPathParam(params.namespace, "namespace");
29
32
  migrateLocalProviderConfigs();
30
33
  ensureProviderConfigDescriptors();
34
+ if (namespace === NOTIFICATIONS_NAMESPACE) ensureNotificationSettingDescriptors();
31
35
  return json({ settings: listVisibleSettings(req).filter((setting) => setting.namespace === namespace) });
32
36
  };
33
37
 
@@ -36,6 +40,7 @@ export const getSettingRoute: Handler = (req, params) => {
36
40
  const key = normalizeConfigPathParam(params.key, "key");
37
41
  migrateLocalProviderConfigs();
38
42
  ensureProviderConfigDescriptors(namespace === PROVIDER_CONFIG_NAMESPACE ? [key] : []);
43
+ if (namespace === NOTIFICATIONS_NAMESPACE) ensureNotificationSettingDescriptors([key]);
39
44
  const descriptor = getSettingDescriptor(namespace, key);
40
45
  if (!descriptor) return error("setting not found", 404);
41
46
  const readable = canAccessSetting(req, descriptor.scope.read);
@@ -76,6 +81,7 @@ async function putSetting(req: Request, namespaceRaw: string | undefined, keyRaw
76
81
  try {
77
82
  migrateLocalProviderConfigs();
78
83
  ensureProviderConfigDescriptors(namespace === PROVIDER_CONFIG_NAMESPACE ? [key] : []);
84
+ if (namespace === NOTIFICATIONS_NAMESPACE) ensureNotificationSettingDescriptors([key]);
79
85
  const descriptor = getSettingDescriptor(namespace, key);
80
86
  if (!descriptor) return error("setting not found", 404);
81
87
  if (descriptor.tier !== "server-runtime" && descriptor.tier !== "user") return error("setting is read-only", 403);
package/src/security.ts CHANGED
@@ -151,6 +151,7 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
151
151
  if (pathname === "/api/health") return "health:read";
152
152
  if (pathname === "/api/events") return "events:read";
153
153
  if (pathname === "/api/route") return "agent:read";
154
+ if (pathname === "/api/notifications/preview") return "notifications:read";
154
155
  if (pathname === "/api/mcp") return "mcp:use";
155
156
  if (pathname === "/api/orchestrators/bootstrap/exchange") return "token:write";
156
157
  if (pathname === "/api/orchestrators/bootstrap") return "token:write";
@@ -39,9 +39,13 @@ export interface ScheduleTimerInput {
39
39
  everyMs?: number;
40
40
  rrule?: string;
41
41
  timezone?: string;
42
+ kind?: SendMessageInput["kind"];
43
+ channel?: string;
42
44
  subject?: string;
43
45
  body: string;
46
+ claimable?: boolean;
44
47
  payload?: Record<string, unknown>;
48
+ meta?: Record<string, unknown>;
45
49
  maxRuns?: number;
46
50
  expiresAt?: string | number;
47
51
  idempotencyKey?: string;
@@ -78,9 +82,13 @@ export function scheduleTimer(input: ScheduleTimerInput, ctx: AuthContext): Sche
78
82
  const deliveryFrom = ctx.callerAgentId ?? "system";
79
83
  const body = cleanBody(input.body);
80
84
  const payload = cleanPayload(input.payload);
85
+ const meta = cleanPayload(input.meta);
81
86
  const maxRuns = cleanPositiveInt(input.maxRuns, "maxRuns");
82
87
  const expiresAt = cleanExpiresAt(input.expiresAt, now);
83
88
  const idempotencyKey = cleanOptionalString(input.idempotencyKey, "idempotencyKey", 240);
89
+ const messageKind = cleanOptionalString(input.kind, "kind", 40) as SendMessageInput["kind"] | undefined;
90
+ const channel = cleanOptionalString(input.channel, "channel", 120);
91
+ const claimable = cleanOptionalBoolean(input.claimable, "claimable");
84
92
 
85
93
  if (schedule.kind === "interval") {
86
94
  if ((schedule.def.everyMs ?? 0) < SCHEDULER_MIN_RECURRING_INTERVAL_MS) {
@@ -100,9 +108,13 @@ export function scheduleTimer(input: ScheduleTimerInput, ctx: AuthContext): Sche
100
108
  validateSendMessageAuthorization({
101
109
  from: deliveryFrom,
102
110
  to: target,
111
+ kind: messageKind,
112
+ channel,
103
113
  body,
104
114
  subject: cleanOptionalString(input.subject, "subject", 200),
115
+ claimable,
105
116
  payload,
117
+ meta,
106
118
  idempotencyKey: idempotencyKey ? `${idempotencyKey}:probe` : undefined,
107
119
  }, ctx);
108
120
 
@@ -116,9 +128,13 @@ export function scheduleTimer(input: ScheduleTimerInput, ctx: AuthContext): Sche
116
128
  nextRunAt: schedule.nextRunAt,
117
129
  scheduleKind: schedule.kind,
118
130
  scheduleDef: schedule.def,
131
+ messageKind,
132
+ channel,
119
133
  subject: cleanOptionalString(input.subject, "subject", 200),
120
134
  body,
135
+ claimable,
121
136
  payload,
137
+ meta,
122
138
  maxRuns,
123
139
  expiresAt,
124
140
  idempotencyKey,
@@ -176,8 +192,11 @@ function fireClaimedTimer(timer: ScheduledTimer, now: number): { status: "fired"
176
192
  const result = sendMessageService({
177
193
  from: timer.deliveryFrom,
178
194
  to: timer.target,
195
+ kind: timer.messageKind as SendMessageInput["kind"] | undefined,
196
+ channel: timer.channel,
179
197
  subject: timer.subject,
180
198
  body: timer.body,
199
+ claimable: timer.claimable,
181
200
  payload: {
182
201
  ...timer.payload,
183
202
  scheduler: {
@@ -187,6 +206,7 @@ function fireClaimedTimer(timer: ScheduledTimer, now: number): { status: "fired"
187
206
  },
188
207
  },
189
208
  meta: {
209
+ ...timer.meta,
190
210
  scheduler: {
191
211
  timerId: timer.id,
192
212
  correlationId: timer.correlationId,
@@ -199,7 +219,7 @@ function fireClaimedTimer(timer: ScheduledTimer, now: number): { status: "fired"
199
219
  return { status: "fired", messageId: result.message.id };
200
220
  } catch (error) {
201
221
  const updated = recordScheduledTimerFailure({
202
- id: timer.id,
222
+ timer,
203
223
  error: errMessage(error),
204
224
  now,
205
225
  retryMs: SCHEDULER_RETRY_MS,
@@ -215,6 +235,8 @@ function timerRunIdempotencyKey(timer: ScheduledTimer, runNumber: number): strin
215
235
  }
216
236
 
217
237
  function persistedAuthContext(timer: ScheduledTimer): AuthContext {
238
+ // Timers intentionally fire with the create-time component scope snapshot. That keeps durable
239
+ // sends restart-safe; a stricter future model can revalidate a live token at fire time.
218
240
  const component: ComponentToken = {
219
241
  sub: timer.createdBy,
220
242
  role: timer.createdByKind,
@@ -287,6 +309,12 @@ function cleanPositiveInt(value: unknown, field: string): number | undefined {
287
309
  return value;
288
310
  }
289
311
 
312
+ function cleanOptionalBoolean(value: unknown, field: string): boolean | undefined {
313
+ if (value === undefined || value === null) return undefined;
314
+ if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
315
+ return value;
316
+ }
317
+
290
318
  function cleanExpiresAt(value: unknown, now: number): number | undefined {
291
319
  if (value === undefined || value === null) return undefined;
292
320
  const ms = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : NaN;