@sellable/mcp 0.1.793 → 0.1.794

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 (54) hide show
  1. package/dist/agent-tool-names.d.ts +2 -2
  2. package/dist/agent-tool-names.js +14 -0
  3. package/dist/scheduler-envelope.d.ts +190 -0
  4. package/dist/scheduler-envelope.js +412 -0
  5. package/dist/server.js +28 -2
  6. package/dist/tools/campaign-message-preparation.d.ts +25 -0
  7. package/dist/tools/campaign-message-preparation.js +37 -0
  8. package/dist/tools/campaign-processing.d.ts +74 -1
  9. package/dist/tools/campaign-processing.js +57 -1
  10. package/dist/tools/prompts.d.ts +15 -3
  11. package/dist/tools/prompts.js +25 -0
  12. package/dist/tools/refill-executors.d.ts +43 -70
  13. package/dist/tools/refill-executors.js +201 -273
  14. package/dist/tools/refill-sends.d.ts +3 -3
  15. package/dist/tools/refill-sends.js +1 -1
  16. package/dist/tools/refill-target-plan.js +1 -1
  17. package/dist/tools/refill-v3-advance-contract.d.ts +618 -0
  18. package/dist/tools/refill-v3-advance-contract.js +920 -0
  19. package/dist/tools/refill-v3-advance.d.ts +397 -0
  20. package/dist/tools/refill-v3-advance.js +75 -0
  21. package/dist/tools/refill-v3-attention-packet-contract.d.ts +608 -0
  22. package/dist/tools/refill-v3-attention-packet-contract.js +911 -0
  23. package/dist/tools/refill-v3-campaign-work.d.ts +324 -0
  24. package/dist/tools/refill-v3-campaign-work.js +1015 -0
  25. package/dist/tools/refill-v3-continue-contract.d.ts +433 -0
  26. package/dist/tools/refill-v3-continue-contract.js +112 -0
  27. package/dist/tools/refill-v3-continue.d.ts +501 -0
  28. package/dist/tools/refill-v3-continue.js +654 -0
  29. package/dist/tools/refill-v3-edit-selected-campaign-contract.d.ts +208 -0
  30. package/dist/tools/refill-v3-edit-selected-campaign-contract.js +351 -0
  31. package/dist/tools/refill-v3-edit-selected-campaign.d.ts +232 -0
  32. package/dist/tools/refill-v3-edit-selected-campaign.js +147 -0
  33. package/dist/tools/refill-v3-fill-ready-contract.d.ts +198 -0
  34. package/dist/tools/refill-v3-fill-ready-contract.js +324 -0
  35. package/dist/tools/refill-v3-fill-ready.d.ts +199 -0
  36. package/dist/tools/refill-v3-fill-ready.js +136 -0
  37. package/dist/tools/refill-v3-source-family-dispatch.d.ts +249 -0
  38. package/dist/tools/refill-v3-source-family-dispatch.js +514 -0
  39. package/dist/tools/refill-v3-waterfall-contract.d.ts +156 -0
  40. package/dist/tools/refill-v3-waterfall-contract.js +181 -0
  41. package/dist/tools/refill-v3-waterfall.d.ts +136 -0
  42. package/dist/tools/refill-v3-waterfall.js +161 -0
  43. package/dist/tools/refill-v3-world-state-contract.d.ts +666 -0
  44. package/dist/tools/refill-v3-world-state-contract.js +790 -0
  45. package/dist/tools/refill-v3-world-state.d.ts +592 -0
  46. package/dist/tools/refill-v3-world-state.js +137 -0
  47. package/dist/tools/registry.d.ts +1710 -3
  48. package/dist/tools/registry.js +19 -0
  49. package/package.json +1 -1
  50. package/skills/refill-sends/SKILL.md +89 -4
  51. package/skills/refill-sends-waterfall-order/SKILL.md +174 -0
  52. package/skills/refill-sends-work-campaign/SKILL.md +253 -0
  53. package/skills/refill-sends-workflow/SKILL.md +112 -5
  54. package/skills/refill-sends-workflow/core/flow.v3.json +209 -0
@@ -0,0 +1,1015 @@
1
+ /**
2
+ * Refill V3 selected-campaign traversal RUNTIME (Phase 146.5 Plan 09 T1,
3
+ * RV3PRM-001).
4
+ *
5
+ * This is AI seam #2's OWNED RUNTIME HOME (`A09-AI-SEAM-RUNTIME-HOME`). The
6
+ * markdown subskill supplies the model's judgment; this module is the
7
+ * deterministic authority that stands between that judgment and every product
8
+ * effect. It:
9
+ *
10
+ * 1. decodes the model's output into AT MOST ONE action drawn from the shared
11
+ * `REFILL_V3_CAMPAIGN_ATTENTION_ACTIONS` constant — never a local list;
12
+ * 2. refuses connection-health evidence before any prompt work;
13
+ * 3. gates every action behind live-job absence with a BOUNDED wait;
14
+ * 4. enforces enrich-before-rubric structurally (incident 112k);
15
+ * 5. consumes the canonical diagnostic order VERBATIM so an enriched island
16
+ * can never truncate traversal;
17
+ * 6. loads exactly ONE matching active provider prompt for a new provider
18
+ * search and exactly ZERO on a drain;
19
+ * 7. falsifies an AI exhaustion claim through the shared exhaustion predicate;
20
+ * 8. dispatches EXACTLY ONE authority, validates its receipt, and forces the
21
+ * exact sender/date two-lane re-observation before the iteration ends.
22
+ *
23
+ * The AI seam NEVER mutates. Every effect crosses `dispatchAuthority`, which the
24
+ * caller binds to an existing owner, and a second mutation from the same
25
+ * evidence packet is a closed typed failure.
26
+ *
27
+ * Package constraints (`A02-NO-ZOD`): no app `src/**` import, no validation
28
+ * library, hand-rolled guards only.
29
+ */
30
+ import { REFILL_V3_CAMPAIGN_ATTENTION_ACTIONS, REFILL_V3_CAMPAIGN_ATTENTION_RESULTS, REFILL_V3_DIAGNOSTIC_SAMPLE_CAP, REFILL_V3_EXHAUSTION_PREDICATE, REFILL_V3_LEAD_SOURCE_FAMILIES, } from "./refill-v3-attention-packet-contract.js";
31
+ // ───────────────────────────────────────────────────────────────────────────
32
+ // ONE shared AI-output failure taxonomy for BOTH seams
33
+ // ───────────────────────────────────────────────────────────────────────────
34
+ /**
35
+ * The single closed taxonomy for "the model's output cannot be trusted".
36
+ * `refill-v3-waterfall.ts` IMPORTS this constant rather than forking a second
37
+ * AI-output framework (`A09-AI-SEAM-RUNTIME-HOME`, disposition D7).
38
+ *
39
+ * Every member is a VALUE, never a throw: a recoverable model error must not
40
+ * take the row down.
41
+ */
42
+ export const REFILL_V3_AI_OUTPUT_FAILURES = [
43
+ "malformed_output",
44
+ "zero_actions",
45
+ "multiple_actions",
46
+ "duplicate_action",
47
+ "unsupported_action",
48
+ "stale_evidence",
49
+ "model_timeout",
50
+ "delegated_tool_timeout",
51
+ "receipt_incomplete",
52
+ "second_mutation_forbidden",
53
+ ];
54
+ /** Deterministic REFUSALS that are about the world, not the model's syntax. */
55
+ export const REFILL_V3_CAMPAIGN_WORK_REFUSALS = [
56
+ "foreign_scope",
57
+ "health_recheck_required",
58
+ "live_job_blocks_action",
59
+ "rubric_before_enrichment",
60
+ "rubric_before_viable_repair",
61
+ "provider_prompt_missing",
62
+ "provider_prompt_mismatch",
63
+ "provider_prompt_forbidden_on_drain",
64
+ "exhaustion_falsified",
65
+ "negative_set_replay",
66
+ "diagnostic_order_not_consumed",
67
+ "cohort_arithmetic_forbidden",
68
+ "template_meaning_requires_approval",
69
+ ];
70
+ /**
71
+ * TRANSPORT MIRROR of the app-owned diagnostic scan-termination union
72
+ * (`src/lib/workflow-tables/campaign-cell-selectors.ts`). The published package
73
+ * may not import app source, so the mirror is declared here and DRIFT IS
74
+ * PINNED: `tests/mcp/refill-v3/campaign-work-validator.test.ts` asserts this
75
+ * tuple equals the app-owned union member-for-member.
76
+ *
77
+ * ONLY `frontier_exhausted` is exhaustion evidence (incident 112t).
78
+ */
79
+ export const REFILL_V3_DIAGNOSTIC_TERMINATIONS = [
80
+ "frontier_exhausted",
81
+ "scan_budget_exhausted",
82
+ "limit_reached",
83
+ ];
84
+ /** Connection-health reason codes Plan 10 must intercept BEFORE this seam. */
85
+ export const REFILL_V3_HEALTH_REASON_CODES = [
86
+ "paid_inmail_connection_health_recheck_required",
87
+ "missing_balance",
88
+ "sender_connection_connecting",
89
+ "sender_connection_credentials",
90
+ "sender_disconnected",
91
+ "sales_nav_disconnected",
92
+ ];
93
+ /** The bounded source modes. A drain loads NO provider prompt. */
94
+ export const REFILL_V3_SOURCE_COMMAND_MODES = [
95
+ "new_provider_search",
96
+ "drain_imported",
97
+ "post_engager_continuation",
98
+ ];
99
+ /**
100
+ * Cohort/batch keys the model may NEVER name. Sizing belongs to the existing
101
+ * `campaign-message-preparation` adaptive worker; a batch size in the model's
102
+ * command is a second selector by another name.
103
+ */
104
+ export const REFILL_V3_FORBIDDEN_COMMAND_KEYS = [
105
+ "batchSize",
106
+ "cohortSize",
107
+ "limit",
108
+ "offset",
109
+ "rangeStart",
110
+ "rangeEnd",
111
+ "targetCount",
112
+ "retryCount",
113
+ "attempts",
114
+ "cursor",
115
+ ];
116
+ /** The SEVEN reuse points a preparation delegation receipt must carry. */
117
+ export const REFILL_V3_PREPARATION_REUSE_POINTS = [
118
+ "frontier",
119
+ "remainingGapPassEvidence",
120
+ "serialBatchCap",
121
+ "totalRowBudget",
122
+ "replayKey",
123
+ "lowPassGuard",
124
+ "reclaimBehavior",
125
+ ];
126
+ function camelize(value) {
127
+ return value.replace(/_(.)/g, (_match, char) => char.toUpperCase());
128
+ }
129
+ function aliasMap(tokens) {
130
+ const entries = tokens.map((token) => [camelize(token), token]);
131
+ return Object.fromEntries(entries);
132
+ }
133
+ const ACTION = aliasMap(REFILL_V3_CAMPAIGN_ATTENTION_ACTIONS);
134
+ const RESULT = aliasMap(REFILL_V3_CAMPAIGN_ATTENTION_RESULTS);
135
+ /** Actions that may only be chosen once the bounded sample is ENRICHED. */
136
+ export const REFILL_V3_ENRICHMENT_GATED_ACTIONS = [
137
+ ACTION.requestRubricTrial,
138
+ ACTION.broadenSourceFamily,
139
+ ACTION.recordSourceFrontierExhausted,
140
+ ];
141
+ /** Actions that OUTRANK rubric relaxation and source broadening. */
142
+ export const REFILL_V3_REPAIR_FIRST_ACTIONS = [
143
+ ACTION.rerunErroredCells,
144
+ ACTION.repairCampaignConfig,
145
+ ACTION.enrichBoundedSample,
146
+ ACTION.regenerateStaleMessages,
147
+ ACTION.patchFilterCriteria,
148
+ ACTION.patchSourceCriteria,
149
+ ];
150
+ /** Row-scoped actions whose exact cohort is owned by the diagnostic authority. */
151
+ export const REFILL_V3_ROW_SCOPED_ACTIONS = [
152
+ ACTION.rerunErroredCells,
153
+ ACTION.enrichBoundedSample,
154
+ ACTION.regenerateStaleMessages,
155
+ ];
156
+ /** Actions that carry a bounded source command. */
157
+ export const REFILL_V3_SOURCE_ACTIONS = [
158
+ ACTION.broadenSourceFamily,
159
+ ACTION.patchSourceCriteria,
160
+ ];
161
+ /** One deterministic result per chosen action. */
162
+ export const REFILL_V3_ACTION_RESULTS = Object.freeze({
163
+ [ACTION.rerunErroredCells]: RESULT.cellsRerun,
164
+ [ACTION.repairCampaignConfig]: RESULT.campaignConfigRepaired,
165
+ [ACTION.enrichBoundedSample]: RESULT.boundedSampleEnriched,
166
+ [ACTION.regenerateStaleMessages]: RESULT.staleMessagesRegenerated,
167
+ [ACTION.patchFilterCriteria]: RESULT.filterCriteriaPatched,
168
+ [ACTION.patchSourceCriteria]: RESULT.sourceCriteriaPatched,
169
+ [ACTION.broadenSourceFamily]: RESULT.sourceFamilyBroadened,
170
+ [ACTION.requestRubricTrial]: RESULT.rubricTrialRequested,
171
+ [ACTION.awaitActiveJob]: RESULT.activeJobAwaited,
172
+ [ACTION.recordSourceFrontierExhausted]: RESULT.sourceFrontierExhausted,
173
+ });
174
+ /**
175
+ * Posts-search sub-mechanics the MODEL must respect. They live here because the
176
+ * prompt and the decoder are the two surfaces the model touches; the source
177
+ * dispatcher IMPORTS them rather than restating them.
178
+ */
179
+ export const REFILL_V3_POSTS_KEYWORD_BATCH_SIZE = 5;
180
+ export const REFILL_V3_POSTS_MAX_SELECTED = 5;
181
+ export const REFILL_V3_POSTS_ROUND_BUDGET = 3;
182
+ /** Finite wait guidance so a stuck job can never hold a campaign forever. */
183
+ export const REFILL_V3_ACTIVE_JOB_WAIT_MS = 30_000;
184
+ export function createRefillV3CampaignWorkTaskScope() {
185
+ return { mutatedEvidenceFingerprints: new Set() };
186
+ }
187
+ // ───────────────────────────────────────────────────────────────────────────
188
+ // Hand-rolled guards
189
+ // ───────────────────────────────────────────────────────────────────────────
190
+ function isRecord(value) {
191
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
192
+ }
193
+ function isNonEmptyString(value) {
194
+ return typeof value === "string" && value.trim().length > 0;
195
+ }
196
+ function isOneOf(value, options) {
197
+ return (typeof value === "string" && options.includes(value));
198
+ }
199
+ function isStringArray(value) {
200
+ return Array.isArray(value) && value.every(isNonEmptyString);
201
+ }
202
+ function sameOrder(left, right) {
203
+ if (left.length !== right.length)
204
+ return false;
205
+ return left.every((value, index) => value === right[index]);
206
+ }
207
+ function malformed(detail) {
208
+ return { ok: false, failure: "malformed_output", detail };
209
+ }
210
+ function unsupported(detail) {
211
+ return { ok: false, failure: "unsupported_action", detail };
212
+ }
213
+ const OUTPUT_KEYS = new Set([
214
+ "scopeEcho",
215
+ "actions",
216
+ "consumedDiagnosticRowIds",
217
+ ]);
218
+ const SCOPE_ECHO_KEYS = new Set([
219
+ "campaignId",
220
+ "tableId",
221
+ "campaignVersion",
222
+ "evidenceFingerprint",
223
+ ]);
224
+ const ACTION_KEYS = new Set([
225
+ "action",
226
+ "rationale",
227
+ "rowIds",
228
+ "sourceCommand",
229
+ "candidateVerdicts",
230
+ "templateFingerprint",
231
+ "templateMeaningChanged",
232
+ "exhaustionEvidence",
233
+ "providerPromptRef",
234
+ ]);
235
+ const SOURCE_COMMAND_KEYS = new Set([
236
+ "family",
237
+ "mode",
238
+ "provider",
239
+ "requestFingerprint",
240
+ "query",
241
+ "filterDelta",
242
+ "keywordBatch",
243
+ "selectedPostIds",
244
+ "postRationales",
245
+ ]);
246
+ const EXHAUSTION_KEYS = new Set([
247
+ "supply",
248
+ "activeJob",
249
+ "repairableExistingRows",
250
+ "approvedUndispatchedWork",
251
+ "structurallyUnactionableRows",
252
+ "retryExhaustedRows",
253
+ "scanTermination",
254
+ "searchedNegativeSet",
255
+ "yieldEvidence",
256
+ ]);
257
+ /**
258
+ * DEEP scan for cohort/retry bookkeeping anywhere in the model's raw output.
259
+ *
260
+ * It runs BEFORE decoding so a batch size buried inside a nested command can
261
+ * never reach a dependency. Sizing belongs to the existing adaptive worker and a
262
+ * model-supplied cap is a second selector by another name.
263
+ */
264
+ export function findRefillV3ForbiddenCommandKey(value) {
265
+ const queue = [value];
266
+ for (let index = 0; index < queue.length; index += 1) {
267
+ const current = queue[index];
268
+ if (Array.isArray(current)) {
269
+ for (const entry of current)
270
+ queue.push(entry);
271
+ continue;
272
+ }
273
+ if (!isRecord(current))
274
+ continue;
275
+ for (const key of Object.keys(current)) {
276
+ if (REFILL_V3_FORBIDDEN_COMMAND_KEYS.includes(key)) {
277
+ return key;
278
+ }
279
+ queue.push(current[key]);
280
+ }
281
+ }
282
+ return null;
283
+ }
284
+ function decodeSourceCommand(value) {
285
+ if (!isRecord(value)) {
286
+ return { ok: false, result: malformed("sourceCommand must be an object.") };
287
+ }
288
+ const unknownKey = Object.keys(value).find((key) => !SOURCE_COMMAND_KEYS.has(key));
289
+ if (unknownKey) {
290
+ return {
291
+ ok: false,
292
+ result: malformed(`Unknown sourceCommand field: ${unknownKey}`),
293
+ };
294
+ }
295
+ if (!isOneOf(value.family, REFILL_V3_LEAD_SOURCE_FAMILIES)) {
296
+ return {
297
+ ok: false,
298
+ result: unsupported(`Source family ${String(value.family)} is outside the closed table.`),
299
+ };
300
+ }
301
+ if (!isOneOf(value.mode, REFILL_V3_SOURCE_COMMAND_MODES)) {
302
+ return {
303
+ ok: false,
304
+ result: unsupported(`Source mode ${String(value.mode)} is outside the closed set.`),
305
+ };
306
+ }
307
+ if (!isNonEmptyString(value.requestFingerprint)) {
308
+ return {
309
+ ok: false,
310
+ result: malformed("sourceCommand needs a requestFingerprint."),
311
+ };
312
+ }
313
+ if (!(value.provider === null || isNonEmptyString(value.provider))) {
314
+ return {
315
+ ok: false,
316
+ result: malformed("provider must be a string or null."),
317
+ };
318
+ }
319
+ if (!(value.query === null || typeof value.query === "string")) {
320
+ return { ok: false, result: malformed("query must be a string or null.") };
321
+ }
322
+ if (!(value.filterDelta === null || isRecord(value.filterDelta))) {
323
+ return {
324
+ ok: false,
325
+ result: malformed("filterDelta must be an object or null."),
326
+ };
327
+ }
328
+ let keywordBatch = null;
329
+ if (value.keywordBatch !== null && value.keywordBatch !== undefined) {
330
+ if (!isStringArray(value.keywordBatch)) {
331
+ return {
332
+ ok: false,
333
+ result: malformed("keywordBatch must be an array of keywords."),
334
+ };
335
+ }
336
+ if (value.keywordBatch.length !== REFILL_V3_POSTS_KEYWORD_BATCH_SIZE) {
337
+ return {
338
+ ok: false,
339
+ result: unsupported(`A posts-search round carries exactly ${REFILL_V3_POSTS_KEYWORD_BATCH_SIZE} keywords; ${value.keywordBatch.length} were supplied.`),
340
+ };
341
+ }
342
+ keywordBatch = [...value.keywordBatch];
343
+ }
344
+ let selectedPostIds = null;
345
+ if (value.selectedPostIds !== null && value.selectedPostIds !== undefined) {
346
+ if (!isStringArray(value.selectedPostIds)) {
347
+ return {
348
+ ok: false,
349
+ result: malformed("selectedPostIds must be an array of post ids."),
350
+ };
351
+ }
352
+ if (value.selectedPostIds.length > REFILL_V3_POSTS_MAX_SELECTED) {
353
+ return {
354
+ ok: false,
355
+ result: unsupported(`At most ${REFILL_V3_POSTS_MAX_SELECTED} candidate posts per round; ${value.selectedPostIds.length} were selected.`),
356
+ };
357
+ }
358
+ selectedPostIds = [...value.selectedPostIds];
359
+ }
360
+ let postRationales = null;
361
+ if (value.postRationales !== null && value.postRationales !== undefined) {
362
+ if (!Array.isArray(value.postRationales)) {
363
+ return {
364
+ ok: false,
365
+ result: malformed("postRationales must be an array."),
366
+ };
367
+ }
368
+ const decoded = [];
369
+ for (const entry of value.postRationales) {
370
+ if (!isRecord(entry) ||
371
+ !isNonEmptyString(entry.postId) ||
372
+ !isNonEmptyString(entry.expectedYieldBasis)) {
373
+ return {
374
+ ok: false,
375
+ result: malformed("Every selected post needs a postId and an expectedYieldBasis."),
376
+ };
377
+ }
378
+ decoded.push({
379
+ postId: entry.postId,
380
+ expectedYieldBasis: entry.expectedYieldBasis,
381
+ });
382
+ }
383
+ postRationales = decoded;
384
+ }
385
+ // A selected post with no recorded rationale is an unauditable pick.
386
+ if (selectedPostIds !== null) {
387
+ const explained = new Set((postRationales ?? []).map((entry) => entry.postId));
388
+ const unexplained = selectedPostIds.filter((postId) => !explained.has(postId));
389
+ if (unexplained.length > 0) {
390
+ return {
391
+ ok: false,
392
+ result: malformed(`Selected posts without a recorded selection rationale: ${unexplained.join(", ")}`),
393
+ };
394
+ }
395
+ }
396
+ return {
397
+ ok: true,
398
+ value: {
399
+ family: value.family,
400
+ mode: value.mode,
401
+ provider: value.provider,
402
+ requestFingerprint: value.requestFingerprint,
403
+ query: value.query,
404
+ filterDelta: value.filterDelta,
405
+ keywordBatch,
406
+ selectedPostIds,
407
+ postRationales,
408
+ },
409
+ };
410
+ }
411
+ function decodeExhaustionEvidence(value) {
412
+ if (!isRecord(value))
413
+ return null;
414
+ if (Object.keys(value).some((key) => !EXHAUSTION_KEYS.has(key)))
415
+ return null;
416
+ if (!isRecord(value.supply) ||
417
+ !isRecord(value.searchedNegativeSet) ||
418
+ typeof value.repairableExistingRows !== "number" ||
419
+ typeof value.approvedUndispatchedWork !== "number" ||
420
+ typeof value.structurallyUnactionableRows !== "number" ||
421
+ typeof value.retryExhaustedRows !== "number" ||
422
+ typeof value.scanTermination !== "string") {
423
+ return null;
424
+ }
425
+ return value;
426
+ }
427
+ function decodeCandidateVerdicts(value) {
428
+ if (value === null || value === undefined)
429
+ return { ok: true, value: null };
430
+ if (!Array.isArray(value))
431
+ return { ok: false };
432
+ const decoded = [];
433
+ for (const entry of value) {
434
+ if (!isRecord(entry) ||
435
+ !isNonEmptyString(entry.candidateId) ||
436
+ !isNonEmptyString(entry.rubricBasis) ||
437
+ !(entry.verdict === "pass" || entry.verdict === "fail")) {
438
+ return { ok: false };
439
+ }
440
+ decoded.push({
441
+ candidateId: entry.candidateId,
442
+ verdict: entry.verdict,
443
+ rubricBasis: entry.rubricBasis,
444
+ });
445
+ }
446
+ return { ok: true, value: decoded };
447
+ }
448
+ function decodeActionEntry(value) {
449
+ if (!isRecord(value)) {
450
+ return {
451
+ ok: false,
452
+ result: malformed("Every action entry must be an object, never prose."),
453
+ };
454
+ }
455
+ const unknownKey = Object.keys(value).find((key) => !ACTION_KEYS.has(key));
456
+ if (unknownKey) {
457
+ return {
458
+ ok: false,
459
+ result: malformed(`Unknown action field: ${unknownKey}`),
460
+ };
461
+ }
462
+ if (!isRefillV3CampaignAttentionAction(value.action)) {
463
+ return {
464
+ ok: false,
465
+ result: unsupported(`Action ${String(value.action)} is not in the closed vocabulary.`),
466
+ };
467
+ }
468
+ if (!isNonEmptyString(value.rationale)) {
469
+ return {
470
+ ok: false,
471
+ result: malformed("Every action needs a non-empty rationale."),
472
+ };
473
+ }
474
+ let rowIds = null;
475
+ if (value.rowIds !== null && value.rowIds !== undefined) {
476
+ if (!isStringArray(value.rowIds)) {
477
+ return {
478
+ ok: false,
479
+ result: malformed("rowIds must be an array of ids."),
480
+ };
481
+ }
482
+ rowIds = [...value.rowIds];
483
+ }
484
+ let sourceCommand = null;
485
+ if (value.sourceCommand !== null && value.sourceCommand !== undefined) {
486
+ const decoded = decodeSourceCommand(value.sourceCommand);
487
+ if (!decoded.ok)
488
+ return { ok: false, result: decoded.result };
489
+ sourceCommand = decoded.value;
490
+ }
491
+ const verdicts = decodeCandidateVerdicts(value.candidateVerdicts);
492
+ if (!verdicts.ok) {
493
+ return {
494
+ ok: false,
495
+ result: malformed("Every candidate verdict needs an identity, a pass/fail, and a rubric basis."),
496
+ };
497
+ }
498
+ if (!(value.templateFingerprint === null ||
499
+ value.templateFingerprint === undefined ||
500
+ isNonEmptyString(value.templateFingerprint))) {
501
+ return {
502
+ ok: false,
503
+ result: malformed("templateFingerprint must be a string or null."),
504
+ };
505
+ }
506
+ if (value.templateMeaningChanged !== undefined &&
507
+ typeof value.templateMeaningChanged !== "boolean") {
508
+ return {
509
+ ok: false,
510
+ result: malformed("templateMeaningChanged must be a boolean."),
511
+ };
512
+ }
513
+ let exhaustionEvidence = null;
514
+ if (value.exhaustionEvidence !== null &&
515
+ value.exhaustionEvidence !== undefined) {
516
+ exhaustionEvidence = decodeExhaustionEvidence(value.exhaustionEvidence);
517
+ if (exhaustionEvidence === null) {
518
+ return {
519
+ ok: false,
520
+ result: malformed("exhaustionEvidence does not match the shared shape."),
521
+ };
522
+ }
523
+ }
524
+ let providerPromptRef = null;
525
+ if (value.providerPromptRef !== null &&
526
+ value.providerPromptRef !== undefined) {
527
+ const ref = value.providerPromptRef;
528
+ if (!isRecord(ref) ||
529
+ !isNonEmptyString(ref.provider) ||
530
+ !isNonEmptyString(ref.campaignOfferId)) {
531
+ return {
532
+ ok: false,
533
+ result: malformed("providerPromptRef needs an exact provider and campaignOfferId."),
534
+ };
535
+ }
536
+ providerPromptRef = {
537
+ provider: ref.provider,
538
+ campaignOfferId: ref.campaignOfferId,
539
+ };
540
+ }
541
+ return {
542
+ ok: true,
543
+ value: {
544
+ action: value.action,
545
+ rationale: value.rationale,
546
+ rowIds,
547
+ sourceCommand,
548
+ candidateVerdicts: verdicts.value,
549
+ templateFingerprint: typeof value.templateFingerprint === "string"
550
+ ? value.templateFingerprint
551
+ : null,
552
+ templateMeaningChanged: value.templateMeaningChanged === true,
553
+ exhaustionEvidence,
554
+ providerPromptRef,
555
+ },
556
+ };
557
+ }
558
+ /**
559
+ * SOLE decoder for the traversal's model output.
560
+ *
561
+ * The model answers with an ARRAY so zero, multiple, and duplicate choices each
562
+ * get their OWN typed failure instead of being silently collapsed into "the
563
+ * first one" — which is how a model that hedged two actions gets read as having
564
+ * decided one.
565
+ */
566
+ export function decodeRefillV3CampaignWorkOutput(value) {
567
+ if (!isRecord(value)) {
568
+ return malformed("The traversal output must be an object, never narration.");
569
+ }
570
+ const unknownKey = Object.keys(value).find((key) => !OUTPUT_KEYS.has(key));
571
+ if (unknownKey) {
572
+ return malformed(`Unknown output field: ${unknownKey}`);
573
+ }
574
+ const echo = value.scopeEcho;
575
+ if (!isRecord(echo)) {
576
+ return malformed("The output must echo the selected scope.");
577
+ }
578
+ if (Object.keys(echo).some((key) => !SCOPE_ECHO_KEYS.has(key))) {
579
+ return malformed("scopeEcho carries a field outside the closed set.");
580
+ }
581
+ if (!isNonEmptyString(echo.campaignId) ||
582
+ !isNonEmptyString(echo.tableId) ||
583
+ !Number.isInteger(echo.campaignVersion) ||
584
+ !isNonEmptyString(echo.evidenceFingerprint)) {
585
+ return malformed("scopeEcho is incomplete.");
586
+ }
587
+ if (!Array.isArray(value.actions)) {
588
+ return malformed("actions must be an array so arity is decidable.");
589
+ }
590
+ if (value.actions.length === 0) {
591
+ return {
592
+ ok: false,
593
+ failure: "zero_actions",
594
+ detail: "The model returned no action.",
595
+ };
596
+ }
597
+ if (value.actions.length > 1) {
598
+ const tokens = value.actions.map((entry) => isRecord(entry) ? String(entry.action) : "");
599
+ const distinct = new Set(tokens);
600
+ return distinct.size === 1
601
+ ? {
602
+ ok: false,
603
+ failure: "duplicate_action",
604
+ detail: `The model repeated ${tokens[0]} ${tokens.length} times.`,
605
+ }
606
+ : {
607
+ ok: false,
608
+ failure: "multiple_actions",
609
+ detail: `The model returned ${distinct.size} distinct actions: ${[...distinct].join(", ")}`,
610
+ };
611
+ }
612
+ const decodedAction = decodeActionEntry(value.actions[0]);
613
+ if (!decodedAction.ok)
614
+ return decodedAction.result;
615
+ let consumed = null;
616
+ if (value.consumedDiagnosticRowIds !== null &&
617
+ value.consumedDiagnosticRowIds !== undefined) {
618
+ if (!isStringArray(value.consumedDiagnosticRowIds)) {
619
+ return malformed("consumedDiagnosticRowIds must be an array of row ids.");
620
+ }
621
+ consumed = [...value.consumedDiagnosticRowIds];
622
+ }
623
+ return {
624
+ ok: true,
625
+ value: {
626
+ scopeEcho: {
627
+ campaignId: echo.campaignId,
628
+ tableId: echo.tableId,
629
+ campaignVersion: echo.campaignVersion,
630
+ evidenceFingerprint: echo.evidenceFingerprint,
631
+ },
632
+ actions: [decodedAction.value],
633
+ consumedDiagnosticRowIds: consumed,
634
+ },
635
+ };
636
+ }
637
+ // ───────────────────────────────────────────────────────────────────────────
638
+ // The one traversal iteration
639
+ // ───────────────────────────────────────────────────────────────────────────
640
+ function refuse(callLog, failure, detail, result = null) {
641
+ return { ok: false, failure, detail, result, callLog };
642
+ }
643
+ /**
644
+ * Run ONE selected-campaign attention iteration.
645
+ *
646
+ * Ordering is load-bearing and every early refusal makes ZERO dependency calls:
647
+ *
648
+ * health → cohort bookkeeping → decode → exact scope → task-local replay →
649
+ * bounded diagnose → live-job gate → canonical order → repair-first →
650
+ * enrich-before-rubric → template meaning → provider prompt → negative set →
651
+ * exhaustion falsification → ONE authority → receipt → ONE re-observation.
652
+ */
653
+ export async function runRefillV3CampaignWork(input) {
654
+ const { packet, dependencies } = input;
655
+ const callLog = [];
656
+ // 1. Connection health is Plan 10's, never this seam's. It outranks every
657
+ // other branch, including a legitimate live-job wait: editing config to
658
+ // mask a disconnected sender is the defect this refusal exists to stop.
659
+ if ((input.healthEvidence ?? []).length > 0) {
660
+ return refuse(callLog, "health_recheck_required", `Connection-health evidence must be handled before campaign work: ${(input.healthEvidence ?? []).join(", ")}`, RESULT.healthRecheckRequired);
661
+ }
662
+ // 2. Cohort/retry bookkeeping anywhere in the raw output.
663
+ const forbiddenKey = findRefillV3ForbiddenCommandKey(input.output);
664
+ if (forbiddenKey) {
665
+ return refuse(callLog, "cohort_arithmetic_forbidden", `The model supplied ${forbiddenKey}; sizing and replay bookkeeping belong to the deterministic owners.`);
666
+ }
667
+ // 3. One closed action.
668
+ const decoded = decodeRefillV3CampaignWorkOutput(input.output);
669
+ if (!decoded.ok) {
670
+ return refuse(callLog, decoded.failure, decoded.detail);
671
+ }
672
+ const chosen = decoded.value.actions[0];
673
+ const echo = decoded.value.scopeEcho;
674
+ // 4. Exact scope, then drift. Foreign identity and stale identity are
675
+ // DIFFERENT facts and must never collapse into one reason.
676
+ if (echo.campaignId !== packet.scope.campaignId ||
677
+ echo.tableId !== packet.scope.tableId) {
678
+ return refuse(callLog, "foreign_scope", `The output names ${echo.campaignId}/${echo.tableId}, not the selected ${packet.scope.campaignId}/${packet.scope.tableId}.`);
679
+ }
680
+ if (echo.campaignVersion !== packet.scope.campaignVersion ||
681
+ echo.evidenceFingerprint !== packet.scope.evidenceFingerprint) {
682
+ return refuse(callLog, "stale_evidence", `The output was formed against version ${echo.campaignVersion}/${echo.evidenceFingerprint}, not ${packet.scope.campaignVersion}/${packet.scope.evidenceFingerprint}.`);
683
+ }
684
+ // 5. One evidence packet can authorize at most one mutation per task.
685
+ if (input.taskScope?.mutatedEvidenceFingerprints.has(packet.scope.evidenceFingerprint)) {
686
+ return refuse(callLog, "second_mutation_forbidden", `Evidence ${packet.scope.evidenceFingerprint} already authorized one mutation; re-observe before choosing again.`);
687
+ }
688
+ // 6. The bounded diagnostic read. Exactly one call, capped at the canonical
689
+ // sample size, scoped to the selected campaign and table.
690
+ const sample = await dependencies.loadDiagnosticSample({
691
+ workspaceId: packet.scope.workspaceId,
692
+ campaignId: packet.scope.campaignId,
693
+ tableId: packet.scope.tableId,
694
+ limit: REFILL_V3_DIAGNOSTIC_SAMPLE_CAP,
695
+ });
696
+ callLog.push({
697
+ step: "diagnose",
698
+ detail: `${packet.scope.campaignId}:${packet.scope.tableId}:limit=${REFILL_V3_DIAGNOSTIC_SAMPLE_CAP}`,
699
+ });
700
+ // 7. Live-job gate, re-checked at the DISPATCH boundary against the shared
701
+ // TABLE frontier so two senders enrolled on one table cannot both dispatch.
702
+ const packetLiveJob = packet.evidence.activeJob;
703
+ const dispatchBoundaryLiveJob = packetLiveJob === null
704
+ ? await dependencies.readLiveTableJob({ tableId: packet.scope.tableId })
705
+ : null;
706
+ const liveJob = packetLiveJob ?? dispatchBoundaryLiveJob;
707
+ if (liveJob !== null) {
708
+ // A model must obey a live job that was already in its packet. A job first
709
+ // observed (or revived) at the dispatch boundary is newer deterministic
710
+ // evidence the model could not have predicted, so the authority converts
711
+ // the stale mutation proposal into the only safe transition: a bounded
712
+ // wait with zero dispatch.
713
+ const becameLiveAtDispatchBoundary = packetLiveJob === null && dispatchBoundaryLiveJob !== null;
714
+ if (chosen.action !== ACTION.awaitActiveJob &&
715
+ !becameLiveAtDispatchBoundary) {
716
+ return refuse(callLog, "live_job_blocks_action", `A live ${liveJob.kind} job (${liveJob.status}) holds this frontier; the only legal action is a bounded wait.`);
717
+ }
718
+ callLog.push({ step: "await_active_job", detail: liveJob.id });
719
+ const reobservation = await dependencies.reobserveExactSenderDates({
720
+ workspaceId: packet.scope.workspaceId,
721
+ senderId: packet.scope.senderId,
722
+ date: packet.scope.date,
723
+ });
724
+ callLog.push({
725
+ step: "reobserve",
726
+ detail: `${packet.scope.senderId}:${packet.scope.date}`,
727
+ });
728
+ return {
729
+ ok: true,
730
+ action: ACTION.awaitActiveJob,
731
+ result: RESULT.activeJobAwaited,
732
+ receipt: {
733
+ outcome: "no_effect",
734
+ result: RESULT.activeJobAwaited,
735
+ scopeEcho: scopeEchoOf(packet),
736
+ idempotencyKey: idempotencyKeyOf(input, ACTION.awaitActiveJob),
737
+ beforeFingerprint: null,
738
+ afterFingerprint: null,
739
+ changedFields: [],
740
+ effectKey: null,
741
+ rollback: null,
742
+ reobserveRequired: true,
743
+ preparationDelegation: null,
744
+ systemicError: false,
745
+ lowPass: false,
746
+ refusalReason: null,
747
+ },
748
+ reobservation,
749
+ wait: {
750
+ activeJob: liveJob,
751
+ waitMs: REFILL_V3_ACTIVE_JOB_WAIT_MS,
752
+ reobserveRequired: true,
753
+ resumeGuidance: `Re-observe this sender/date after ${REFILL_V3_ACTIVE_JOB_WAIT_MS}ms and choose again from fresh evidence. The wait is finite: a job that is still live on the next observation is a stuck job, not a reason to block.`,
754
+ },
755
+ nextCohortCap: null,
756
+ callLog,
757
+ };
758
+ }
759
+ // 8. The backend owns the canonical bounded cohort. A model may echo an
760
+ // already-issued order for backward compatibility, but it never needs a
761
+ // discovery/retry call merely to learn row ids. Any supplied prefix,
762
+ // offset, or reordering is still refused.
763
+ if (decoded.value.consumedDiagnosticRowIds !== null &&
764
+ !sameOrder(decoded.value.consumedDiagnosticRowIds, sample.orderedRowIds)) {
765
+ return refuse(callLog, "diagnostic_order_not_consumed", `The echoed order [${decoded.value.consumedDiagnosticRowIds.join(",")}] is not the canonical order [${sample.orderedRowIds.join(",")}].`);
766
+ }
767
+ // 9. A FAILED job is not active: it routes to bounded just-in-time inspection
768
+ // and then to ONE repair, which OUTRANKS rubric relaxation and broadening.
769
+ if (packet.evidence.failedJobRef !== null) {
770
+ if (sample.failureEvidence.length === 0) {
771
+ return refuse(callLog, "stale_evidence", `The packet names failed job ${packet.evidence.failedJobRef} but the bounded inspection returned no failure evidence.`);
772
+ }
773
+ if (!REFILL_V3_REPAIR_FIRST_ACTIONS.includes(chosen.action)) {
774
+ return refuse(callLog, "rubric_before_viable_repair", `An exact repair for failed job ${packet.evidence.failedJobRef} outranks ${chosen.action}.`);
775
+ }
776
+ }
777
+ // 10. Enrich BEFORE any rubric judgement. A rubric verdict over an unenriched
778
+ // sample is a false negative because the fields it reads do not exist yet
779
+ // (incident 112k), so it can never justify relaxation, broadening, or
780
+ // exhaustion.
781
+ if (sample.unenrichedRowIds.length > 0 &&
782
+ REFILL_V3_ENRICHMENT_GATED_ACTIONS.includes(chosen.action)) {
783
+ return refuse(callLog, "rubric_before_enrichment", `${sample.unenrichedRowIds.length} sampled rows are unenriched; ${chosen.action} is invalid until the bounded sample is enriched.`);
784
+ }
785
+ // 11. Template/sequence MEANING exits to the existing approval boundary. It
786
+ // cannot be smuggled through repair, source work, or rubric relaxation.
787
+ if (chosen.templateMeaningChanged) {
788
+ return refuse(callLog, "template_meaning_requires_approval", "A template or sequence meaning change belongs to the separate approval boundary.", RESULT.noActionAvailable);
789
+ }
790
+ // 12. Row selection must stay inside the canonical bounded frontier.
791
+ const resolvedRowIds = resolveRowIds(chosen, sample);
792
+ if (resolvedRowIds !== null) {
793
+ const canonical = new Set(sample.orderedRowIds);
794
+ const outside = resolvedRowIds.filter((rowId) => !canonical.has(rowId));
795
+ if (outside.length > 0) {
796
+ return refuse(callLog, "diagnostic_order_not_consumed", `Rows outside the canonical bounded frontier: ${outside.join(", ")}`);
797
+ }
798
+ }
799
+ // 13. Searched-negative-set replay. Checked BEFORE any provider guidance load
800
+ // so an identical search costs zero provider work.
801
+ if (chosen.sourceCommand !== null &&
802
+ packet.evidence.searchedNegativeSetFingerprints.includes(chosen.sourceCommand.requestFingerprint)) {
803
+ return refuse(callLog, "negative_set_replay", `Request fingerprint ${chosen.sourceCommand.requestFingerprint} is already in the searched negative set for this campaign.`);
804
+ }
805
+ // 14. Exactly ONE matching active provider prompt for a new provider search or
806
+ // a prompt-package-bound exhaustion record; exactly ZERO on a drain.
807
+ const promptGate = resolveProviderPromptRequirement(chosen, packet);
808
+ if (promptGate.kind === "refuse") {
809
+ return refuse(callLog, promptGate.failure, promptGate.detail);
810
+ }
811
+ let providerPrompt = null;
812
+ if (promptGate.kind === "load") {
813
+ providerPrompt = await dependencies.loadProviderPrompt({
814
+ provider: promptGate.provider,
815
+ campaignOfferId: packet.scope.campaignId,
816
+ });
817
+ callLog.push({
818
+ step: "load_provider_prompt",
819
+ detail: `${promptGate.provider}:${packet.scope.campaignId}`,
820
+ });
821
+ if (providerPrompt === null) {
822
+ return refuse(callLog, "provider_prompt_missing", `${promptGate.provider} has no active campaign-scoped guidance for ${packet.scope.campaignId}; missing guidance can never license a search or prove exhaustion.`);
823
+ }
824
+ }
825
+ // 15. Every exhaustion claim is falsified against the SHARED predicate. The
826
+ // AI never terminates a campaign on its own word.
827
+ let exhaustionVerdict = null;
828
+ if (chosen.action === ACTION.recordSourceFrontierExhausted) {
829
+ if (chosen.exhaustionEvidence === null) {
830
+ return refuse(callLog, "stale_evidence", "An exhaustion claim must carry the bounded evidence it was formed from.");
831
+ }
832
+ exhaustionVerdict = falsifyRefillV3ExhaustionClaim(chosen.exhaustionEvidence);
833
+ if (!exhaustionVerdict.exhausted) {
834
+ return refuse(callLog, "exhaustion_falsified", `Live evidence contradicts the exhaustion claim: ${exhaustionVerdict.falsifyingClause}. The flow continues.`);
835
+ }
836
+ }
837
+ // 16. EXACTLY ONE authority dispatch. Duplicate-work identity keys to the
838
+ // TABLE frontier, never to the enrolled sender (incident 112ak).
839
+ const request = {
840
+ action: chosen.action,
841
+ scope: { ...packet.scope },
842
+ frontierKey: `${packet.scope.tableId}:${chosen.action}`,
843
+ idempotencyKey: idempotencyKeyOf(input, chosen.action),
844
+ rowIds: resolvedRowIds,
845
+ sourceCommand: chosen.sourceCommand,
846
+ candidateVerdicts: chosen.candidateVerdicts,
847
+ providerPrompt,
848
+ exhaustionVerdict,
849
+ };
850
+ const receipt = await dependencies.dispatchAuthority(request);
851
+ callLog.push({ step: "authority", detail: chosen.action });
852
+ const receiptCheck = validateReceipt(receipt, request, chosen);
853
+ if (receiptCheck !== null) {
854
+ return refuse(callLog, receiptCheck.failure, receiptCheck.detail);
855
+ }
856
+ // 17. Immediate exact sender/date two-lane re-observation, then STOP.
857
+ const reobservation = await dependencies.reobserveExactSenderDates({
858
+ workspaceId: packet.scope.workspaceId,
859
+ senderId: packet.scope.senderId,
860
+ date: packet.scope.date,
861
+ });
862
+ callLog.push({
863
+ step: "reobserve",
864
+ detail: `${packet.scope.senderId}:${packet.scope.date}`,
865
+ });
866
+ input.taskScope?.mutatedEvidenceFingerprints.add(packet.scope.evidenceFingerprint);
867
+ return {
868
+ ok: true,
869
+ action: chosen.action,
870
+ result: receipt.result,
871
+ receipt,
872
+ reobservation,
873
+ wait: null,
874
+ // The shrink-back rule is BOUND to the worker's own low-pass guard; this
875
+ // layer names the canonical bounded cap and computes no cohort of its own.
876
+ nextCohortCap: receipt.systemicError || receipt.lowPass
877
+ ? REFILL_V3_DIAGNOSTIC_SAMPLE_CAP
878
+ : null,
879
+ callLog,
880
+ };
881
+ }
882
+ function scopeEchoOf(packet) {
883
+ return `${packet.scope.workspaceId}:${packet.scope.campaignId}:${packet.scope.tableId}`;
884
+ }
885
+ function idempotencyKeyOf(input, action) {
886
+ if (isNonEmptyString(input.idempotencyKey))
887
+ return input.idempotencyKey;
888
+ const { scope } = input.packet;
889
+ return `${scope.workspaceId}:${scope.campaignId}:${scope.tableId}:${scope.evidenceFingerprint}:${action}`;
890
+ }
891
+ /**
892
+ * Every row-scoped action consumes the diagnostic authority's exact bounded
893
+ * cohort. The model chooses WHAT to do; it never chooses the batch.
894
+ */
895
+ function resolveRowIds(chosen, sample) {
896
+ if (chosen.action === ACTION.enrichBoundedSample ||
897
+ chosen.action === ACTION.regenerateStaleMessages) {
898
+ return sample.orderedRowIds;
899
+ }
900
+ if (chosen.action === ACTION.rerunErroredCells)
901
+ return sample.failedRowIds;
902
+ return chosen.rowIds;
903
+ }
904
+ function resolveProviderPromptRequirement(chosen, packet) {
905
+ const needsGuidance = chosen.action === ACTION.recordSourceFrontierExhausted ||
906
+ (chosen.sourceCommand !== null &&
907
+ chosen.sourceCommand.mode === "new_provider_search");
908
+ if (!needsGuidance) {
909
+ // A drain of already-imported supply and a non-provider post-engager
910
+ // continuation require and mark NO provider guidance. Preloading one anyway
911
+ // contradicts the declared mode.
912
+ if (chosen.providerPromptRef !== null) {
913
+ return {
914
+ kind: "refuse",
915
+ failure: "provider_prompt_forbidden_on_drain",
916
+ detail: "This action performs no new provider search, so it must load zero provider prompts.",
917
+ };
918
+ }
919
+ return { kind: "skip" };
920
+ }
921
+ const ref = chosen.providerPromptRef;
922
+ if (ref === null) {
923
+ return {
924
+ kind: "refuse",
925
+ failure: "provider_prompt_mismatch",
926
+ detail: "A new provider search and an exhaustion record each require the matching active provider guidance to be named.",
927
+ };
928
+ }
929
+ if (ref.campaignOfferId !== packet.scope.campaignId) {
930
+ return {
931
+ kind: "refuse",
932
+ failure: "provider_prompt_mismatch",
933
+ detail: `Guidance was named for campaign ${ref.campaignOfferId}, not the selected ${packet.scope.campaignId}.`,
934
+ };
935
+ }
936
+ if (chosen.sourceCommand !== null &&
937
+ chosen.sourceCommand.provider !== null &&
938
+ chosen.sourceCommand.provider !== ref.provider) {
939
+ return {
940
+ kind: "refuse",
941
+ failure: "provider_prompt_mismatch",
942
+ detail: `Guidance was named for ${ref.provider} but the command targets ${chosen.sourceCommand.provider}.`,
943
+ };
944
+ }
945
+ return { kind: "load", provider: ref.provider };
946
+ }
947
+ /**
948
+ * A receipt is the ONLY evidence an effect happened. Missing or uncertain
949
+ * evidence is never success, and an uncertain receipt is reconciled by effect
950
+ * identity rather than assumed to be a no-op.
951
+ */
952
+ function validateReceipt(receipt, request, chosen) {
953
+ if (receipt.outcome === "uncertain") {
954
+ return {
955
+ failure: "delegated_tool_timeout",
956
+ detail: `The ${request.action} authority returned an uncertain effect; reconcile by effect identity before any further mutation.`,
957
+ };
958
+ }
959
+ if (receipt.outcome === "refused") {
960
+ return {
961
+ failure: "receipt_incomplete",
962
+ detail: `The ${request.action} authority refused: ${receipt.refusalReason ?? "no reason supplied"}.`,
963
+ };
964
+ }
965
+ if (!receipt.reobserveRequired) {
966
+ return {
967
+ failure: "receipt_incomplete",
968
+ detail: "Every closed outcome obliges a fresh exact sender/date observation.",
969
+ };
970
+ }
971
+ if (receipt.outcome === "applied") {
972
+ const missing = [];
973
+ if (!isNonEmptyString(receipt.beforeFingerprint))
974
+ missing.push("beforeFingerprint");
975
+ if (!isNonEmptyString(receipt.afterFingerprint))
976
+ missing.push("afterFingerprint");
977
+ if (receipt.changedFields.length === 0)
978
+ missing.push("changedFields");
979
+ if (!isNonEmptyString(receipt.effectKey))
980
+ missing.push("effectKey");
981
+ if (receipt.rollback === null)
982
+ missing.push("rollback");
983
+ if (missing.length > 0) {
984
+ return {
985
+ failure: "receipt_incomplete",
986
+ detail: `An applied receipt is missing: ${missing.join(", ")}.`,
987
+ };
988
+ }
989
+ }
990
+ if (chosen.action === ACTION.regenerateStaleMessages) {
991
+ const delegation = receipt.preparationDelegation;
992
+ const absent = REFILL_V3_PREPARATION_REUSE_POINTS.filter((point) => delegation === null || delegation[point] === undefined);
993
+ if (absent.length > 0) {
994
+ return {
995
+ failure: "receipt_incomplete",
996
+ detail: `Message preparation must consume every reuse point; missing: ${absent.join(", ")}.`,
997
+ };
998
+ }
999
+ }
1000
+ return null;
1001
+ }
1002
+ /**
1003
+ * Exported so the falsification path can never be restated: the AI's exhaustion
1004
+ * claim is answered by the SHARED predicate, not by a local truth table.
1005
+ */
1006
+ export function falsifyRefillV3ExhaustionClaim(evidence) {
1007
+ return REFILL_V3_EXHAUSTION_PREDICATE.evaluate(evidence);
1008
+ }
1009
+ /** The bounded sample cap, re-exported so no consumer computes a cohort. */
1010
+ export const REFILL_V3_CAMPAIGN_WORK_SAMPLE_CAP = REFILL_V3_DIAGNOSTIC_SAMPLE_CAP;
1011
+ /** Guard used by the decoder and by the source dispatcher's boundary pins. */
1012
+ export function isRefillV3CampaignAttentionAction(value) {
1013
+ return (typeof value === "string" &&
1014
+ REFILL_V3_CAMPAIGN_ATTENTION_ACTIONS.includes(value));
1015
+ }