deepline 0.3.431 → 0.3.433

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 (117) hide show
  1. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  2. package/dist/bundling-sources/shared_libs/monitors/billing/credit-recovery.ts +97 -6
  3. package/dist/bundling-sources/shared_libs/monitors/billing/state.ts +33 -0
  4. package/dist/bundling-sources/shared_libs/monitors/deactivation-reasons.ts +94 -10
  5. package/dist/bundling-sources/shared_libs/monitors/signal-radars/inbound-dead-letter.ts +46 -2
  6. package/dist/bundling-sources/shared_libs/monitors/signal-radars/inbound-message.ts +4 -0
  7. package/dist/bundling-sources/shared_libs/observability/queue-health.ts +4 -4
  8. package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +36 -0
  9. package/dist/bundling-sources/shared_libs/product-notifications/catalog.ts +95 -8
  10. package/dist/bundling-sources/shared_libs/product-notifications/default-policy.ts +1 -1
  11. package/dist/bundling-sources/shared_libs/product-notifications/events.ts +5 -1
  12. package/dist/cli/{auth-C4O2LOMP.mjs → auth-5LF5G3Z2.mjs} +4 -4
  13. package/dist/cli/auth-FGHYE2JG.js +40 -0
  14. package/dist/cli/{billing-KJWTSINT.mjs → billing-I7ZADV7D.mjs} +4 -4
  15. package/dist/cli/{billing-RSZCFJLQ.js → billing-M34SJBAB.js} +73 -73
  16. package/dist/cli/{chunk-PMEMHRBA.mjs → chunk-2TNGO4DZ.mjs} +5 -4
  17. package/dist/cli/{chunk-DCEMKJAM.mjs → chunk-3C25DYSE.mjs} +3 -3
  18. package/dist/cli/{chunk-ZFZB3J6X.mjs → chunk-3CZEDR4K.mjs} +5 -5
  19. package/dist/cli/{chunk-II2W3O4Z.mjs → chunk-3ET2ZKZS.mjs} +1 -1
  20. package/dist/cli/{chunk-727ZKS55.mjs → chunk-42JTV4IM.mjs} +1 -1
  21. package/dist/cli/{chunk-E36ZD6WX.js → chunk-6R6SQQGQ.js} +4 -4
  22. package/dist/cli/{chunk-7H2VOSMU.mjs → chunk-B3XEZMYS.mjs} +8 -8
  23. package/dist/cli/{chunk-IZIKOENG.mjs → chunk-BSQH4V3Y.mjs} +1 -1
  24. package/dist/cli/{chunk-Y4YTLJOY.js → chunk-CXGNMAFV.js} +16 -16
  25. package/dist/cli/{chunk-VFEB52MK.js → chunk-DAEODAWB.js} +35 -35
  26. package/dist/cli/{chunk-YMVDYAH6.js → chunk-FYDPU4QV.js} +26 -26
  27. package/dist/cli/{chunk-XQ6GLQBP.mjs → chunk-G4P74R2F.mjs} +2 -2
  28. package/dist/cli/{chunk-QZZDQRZR.mjs → chunk-GZS6ZB55.mjs} +1 -1
  29. package/dist/cli/{chunk-VCV523UN.mjs → chunk-HAC6SRO5.mjs} +1 -1
  30. package/dist/cli/{chunk-OU7HHXQF.js → chunk-I5CB3UUX.js} +50 -50
  31. package/dist/cli/{chunk-QVCA5NFP.js → chunk-JNX4Y37G.js} +1 -1
  32. package/dist/cli/{chunk-EMXOHRK3.js → chunk-JUZW32YP.js} +8 -8
  33. package/dist/cli/{chunk-X6YQF3UF.js → chunk-KAWPJNUO.js} +20 -20
  34. package/dist/cli/{chunk-FNYEER22.js → chunk-KQ5VO34X.js} +10 -10
  35. package/dist/cli/{chunk-H37US5PT.js → chunk-N5VOE6KZ.js} +6 -6
  36. package/dist/cli/{chunk-C5ATRVQJ.js → chunk-O7X44YGX.js} +10 -9
  37. package/dist/cli/{chunk-VIA6YQI7.js → chunk-PKOCM6JB.js} +52 -52
  38. package/dist/cli/{chunk-5FE6L6K7.mjs → chunk-PM3DL6YP.mjs} +3 -3
  39. package/dist/cli/{chunk-LVO7M2MG.mjs → chunk-PRA2CG4B.mjs} +1 -1
  40. package/dist/cli/{chunk-MIGX46JW.js → chunk-T77HG3R7.js} +2 -2
  41. package/dist/cli/{chunk-4MUENWMZ.mjs → chunk-UAUHERWW.mjs} +2 -2
  42. package/dist/cli/{chunk-CAJT43U2.js → chunk-UDJXL4SM.js} +79 -79
  43. package/dist/cli/{chunk-CZ4JZP2F.mjs → chunk-VMCJGLJL.mjs} +2 -2
  44. package/dist/cli/{chunk-UE34XUXS.js → chunk-WVCAELST.js} +6 -6
  45. package/dist/cli/{chunk-K5M4OOJJ.mjs → chunk-XSEQLAHE.mjs} +2 -2
  46. package/dist/cli/{chunk-FNHIJPQ6.mjs → chunk-YBSOJ6D4.mjs} +4 -4
  47. package/dist/cli/{chunk-24FMUK3P.js → chunk-ZMRP5UXN.js} +2 -2
  48. package/dist/cli/{csv-AFWU7THX.mjs → csv-3TCORDI3.mjs} +3 -3
  49. package/dist/cli/{csv-R7BRVDCD.js → csv-R5I5ZWSC.js} +12 -12
  50. package/dist/cli/{db-VWE6GS7Z.js → db-BXEK3WX2.js} +25 -25
  51. package/dist/cli/{db-4DDGZWIG.mjs → db-D7NC4XAM.mjs} +4 -4
  52. package/dist/cli/{describe-VPSIS7MQ.js → describe-LSOFG74H.js} +16 -16
  53. package/dist/cli/{describe-O4H6N4EO.mjs → describe-SXYZTJQ5.mjs} +12 -12
  54. package/dist/cli/describe-core.d.mts +1 -1
  55. package/dist/cli/describe-core.d.ts +1 -1
  56. package/dist/cli/{executions-LHNWIQDR.js → executions-BF5YRFHH.js} +6 -6
  57. package/dist/cli/{executions-BLF6JAAZ.mjs → executions-ULXJPGV4.mjs} +4 -4
  58. package/dist/cli/{feedback-6BNBRU3O.mjs → feedback-6FJOHQJB.mjs} +3 -3
  59. package/dist/cli/{feedback-YJ6EB3JH.js → feedback-VJM56OFG.js} +7 -7
  60. package/dist/cli/git-TSBBSAK3.js +24 -0
  61. package/dist/cli/{git-TCLUOJU7.mjs → git-WGZMHPW3.mjs} +4 -4
  62. package/dist/cli/index.js +131 -131
  63. package/dist/cli/index.mjs +36 -36
  64. package/dist/cli/{legacy-noops-6ZKNRJBD.mjs → legacy-noops-EVFQBQ6H.mjs} +4 -4
  65. package/dist/cli/{legacy-noops-4KXLXXFC.js → legacy-noops-ZRDESRRJ.js} +7 -7
  66. package/dist/cli/{monitors-5XJIT3GU.mjs → monitors-4PMVB7RD.mjs} +4 -4
  67. package/dist/cli/{monitors-6U3WH74P.js → monitors-FR4OSX77.js} +84 -84
  68. package/dist/cli/{org-6LAFFTIE.js → org-HSY6TVRI.js} +64 -64
  69. package/dist/cli/{org-QZZL2WO2.mjs → org-QBENPGXU.mjs} +4 -4
  70. package/dist/cli/{play-7KDM3QTU.mjs → play-7O7GXOWT.mjs} +10 -10
  71. package/dist/cli/{play-CCDHG3GT.js → play-JSY4OSLN.js} +283 -283
  72. package/dist/cli/{providers-WEYDXXND.mjs → providers-76OM5TQ7.mjs} +4 -4
  73. package/dist/cli/{providers-VUNDMKOF.js → providers-RLLSVNR6.js} +7 -7
  74. package/dist/cli/{quickstart-ABTQUOYC.js → quickstart-MZZDW6VC.js} +5 -5
  75. package/dist/cli/{quickstart-UAWZJ52P.mjs → quickstart-UHB5GNJV.mjs} +3 -3
  76. package/dist/cli/search-GCMZS6IK.js +30 -0
  77. package/dist/cli/{search-VTEFQWX4.mjs → search-X5P34RAU.mjs} +5 -5
  78. package/dist/cli/{secrets-ZSMBJJAU.js → secrets-EKVWSDQT.js} +13 -13
  79. package/dist/cli/{secrets-FKGXGVTS.mjs → secrets-L43HK4A6.mjs} +4 -4
  80. package/dist/cli/{sessions-RXJZVW7N.js → sessions-IPV23HXJ.js} +5 -5
  81. package/dist/cli/{sessions-ZWTSTLOZ.mjs → sessions-QPK5NPXO.mjs} +4 -4
  82. package/dist/cli/{settings-ULIRON4N.js → settings-5CJ6SMAN.js} +75 -75
  83. package/dist/cli/{settings-2TYYFRK2.mjs → settings-NF4VMLGN.mjs} +8 -8
  84. package/dist/cli/{setup-PRTFVHWH.mjs → setup-3JVUB4V6.mjs} +7 -7
  85. package/dist/cli/{setup-D6PIAKTH.js → setup-FHK6S4JC.js} +42 -42
  86. package/dist/cli/{skills-S6LAHWZZ.mjs → skills-HZLL2C7G.mjs} +5 -5
  87. package/dist/cli/skills-U2R75N5D.js +29 -0
  88. package/dist/cli/tools-N57LENEU.js +44 -0
  89. package/dist/cli/{tools-3QL4GWQI.mjs → tools-TG5SDGNS.mjs} +12 -12
  90. package/dist/cli/{update-MH6Z5UL7.mjs → update-4UZ6PUZK.mjs} +7 -7
  91. package/dist/cli/update-WYEO75YZ.js +31 -0
  92. package/dist/cli/{workflow-YSIK4DXK.mjs → workflow-FAJY6TR4.mjs} +5 -5
  93. package/dist/cli/{workflow-ECWHO7I5.js → workflow-UTPL4KIF.js} +15 -15
  94. package/dist/{client-C0VoQB5b.d.ts → client-CgmfMj_N.d.ts} +73 -4
  95. package/dist/{client-BuCrTjW6.d.mts → client-DIY1WpSW.d.mts} +73 -4
  96. package/dist/index.d.mts +2 -2
  97. package/dist/index.d.ts +2 -2
  98. package/dist/index.js +6 -6
  99. package/dist/index.mjs +3 -3
  100. package/dist/install-integrity.json +80 -80
  101. package/dist/plays/play-description.d.mts +1 -1
  102. package/dist/plays/play-description.d.ts +1 -1
  103. package/dist/plays/run-request.d.mts +1 -1
  104. package/dist/plays/run-request.d.ts +1 -1
  105. package/dist/release.d.mts +1 -1
  106. package/dist/release.d.ts +1 -1
  107. package/dist/release.js +2 -2
  108. package/dist/release.mjs +1 -1
  109. package/dist/viewer/viewer.css +4 -0
  110. package/dist/viewer/viewer.js +19 -3
  111. package/package.json +1 -1
  112. package/dist/cli/auth-IJQWGGCT.js +0 -40
  113. package/dist/cli/git-OVSSGF57.js +0 -24
  114. package/dist/cli/search-44L5VX4Q.js +0 -30
  115. package/dist/cli/skills-5YWEURZ2.js +0 -29
  116. package/dist/cli/tools-UE45QLRH.js +0 -44
  117. package/dist/cli/update-J5DUS5I6.js +0 -31
@@ -205,7 +205,7 @@ export const SDK_RELEASE = {
205
205
  // getters keep their established compatibility behavior.
206
206
  // 0.3.1 deprecated the legacy `deepline enrich` command in favor of Plays.
207
207
  // 0.3.90 is the first deliberately versioned SDK release for API v3.
208
- version: '0.3.431',
208
+ version: '0.3.433',
209
209
  updateSummary: {
210
210
  introducedIn: '0.3.90',
211
211
  summary:
@@ -2,8 +2,9 @@ import { parseStoredMonitorBillingState } from './state';
2
2
 
3
3
  /**
4
4
  * Only these event-time billing denials are healed by a restored Deepline
5
- * balance. A monthly spending cap and an opaque billing failure deliberately
6
- * remain explicit-recovery states.
5
+ * balance. A monthly spending cap is healed by the cap admitting again
6
+ * (`isMonitorCapSuspensionReason`), and an opaque billing failure deliberately
7
+ * remains an explicit-recovery state.
7
8
  */
8
9
  export function isCreditExhaustionSuspensionReason(
9
10
  reason: string | undefined,
@@ -14,6 +15,60 @@ export function isCreditExhaustionSuspensionReason(
14
15
  );
15
16
  }
16
17
 
18
+ /**
19
+ * The billing policy sweep's credit pause. When the sweep finds an exhausted
20
+ * wallet it deactivates a direct monitor with this reason, and the repair that
21
+ * converges it records the same reason as the billing suspension's. The sweep
22
+ * then defers a `billing_suspended` row to credit recovery and never revives
23
+ * it itself, so unless credit recovery accepts it, a top-up has no automatic
24
+ * path back (the reason's own resolution promises one).
25
+ *
26
+ * Accepted only with the policy's provenance on the row: the same suspension
27
+ * reason under any other deactivation reason is not proof the wallet caused
28
+ * it.
29
+ *
30
+ * KAR-43: an exhausted wallet switches every Monitor off through this policy
31
+ * pause, Fleet members included, so a Fleet the policy suspended is credit
32
+ * restorable too (`isCreditRestorableFleetSuspensionReason`).
33
+ */
34
+ export const POLICY_CREDIT_SUSPENSION_REASON = 'insufficient_balance';
35
+
36
+ /** The policy's spending-cap pause (KAR-43). */
37
+ export const POLICY_CAP_SUSPENSION_REASON = 'monthly_cap_exceeded';
38
+
39
+ /**
40
+ * A Fleet's billing suspension that a restored balance heals: an event-time
41
+ * credit denial or the policy's own credit pause. The Fleet's suspension
42
+ * record is written only by Fleet billing lifecycle, so its reason is its
43
+ * provenance.
44
+ */
45
+ export function isCreditRestorableFleetSuspensionReason(
46
+ reason: string | undefined,
47
+ ): boolean {
48
+ return (
49
+ isCreditExhaustionSuspensionReason(reason) ||
50
+ reason === POLICY_CREDIT_SUSPENSION_REASON
51
+ );
52
+ }
53
+
54
+ /** A spending-cap suspension, healed once this period's spend is under the cap. */
55
+ export function isMonitorCapSuspensionReason(
56
+ reason: string | undefined,
57
+ ): boolean {
58
+ return reason === POLICY_CAP_SUSPENSION_REASON;
59
+ }
60
+
61
+ function isDirectCreditSuspension(input: {
62
+ suspensionReason: string | undefined;
63
+ deactivationReason: string | undefined;
64
+ }): boolean {
65
+ return (
66
+ isCreditExhaustionSuspensionReason(input.suspensionReason) ||
67
+ (input.suspensionReason === POLICY_CREDIT_SUSPENSION_REASON &&
68
+ input.deactivationReason === POLICY_CREDIT_SUSPENSION_REASON)
69
+ );
70
+ }
71
+
17
72
  /**
18
73
  * A billing suspension is not itself proof that TamRadar accepted the delete.
19
74
  * Keep this test beside the recovery selector so every automatic path rejects
@@ -44,9 +99,10 @@ export function hasConfirmedProviderMonitorUpstreamCleanup(
44
99
  }
45
100
 
46
101
  /**
47
- * Narrow selector for direct event-time suspensions that credit restoration
48
- * may re-provision. It intentionally does not include policy-only pauses,
49
- * manual intent, spending-cap denials, or cleanup still in flight.
102
+ * Narrow selector for direct credit-exhaustion suspensions that credit
103
+ * restoration may re-provision: an event-time suspension, or the policy
104
+ * sweep's own credit pause. It intentionally does not include manual intent,
105
+ * spending-cap denials, opaque billing failures, or cleanup still in flight.
50
106
  */
51
107
  export function isCreditRestorableDirectMonitorSuspension(input: {
52
108
  fleetId?: string;
@@ -61,7 +117,10 @@ export function isCreditRestorableDirectMonitorSuspension(input: {
61
117
  if (
62
118
  billingState.state !== 'billing_suspended' ||
63
119
  !billingState.reactivation_required ||
64
- !isCreditExhaustionSuspensionReason(billingState.suspension_reason) ||
120
+ !isDirectCreditSuspension({
121
+ suspensionReason: billingState.suspension_reason,
122
+ deactivationReason: input.deactivationReason,
123
+ }) ||
65
124
  !hasConfirmedProviderMonitorUpstreamCleanup(input.providerBinding)
66
125
  ) {
67
126
  return false;
@@ -75,3 +134,35 @@ export function isCreditRestorableDirectMonitorSuspension(input: {
75
134
  input.deactivationReason === 'insufficient_balance'
76
135
  );
77
136
  }
137
+
138
+ /**
139
+ * Narrow selector for direct spending-cap suspensions that may be re-provisioned
140
+ * once the cap admits again (KAR-43): the cap is raised, or the next billing
141
+ * period begins. The caller proves the cap admits; this proves the row was
142
+ * switched off for the cap, by the platform, and that upstream cleanup is
143
+ * confirmed. A customer stop, an opaque billing failure and an unfinished
144
+ * cleanup are never selected.
145
+ */
146
+ export function isCapRestorableDirectMonitorSuspension(input: {
147
+ fleetId?: string;
148
+ status: string;
149
+ desired?: 'active' | 'inactive';
150
+ deactivationReason?: string;
151
+ billingState: unknown;
152
+ providerBinding: unknown;
153
+ }): boolean {
154
+ if (input.fleetId !== undefined || input.status !== 'disabled') return false;
155
+ const billingState = parseStoredMonitorBillingState(input.billingState);
156
+ if (
157
+ billingState.state !== 'billing_suspended' ||
158
+ !billingState.reactivation_required ||
159
+ !isMonitorCapSuspensionReason(billingState.suspension_reason) ||
160
+ !hasConfirmedProviderMonitorUpstreamCleanup(input.providerBinding)
161
+ ) {
162
+ return false;
163
+ }
164
+ return (
165
+ input.desired !== 'inactive' ||
166
+ input.deactivationReason === POLICY_CAP_SUSPENSION_REASON
167
+ );
168
+ }
@@ -187,6 +187,39 @@ export function monitorBillingRequiresExplicitReactivation(
187
187
  );
188
188
  }
189
189
 
190
+ /**
191
+ * Whether a monthly-billed monitor is still inside the period it has already
192
+ * paid for (KAR-43 decision 8).
193
+ *
194
+ * A subscription monitor (TamRadar `contact_job_changes`) is billed per month,
195
+ * not per finding: the first month at create, then renewed by the
196
+ * `charge-reconcile` cron 24 hours before the provider's `next_charge_at`.
197
+ * That renewal is its funding gate. Until the paid period ends, a negative
198
+ * balance or an exceeded cap must not switch it off: its findings add no
199
+ * charges, the customer keeps the month they paid for, and a recreate would
200
+ * charge Deepline again (TamRadar has no reactivate endpoint).
201
+ *
202
+ * `provider_next_charge_at` is the provider-owned period end; `next_renewal_at`
203
+ * is Deepline's own periodic renewal. Either one in the future proves a paid
204
+ * period. An unparseable anchor proves nothing.
205
+ */
206
+ export function monitorWithinPaidSubscriptionPeriod(
207
+ value: unknown,
208
+ now: number,
209
+ ): boolean {
210
+ const state = parseStoredMonitorBillingState(value);
211
+ if (state.state !== 'active' && state.state !== 'billing_pending') {
212
+ return false;
213
+ }
214
+ return [state.provider_next_charge_at, state.next_renewal_at].some(
215
+ (anchor) => {
216
+ if (!anchor) return false;
217
+ const paidThrough = Date.parse(anchor);
218
+ return Number.isFinite(paidThrough) && now < paidThrough;
219
+ },
220
+ );
221
+ }
222
+
190
223
  export function publicMonitorBillingSummary(
191
224
  state: StoredMonitorBillingState,
192
225
  customerState: ProviderMonitorCustomerState,
@@ -1,4 +1,7 @@
1
- import { monitorBillingRequiresExplicitReactivation } from './billing/state';
1
+ import {
2
+ monitorBillingRequiresExplicitReactivation,
3
+ monitorWithinPaidSubscriptionPeriod,
4
+ } from './billing/state';
2
5
 
3
6
  /**
4
7
  * Why a monitor is desired-inactive.
@@ -62,9 +65,9 @@ export const MONITOR_DEACTIVATION_REASONS: readonly MonitorDeactivationReasonDef
62
65
  id: 'monthly_cap_exceeded',
63
66
  owner: 'policy',
64
67
  summary:
65
- 'Deactivated because the organization reached its monthly monitor spending cap.',
68
+ 'Deactivated because the organization reached its monthly spending cap.',
66
69
  resolution:
67
- 'Raise or reset the monthly cap, then explicitly reactivate the monitors you want restored.',
70
+ "Raise the cap, or wait for the next billing period. Monitors are reactivated automatically once this period's spend is under the cap again.",
68
71
  },
69
72
  {
70
73
  id: 'billing_failure',
@@ -159,8 +162,15 @@ export type MonitorPolicyRow = {
159
162
  desired?: 'active' | 'inactive' | undefined;
160
163
  deactivationReason?: string | undefined;
161
164
  billingState?: unknown;
165
+ fleetId?: string | undefined;
162
166
  };
163
167
 
168
+ /** The two spend reasons policy writes and reverts (KAR-43). */
169
+ export const SPEND_POLICY_REASONS: ReadonlySet<string> = new Set([
170
+ 'insufficient_balance',
171
+ 'monthly_cap_exceeded',
172
+ ]);
173
+
164
174
  export type MonitorPolicyDecision =
165
175
  | { kind: 'deactivate'; reason: string }
166
176
  | { kind: 'reactivate' }
@@ -182,22 +192,59 @@ export type MonitorPolicyDecision =
182
192
  export function decideMonitorBalancePolicy(input: {
183
193
  row: MonitorPolicyRow;
184
194
  balance: number | null;
195
+ /**
196
+ * This billing period's recorded spend is at or over the workspace's
197
+ * monthly cap (KAR-43). Absent means no cap applies.
198
+ */
199
+ capExceeded?: boolean;
200
+ /** Decision time, for the paid-subscription-period exclusion. */
201
+ now?: number;
185
202
  }): MonitorPolicyDecision {
186
203
  const admits = balanceAdmitsMonitors(input.balance);
204
+ const capExceeded = input.capExceeded === true;
187
205
  const reason = input.row.deactivationReason;
188
- if (!admits) {
206
+ // An exhausted wallet outranks an exceeded cap: a top-up alone must not
207
+ // leave a monitor that the cap still holds off labelled as a credit pause.
208
+ const offReason = !admits
209
+ ? 'insufficient_balance'
210
+ : capExceeded
211
+ ? 'monthly_cap_exceeded'
212
+ : null;
213
+ if (offReason !== null) {
189
214
  if (input.row.desired === 'inactive') {
190
215
  // Already off. Only re-label when policy already owned the reason: a
191
216
  // customer's own deactivation must not be re-attributed to billing.
192
- return reason === 'insufficient_balance'
217
+ // A row whose event-time suspension owns recovery keeps its reason.
218
+ return reason === offReason
193
219
  ? { kind: 'none' }
194
- : isPolicyDeactivation(reason)
195
- ? { kind: 'deactivate', reason: 'insufficient_balance' }
220
+ : isPolicyDeactivation(reason) &&
221
+ !monitorBillingRequiresExplicitReactivation(input.row.billingState)
222
+ ? { kind: 'deactivate', reason: offReason }
196
223
  : { kind: 'none' };
197
224
  }
198
- return { kind: 'deactivate', reason: 'insufficient_balance' };
225
+ // KAR-43 decision 8: a monthly-billed monitor inside the period it has
226
+ // already paid for keeps running. Its renewal charge is its funding gate.
227
+ //
228
+ // DIRECT MONITORS ONLY. A Fleet's billing suspension is one fleet-wide
229
+ // lifecycle: the Fleet becomes `billing_suspended`, and only then is it
230
+ // eligible for automatic reactivation, once EVERY member is disabled. A
231
+ // member left running would hold its Fleet in `billing_suspending`, which
232
+ // nothing recovers, so a Fleet member is switched off with its Fleet. The
233
+ // slot-month rule (`monitorPaidThroughAdmission`) still makes its
234
+ // recreate inside the paid period free to the customer.
235
+ if (
236
+ input.row.fleetId === undefined &&
237
+ monitorWithinPaidSubscriptionPeriod(
238
+ input.row.billingState,
239
+ input.now ?? Date.now(),
240
+ )
241
+ ) {
242
+ return { kind: 'none' };
243
+ }
244
+ return { kind: 'deactivate', reason: offReason };
199
245
  }
200
- // The balance is healthy again. Revive only what THIS policy turned off.
246
+ // The balance and the cap admit again. Revive only what THIS policy turned
247
+ // off.
201
248
  //
202
249
  // ASYMMETRIC ON `null`, DELIBERATELY. An unreadable wallet is permissive for
203
250
  // deactivation - "we do not know" must not turn a customer's monitors off -
@@ -209,10 +256,47 @@ export function decideMonitorBalancePolicy(input: {
209
256
  if (
210
257
  input.balance !== null &&
211
258
  input.row.desired === 'inactive' &&
212
- reason === 'insufficient_balance' &&
259
+ reason !== undefined &&
260
+ SPEND_POLICY_REASONS.has(reason) &&
213
261
  !monitorBillingRequiresExplicitReactivation(input.row.billingState)
214
262
  ) {
215
263
  return { kind: 'reactivate' };
216
264
  }
217
265
  return { kind: 'none' };
218
266
  }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // The shutdown notification (KAR-43)
270
+ // ---------------------------------------------------------------------------
271
+
272
+ /** The two reasons a workspace is told its Monitors were switched off. */
273
+ export type MonitorShutdownNotificationReason =
274
+ | 'insufficient_credits'
275
+ | 'monthly_cap_exceeded';
276
+
277
+ /**
278
+ * The notification subject for "this workspace's Monitors". The shutdown
279
+ * notification is one per shutdown, not one per monitor, so its ordering
280
+ * subject is the workspace's Monitors as a whole.
281
+ */
282
+ export const WORKSPACE_MONITORS_NOTIFICATION_SUBJECT = 'workspace-monitors';
283
+
284
+ /**
285
+ * Collapse every billing reason that switches a Monitor off into the two the
286
+ * customer acts on: add credits, or raise the cap. Event-time denials, the
287
+ * policy's credit pause and the cap pause all name the same remedy. Anything
288
+ * else (an opaque billing failure, a customer stop) is not this notification.
289
+ */
290
+ export function monitorShutdownNotificationReason(
291
+ reason: string | null | undefined,
292
+ ): MonitorShutdownNotificationReason | null {
293
+ if (
294
+ reason === 'insufficient_credits' ||
295
+ reason === 'insufficient_credits_after_provider_event' ||
296
+ reason === 'insufficient_balance'
297
+ ) {
298
+ return 'insufficient_credits';
299
+ }
300
+ if (reason === 'monthly_cap_exceeded') return 'monthly_cap_exceeded';
301
+ return null;
302
+ }
@@ -100,6 +100,9 @@ type DeadLetterMessageInput = Pick<
100
100
  radarKey?: string | null;
101
101
  eventIdempotencyKey?: string | null;
102
102
  rawBody?: string;
103
+ postgresSchedulerSchema?: string | null;
104
+ runtimeApiBaseUrl?: string | null;
105
+ deadLetterId?: string | null;
103
106
  };
104
107
 
105
108
  async function markCapturedRawWriteFailed(
@@ -132,6 +135,12 @@ async function markCapturedRawWriteFailed(
132
135
  // null-keyed poison events stay distinct (the partial index only enforces
133
136
  // uniqueness WHERE event_idempotency_key IS NOT NULL). Also marks the captured
134
137
  // raw row write_failed when one exists for the key.
138
+ //
139
+ // A redriven event that fails again returns its row to `open` with the
140
+ // attempts added up, so a row never claims `replaying` or `replayed` for an
141
+ // event that was not processed. A redrive message names its row
142
+ // (`deadLetterId`), which also covers a null-keyed event the index cannot
143
+ // match. A `discarded` row stays discarded.
135
144
  export async function recordSignalRadarDeadLetter(input: {
136
145
  client: InboundPgClient;
137
146
  message: DeadLetterMessageInput;
@@ -151,18 +160,53 @@ export async function recordSignalRadarDeadLetter(input: {
151
160
  headers: message.headers ?? {},
152
161
  receivedAt: message.receivedAt,
153
162
  admission: message.admission ?? null,
163
+ // A Preview callback runs on its own scheduler lane and calls back to its
164
+ // own origin; a redrive must keep both.
165
+ postgresSchedulerSchema: message.postgresSchedulerSchema ?? null,
166
+ runtimeApiBaseUrl: message.runtimeApiBaseUrl ?? null,
154
167
  };
155
168
 
169
+ if (message.deadLetterId) {
170
+ const reopened = await client.query(
171
+ `UPDATE dl_meta.signal_radar_inbound_dead_letter
172
+ SET status = CASE WHEN status = 'discarded' THEN status ELSE 'open' END,
173
+ attempts = attempts + $2,
174
+ last_error = $3,
175
+ updated_at = now()
176
+ WHERE dead_letter_id = $1
177
+ RETURNING dead_letter_id`,
178
+ [message.deadLetterId, input.attempts, lastError.slice(0, 8000)],
179
+ );
180
+ if ((reopened.rows ?? []).length > 0) {
181
+ if (eventIdempotencyKey) {
182
+ await markCapturedRawWriteFailed(
183
+ client,
184
+ eventIdempotencyKey,
185
+ lastError,
186
+ );
187
+ }
188
+ return;
189
+ }
190
+ }
191
+
156
192
  if (eventIdempotencyKey) {
157
193
  await client.query(
158
- `INSERT INTO dl_meta.signal_radar_inbound_dead_letter
194
+ `INSERT INTO dl_meta.signal_radar_inbound_dead_letter AS dead_letter
159
195
  (dead_letter_id, provider, radar_key, org_id, event_idempotency_key,
160
196
  payload, headers, attempts, last_error, status)
161
197
  VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9, 'open')
162
198
  ON CONFLICT (event_idempotency_key)
163
199
  WHERE event_idempotency_key IS NOT NULL
164
200
  DO UPDATE SET
165
- attempts = EXCLUDED.attempts,
201
+ status = CASE
202
+ WHEN dead_letter.status IN ('replaying', 'replayed') THEN 'open'
203
+ ELSE dead_letter.status
204
+ END,
205
+ attempts = CASE
206
+ WHEN dead_letter.status IN ('replaying', 'replayed')
207
+ THEN dead_letter.attempts + EXCLUDED.attempts
208
+ ELSE EXCLUDED.attempts
209
+ END,
166
210
  last_error = EXCLUDED.last_error,
167
211
  payload = EXCLUDED.payload,
168
212
  headers = EXCLUDED.headers,
@@ -35,4 +35,8 @@ export type SignalRadarInboundMessage = {
35
35
  // Optional radar hint the worker resolves durably from Neon; present only on
36
36
  // dead-letter/replay envelopes where it was already known.
37
37
  radarKey?: string | null;
38
+ // The tenant dead-letter row this message redrives. The worker settles that
39
+ // row from the outcome: `replayed` once processed, back to `open` when it
40
+ // dead-letters again. Absent on every first delivery.
41
+ deadLetterId?: string | null;
38
42
  };
@@ -219,19 +219,19 @@ export const OPERATIONS_QUEUE_CATALOG = [
219
219
  ),
220
220
  queue(
221
221
  'signal-radar-inbound-legacy-dlq',
222
- 'Signal-radar legacy inbound dead-letters',
222
+ 'Signal-radar inbound dead letters',
223
223
  'provider_queue',
224
224
  'monitors',
225
- 'Tenant-scoped legacy poison-callback records; counts can overlap raw write failures',
225
+ 'Tenant-scoped inbound callbacks that exhausted their deliveries; redrive (retry) or retire one item; counts can overlap raw receipts',
226
226
  300_000,
227
227
  900_000,
228
228
  ),
229
229
  queue(
230
230
  'signal-radar-inbound-typed-write-failures',
231
- 'Signal-radar typed-write failures',
231
+ 'Signal-radar raw receipts not written',
232
232
  'provider_queue',
233
233
  'monitors',
234
- 'Tenant-scoped raw callback receipts whose typed write failed; records can overlap legacy dead-letters',
234
+ 'Read-only evidence: tenant raw callback receipts not written as rows, including deliberate raw-only captures; act on the dead-letter item',
235
235
  300_000,
236
236
  900_000,
237
237
  ),
@@ -909,6 +909,42 @@
909
909
  "failureAlert": "convex_action_wrapper",
910
910
  "domainReportRequired": true
911
911
  },
912
+ {
913
+ "id": "convex.monitor-cap-change",
914
+ "name": "Monitor spending-cap change",
915
+ "scheduler": "convex_scheduled_action",
916
+ "cadence": "runAfter(0) when a workspace that has Monitors changes or removes its spending cap",
917
+ "timezone": "UTC",
918
+ "source": "packages/convex/scheduledJobsNode.ts",
919
+ "locator": "reactToCreditBalanceChange",
920
+ "owner": "billing",
921
+ "criticality": "critical",
922
+ "purpose": "Re-decide a workspace's Monitors against its changed spending cap: revive cap-paused Monitors through the fenced billing-recovery lifecycle once the cap admits, or switch them off when spend already exceeds a lowered cap.",
923
+ "successDefinition": "The bounded policy pass completes and submits any durable operation it needs, or records a valid no-op.",
924
+ "failureDefinition": "The policy pass cannot run. The hourly provider-monitor policy sweep remains the backstop.",
925
+ "startGraceMs": 120000,
926
+ "runTimeoutMs": 300000,
927
+ "failureAlert": "convex_action_wrapper",
928
+ "domainReportRequired": true
929
+ },
930
+ {
931
+ "id": "convex.monitor-spend-shutdown",
932
+ "name": "Monitor spend shutdown",
933
+ "scheduler": "convex_scheduled_action",
934
+ "cadence": "runAfter(0) from the per-workspace notification worker when a queued balance change goes below 0 or a spending-cap denial is recorded while a Monitor runs, and from a failed automatic recharge",
935
+ "timezone": "UTC",
936
+ "source": "packages/convex/scheduledJobsNode.ts",
937
+ "locator": "reactToCreditBalanceChange",
938
+ "owner": "billing",
939
+ "criticality": "critical",
940
+ "purpose": "Switch every Monitor in a workspace off for credit or the spending cap as soon as spend makes it necessary, instead of waiting for the hourly policy sweep. Direct monthly-billed Monitors inside their paid period are left running.",
941
+ "successDefinition": "The balance or cap is re-checked and the bounded policy pass submits its durable operations, or a valid no-op is recorded (balance back above 0, cap admits, automatic recharge in progress, no running Monitor).",
942
+ "failureDefinition": "The re-check or a policy page cannot run. The hourly provider-monitor policy sweep remains the backstop.",
943
+ "startGraceMs": 120000,
944
+ "runTimeoutMs": 300000,
945
+ "failureAlert": "convex_action_wrapper",
946
+ "domainReportRequired": true
947
+ },
912
948
  {
913
949
  "id": "inngest.workflow-run-watchdog",
914
950
  "name": "Workflow run watchdog",
@@ -7,6 +7,7 @@ import type {
7
7
  import { PRODUCT_NOTIFICATION_EVENT_CATALOG } from './contract';
8
8
  import {
9
9
  ProductNotificationEvent,
10
+ ProductNotificationTargetPath,
10
11
  getProductNotificationEventDefinition,
11
12
  productNotificationTriggerIsAllowed,
12
13
  type ProductNotificationEventName,
@@ -136,6 +137,30 @@ function validateInsufficientCreditDisable(
136
137
  }
137
138
  }
138
139
 
140
+ function validateMonthlyCapMonitorDisable(trigger: ProductNotificationTrigger) {
141
+ if (
142
+ trigger.kind !== 'state_transition' ||
143
+ trigger.targetState !== 'disabled' ||
144
+ trigger.reasonCode !== 'monthly_cap_exceeded'
145
+ ) {
146
+ throw new Error(
147
+ 'Spending-cap Monitor rules must enter disabled for monthly_cap_exceeded.',
148
+ );
149
+ }
150
+ }
151
+
152
+ /**
153
+ * KAR-43 decision 7: a shutdown notification goes only to a workspace that
154
+ * has at least one Monitor. A workspace-wide shutdown event names how many
155
+ * Monitors it switched off; a per-Monitor event is itself a Monitor.
156
+ */
157
+ function switchedOffAMonitor(payload: ProductNotificationFacts): boolean {
158
+ return (
159
+ payload.affectedMonitorCount === undefined ||
160
+ Number(payload.affectedMonitorCount) >= 1
161
+ );
162
+ }
163
+
139
164
  function validateFailedOutcome(trigger: ProductNotificationTrigger) {
140
165
  if (trigger.kind === 'consecutive_outcomes' && trigger.outcome !== 'failed') {
141
166
  throw new Error('Failure rules require failed consecutive outcomes.');
@@ -321,14 +346,19 @@ const PRODUCT_NOTIFICATION_TYPE_REGISTRY = [
321
346
  requiredFilter: {},
322
347
  allowedTriggers: ['state_transition'],
323
348
  catalog: {
324
- name: 'Monitor disabled due to insufficient credits',
349
+ name: 'Monitors turned off due to insufficient credits',
325
350
  category: 'credits',
326
351
  description:
327
- 'Notify when Deepline disables a Monitor because the workspace ran out of credits.',
352
+ "Notify when Deepline turns off the workspace's Monitors because it ran out of credits. Adding credits turns them back on.",
328
353
  allowMultiple: false,
329
354
  editor: { kind: 'none' },
330
355
  },
331
356
  rule: {
357
+ // KAR-43: on by default for workspace owners, once per shutdown. The
358
+ // workspace condition fires on the first Monitor switched off and
359
+ // rearms when the balance rises.
360
+ default: { ruleKey: 'default.monitor_disabled', groupKey: 'default' },
361
+ eligible: switchedOffAMonitor,
332
362
  conditionSubject: 'workspace',
333
363
  rearm: {
334
364
  eventName: ProductNotificationEvent.CreditsBalanceChanged,
@@ -345,15 +375,72 @@ const PRODUCT_NOTIFICATION_TYPE_REGISTRY = [
345
375
  }),
346
376
  validate: validateInsufficientCreditDisable,
347
377
  present: ({ payload }) => ({
348
- title: 'Monitor disabled due to insufficient credits',
378
+ title: 'Monitors turned off: out of credits',
349
379
  summary: [
350
- `${payload.monitorName} was disabled because your `,
380
+ 'Deepline turned off the Monitors in your ',
351
381
  { workspace: true },
352
- ' ran out of credits.',
382
+ ' because it ran out of credits.',
353
383
  ],
354
- details: [],
355
- targetUrl: String(payload.targetUrl),
356
- subjectKind: 'monitor',
384
+ details: [
385
+ 'Add credits to turn them back on. Monitors resume automatically once the balance is above zero.',
386
+ 'Events that arrive while Monitors are off are not delivered later.',
387
+ ],
388
+ targetUrl: ProductNotificationTargetPath.Billing,
389
+ subjectKind: 'workspace_monitors',
390
+ subjectId: String(payload.monitorId),
391
+ severity: 'error',
392
+ }),
393
+ },
394
+ },
395
+ {
396
+ id: 'monitor.disabled.monthly_cap_exceeded',
397
+ eventName: ProductNotificationEvent.MonitorLifecycleChanged,
398
+ requiredFilter: {},
399
+ allowedTriggers: ['state_transition'],
400
+ catalog: {
401
+ name: 'Monitors turned off at the spending cap',
402
+ category: 'credits',
403
+ description:
404
+ "Notify when Deepline turns off the workspace's Monitors because it reached its monthly spending cap. Raising the cap, or the next billing period, turns them back on.",
405
+ allowMultiple: false,
406
+ editor: { kind: 'none' },
407
+ },
408
+ rule: {
409
+ default: {
410
+ ruleKey: 'default.monitor_disabled_monthly_cap',
411
+ groupKey: 'default',
412
+ },
413
+ eligible: switchedOffAMonitor,
414
+ conditionSubject: 'workspace',
415
+ // Monitors coming back under the cap rearm it for the next shutdown.
416
+ rearm: {
417
+ eventName: ProductNotificationEvent.MonitorLifecycleChanged,
418
+ matches: (payload) =>
419
+ payload.currentState === 'active' &&
420
+ payload.reasonCode === 'monthly_cap_exceeded',
421
+ },
422
+ create: (_input, base) => ({
423
+ ...base,
424
+ trigger: {
425
+ kind: 'state_transition',
426
+ targetState: 'disabled',
427
+ reasonCode: 'monthly_cap_exceeded',
428
+ },
429
+ }),
430
+ validate: validateMonthlyCapMonitorDisable,
431
+ present: ({ payload }) => ({
432
+ title: 'Monitors turned off: spending cap reached',
433
+ summary: [
434
+ 'Deepline turned off the Monitors in your ',
435
+ { workspace: true },
436
+ ' because it reached its monthly spending cap.',
437
+ ],
438
+ details: [
439
+ 'Raise the cap to turn them back on. Otherwise they resume automatically when the next billing period begins.',
440
+ 'Events that arrive while Monitors are off are not delivered later.',
441
+ ],
442
+ targetUrl: ProductNotificationTargetPath.Billing,
443
+ subjectKind: 'workspace_monitors',
357
444
  subjectId: String(payload.monitorId),
358
445
  severity: 'error',
359
446
  }),
@@ -20,7 +20,7 @@ import {
20
20
  * they create sparse copy-on-write overrides.
21
21
  */
22
22
  export const CURRENT_PRODUCT_NOTIFICATION_DEFAULT_POLICY = {
23
- version: 10,
23
+ version: 11,
24
24
  groups: [
25
25
  {
26
26
  groupKey: 'default',
@@ -128,7 +128,11 @@ export const PRODUCT_NOTIFICATION_EVENT_DEFINITIONS: readonly ProductNotificatio
128
128
  targetUrl: 'string',
129
129
  },
130
130
  orderingSubjectFact: 'monitorId',
131
- optionalFacts: { previousReasonCode: 'string' },
131
+ optionalFacts: {
132
+ previousReasonCode: 'string',
133
+ /** A workspace-wide shutdown names how many Monitors it switched off. */
134
+ affectedMonitorCount: 'number',
135
+ },
132
136
  allowedTriggers: ['state_transition'],
133
137
  },
134
138
  {